Add custom nodes manager UI (#2923)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-03-08 16:54:08 -07:00
committed by GitHub
parent f53c04834f
commit d8721760f1
42 changed files with 1792 additions and 19 deletions

View File

@@ -0,0 +1,202 @@
<template>
<div
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
:aria-label="$t('manager.title')"
>
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
text
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
@click="toggleSideNav"
/>
<div class="flex flex-1 relative overflow-hidden">
<ManagerNavSidebar
v-if="isSideNavOpen"
:tabs="tabs"
:selected-tab="selectedTab"
@update:selected-tab="handleTabSelection"
/>
<div
class="flex-1 overflow-auto"
:class="{
'transition-all duration-300': isSmallScreen,
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen,
'pr-80': showInfoPanel
}"
>
<div class="px-6 pt-6 flex flex-col h-full">
<RegistrySearchBar
v-if="!hideSearchBar"
v-model:searchQuery="searchQuery"
:searchResults="searchResults"
@update:sortBy="handleSortChange"
@update:filterBy="handleFilterChange"
/>
<div class="flex-1 overflow-auto">
<NoResultsPlaceholder
v-if="error || searchResults.length === 0"
:title="
error
? $t('manager.errorConnecting')
: $t('manager.noResultsFound')
"
:message="
error
? $t('manager.tryAgainLater')
: $t('manager.tryDifferentSearch')
"
/>
<div
v-else-if="isLoading"
class="flex justify-center items-center h-full"
>
<ProgressSpinner />
</div>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
:items="resultsWithKeys"
:defaultItemSize="DEFAULT_CARD_SIZE"
class="p-0 m-0 max-w-full"
:buffer-rows="2"
:gridStyle="{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${DEFAULT_CARD_SIZE}px, 1fr))`,
padding: '0.5rem',
gap: '1.125rem 1.25rem',
justifyContent: 'stretch'
}"
>
<template #item="{ item }">
<div
class="relative w-full aspect-square cursor-pointer"
@click.stop="(event) => selectNodePack(item, event)"
>
<PackCard
:node-pack="item"
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
/>
</div>
</template>
</VirtualGrid>
</div>
</div>
</div>
</div>
<div
v-if="showInfoPanel"
class="w-80 border-l-0 border-surface-border absolute right-0 top-0 bottom-0 flex z-20"
>
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections"
:node-pack="selectedNodePack"
/>
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ManagerNavSidebar from '@/components/dialog/content/manager/ManagerNavSidebar.vue'
import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.vue'
import InfoPanelMultiItem from '@/components/dialog/content/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import type { NodeField, TabItem } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_CARD_SIZE = 512
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const hideSearchBar = computed(() => isSmallScreen.value && showInfoPanel.value)
const tabs = ref<TabItem[]>([
{ id: 'all', label: 'All', icon: 'pi-list' },
{ id: 'community', label: 'Community', icon: 'pi-globe' },
{ id: 'installed', label: 'Installed', icon: 'pi-box' }
])
const selectedTab = ref<TabItem>(tabs.value[0])
const handleTabSelection = (tab: TabItem) => {
selectedTab.value = tab
}
const { searchQuery, pageNumber, sortField, isLoading, error, searchResults } =
useRegistrySearch()
pageNumber.value = 1
const resultsWithKeys = computed(() =>
searchResults.value.map((item) => ({
...item,
key: item.id || item.name
}))
)
const selectedNodePacks = ref<components['schemas']['Node'][]>([])
const selectedNodePack = computed(() =>
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
)
const selectNodePack = (
nodePack: components['schemas']['Node'],
event: MouseEvent
) => {
// Handle multi-select with Shift or Ctrl/Cmd key
if (event.shiftKey || event.ctrlKey || event.metaKey) {
const index = selectedNodePacks.value.findIndex(
(pack) => pack.id === nodePack.id
)
if (index === -1) {
// Add to selection if not already selected
selectedNodePacks.value.push(nodePack)
} else {
// Remove from selection if already selected
selectedNodePacks.value.splice(index, 1)
}
} else {
// Single select behavior
selectedNodePacks.value = [nodePack]
}
}
const unSelectItems = () => {
selectedNodePacks.value = []
}
const handleGridContainerClick = (event: MouseEvent) => {
const targetElement = event.target as HTMLElement
if (targetElement && !targetElement.closest('[data-virtual-grid-item]')) {
unSelectItems()
}
}
const showInfoPanel = computed(() => selectedNodePacks.value.length > 0)
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
const currentFilterBy = ref('all')
const handleSortChange = (sortBy: NodeField) => {
sortField.value = sortBy
}
const handleFilterChange = (filterBy: NodeField) => {
currentFilterBy.value = filterBy
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div class="w-full">
<div class="px-6 py-4">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
</div>
<ContentDivider :width="0.3" />
</div>
</template>
<script setup lang="ts">
import ContentDivider from '@/components/common/ContentDivider.vue'
</script>

View File

@@ -0,0 +1,50 @@
<template>
<aside
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
>
<ScrollPanel class="w-80 mt-7">
<Listbox
:model-value="selectedTab"
:options="tabs"
optionLabel="label"
listStyle="max-height:unset"
:pt="{
root: { class: 'w-full border-0 bg-transparent' },
list: { class: 'p-5' },
option: { class: 'px-8 py-3 text-lg rounded-xl' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
@update:model-value="handleTabSelection"
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'mr-3']"></i>
<span class="text-lg">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" />
</aside>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { TabItem } from '@/types/comfyManagerTypes'
defineProps<{
tabs: TabItem[]
selectedTab: TabItem
}>()
const emit = defineEmits<{
'update:selectedTab': [value: TabItem]
}>()
const handleTabSelection = (tab: TabItem) => {
emit('update:selectedTab', tab)
}
</script>

View File

@@ -0,0 +1,24 @@
<template>
<Button
outlined
class="m-0 p-0 rounded-lg border-neutral-700"
severity="secondary"
:class="{
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
>
<span class="py-2.5 px-3">
{{ multi ? $t('manager.installSelected') : $t('g.install') }}
</span>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
defineProps<{
fullWidth?: boolean
multi?: boolean
}>()
</script>

View File

@@ -0,0 +1,82 @@
<template>
<Message
:severity="statusSeverity"
class="p-0 flex items-center rounded-xl break-words w-fit"
:pt="{
text: { class: 'text-xs' },
content: { class: 'px-2 py-0.5' }
}"
>
<i
class="pi pi-circle-fill mr-1.5 text-[0.6rem] p-0"
:style="{ opacity: 0.8 }"
></i>
{{ $t(`manager.status.${statusLabel}`) }}
</Message>
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { components } from '@/types/comfyRegistryTypes'
import { VueSeverity } from '@/types/primeVueTypes'
const { t } = useI18n()
type PackVersionStatus = components['schemas']['NodeVersionStatus']
type PackStatus = components['schemas']['NodeStatus']
type Status = PackVersionStatus | PackStatus
type StatusProps = {
label: string
severity: VueSeverity
}
const { statusType } = defineProps<{
statusType: Status
}>()
const statusPropsMap: Record<Status, StatusProps> = {
NodeStatusActive: {
label: 'active',
severity: 'success'
},
NodeStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeStatusBanned: {
label: 'banned',
severity: 'danger'
},
NodeVersionStatusActive: {
label: 'active',
severity: 'success'
},
NodeVersionStatusPending: {
label: 'pending',
severity: 'warn'
},
NodeVersionStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeVersionStatusFlagged: {
label: 'flagged',
severity: 'danger'
},
NodeVersionStatusBanned: {
label: 'banned',
severity: 'danger'
}
}
const statusLabel = computed(
() => statusPropsMap[statusType]?.label || 'unknown'
)
const statusSeverity = computed(
() => statusPropsMap[statusType]?.severity || 'secondary'
)
</script>

View File

@@ -0,0 +1,23 @@
<template>
<Button
v-if="version"
:label="version"
severity="secondary"
icon="pi pi-chevron-right"
icon-pos="right"
class="rounded-xl text-xs tracking-tighter"
:pt="{
root: { class: 'p-0' },
label: { class: 'pl-2 pr-0 py-0.5' },
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
}"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
defineProps<{
version: string | undefined
}>()
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="flex flex-col h-full z-40 hidden-scrollbar w-80">
<div class="p-6 flex-1 overflow-hidden text-sm">
<PackCardHeader
:node-pack="nodePack"
:install-button-full-width="false"
/>
<div class="mb-6">
<MetadataRow
v-for="item in infoItems"
v-show="item.value !== undefined && item.value !== null"
:key="item.key"
:label="item.label"
:value="item.value"
/>
<MetadataRow :label="t('g.status')">
<PackStatusMessage
:status-type="
nodePack.status as components['schemas']['NodeVersionStatus']
"
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge :version="nodePack.latest_version?.version" />
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs :node-pack="nodePack" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
interface InfoItem {
key: string
label: string
value: string | number | undefined
}
const { t } = useI18n()
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
label: `${t('manager.createdBy')}`,
value: nodePack.publisher?.name
},
{
key: 'downloads',
label: t('manager.downloads'),
value: nodePack.downloads ? formatNumber(nodePack.downloads) : undefined
},
{
key: 'lastUpdated',
label: t('manager.lastUpdated'),
value: nodePack.latest_version?.createdAt
? new Date(nodePack.latest_version.createdAt).toLocaleDateString()
: undefined
}
])
</script>
<style scoped>
.hidden-scrollbar {
height: 100%;
overflow-y: auto;
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<div class="flex flex-col h-full">
<div class="p-6 flex-1 overflow-auto">
<PackCardHeader>
<template #thumbnail>
<PackIconStacked :node-packs="nodePacks" />
</template>
<template #title>
{{ nodePacks.length }}
{{ $t('manager.packsSelected') }}
</template>
<template #install-button>
<PackInstallButton :full-width="true" :multi="true" />
</template>
</PackCardHeader>
<div class="mb-6">
<MetadataRow :label="$t('g.status')">
<PackStatusMessage status-type="NodeVersionStatusActive" />
</MetadataRow>
<MetadataRow
:label="$t('manager.totalNodes')"
:value="totalNodesCount"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed } from 'vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import PackIconStacked from '@/components/dialog/content/manager/packIcon/PackIconStacked.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { components } from '@/types/comfyRegistryTypes'
const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
}>()
const comfyRegistryService = useComfyRegistryService()
const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!comfyRegistryService.packNodesAvailable(pack)) return []
return comfyRegistryService.getNodeDefs({
packId: pack.id,
versionId: pack.latest_version.id
})
}
const { state: allNodeDefs } = useAsyncState(
() => Promise.all(nodePacks.map(getPackNodes)),
[],
{
immediate: true
}
)
const totalNodesCount = computed(() =>
allNodeDefs.value.reduce(
(total, nodeDefs) => total + (nodeDefs?.length || 0),
0
)
)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList>
<Tab value="description">{{ $t('g.description') }}</Tab>
<Tab value="nodes">{{ $t('g.nodes') }}</Tab>
</TabList>
<TabPanels class="overflow-auto">
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
<TabPanel value="nodes">
<NodesTabPanel :node-pack="nodePack" />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { ref } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack: components['schemas']['Node']
}>()
const activeTab = ref('description')
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<div v-for="(section, index) in sections" :key="index" class="mb-4">
<div class="mb-1">{{ section.title }}</div>
<div class="text-muted break-words">
<a
v-if="section.isUrl"
:href="section.text"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 hover:underline"
>
<i
v-if="isGitHubLink(section.text)"
class="pi pi-github text-base"
></i>
<span class="break-all">{{ section.text }}</span>
</a>
<MarkdownText v-else :text="section.text" class="text-muted" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MarkdownText from '@/components/dialog/content/manager/infoPanel/MarkdownText.vue'
export interface TextSection {
title: string
text: string
isUrl?: boolean
}
defineProps<{
sections: TextSection[]
}>()
const isGitHubLink = (url: string): boolean => url.includes('github.com')
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="hover:underline">
<div v-if="!hasMarkdown" v-text="text" class="break-words"></div>
<div v-else class="break-words">
<template v-for="(segment, index) in parsedSegments" :key="index">
<a
v-if="segment.type === 'link' && 'url' in segment"
:href="segment.url"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
>
<span class="text-blue-600">{{ segment.text }}</span>
</a>
<strong v-else-if="segment.type === 'bold'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'italic'">{{ segment.text }}</em>
<code
v-else-if="segment.type === 'code'"
class="bg-surface-100 px-1 py-0.5 rounded text-xs"
>{{ segment.text }}</code
>
<span v-else>{{ segment.text }}</span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { text } = defineProps<{
text: string
}>()
type MarkdownSegment = {
type: 'text' | 'link' | 'bold' | 'italic' | 'code'
text: string
url?: string
}
const hasMarkdown = computed(() => {
const hasMarkdown =
/(\[.*?\]\(.*?\)|(\*\*|__)(.*?)(\*\*|__)|(\*|_)(.*?)(\*|_)|`(.*?)`)/.test(
text
)
return hasMarkdown
})
const parsedSegments = computed(() => {
if (!hasMarkdown.value) return [{ type: 'text', text }]
const segments: MarkdownSegment[] = []
const remainingText = text
let lastIndex: number = 0
const linkRegex = /\[(.*?)\]\((.*?)\)/g
let linkMatch: RegExpExecArray | null
while ((linkMatch = linkRegex.exec(remainingText)) !== null) {
// Add text before the match
if (linkMatch.index > lastIndex) {
segments.push({
type: 'text',
text: remainingText.substring(lastIndex, linkMatch.index)
})
}
// Add the link
segments.push({
type: 'link',
text: linkMatch[1],
url: linkMatch[2]
})
lastIndex = linkMatch.index + linkMatch[0].length
}
// Add remaining text after all links
if (lastIndex < remainingText.length) {
let rest = remainingText.substring(lastIndex)
// Process bold text
rest = rest.replace(/(\*\*|__)(.*?)(\*\*|__)/g, (_, __, p2) => {
segments.push({ type: 'bold', text: p2 })
return ''
})
// Process italic text
rest = rest.replace(/(\*|_)(.*?)(\*|_)/g, (_, __, p2) => {
segments.push({ type: 'italic', text: p2 })
return ''
})
// Process code
rest = rest.replace(/`(.*?)`/g, (_, p1) => {
segments.push({ type: 'code', text: p1 })
return ''
})
// Add any remaining text
if (rest) {
segments.push({ type: 'text', text: rest })
}
}
return segments
})
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex py-1.5 text-xs">
<div class="w-1/3 text-color-secondary truncate pr-2 text-muted">
{{ label }}:
</div>
<div class="w-2/3">
<slot>{{ value }}</slot>
</div>
</div>
</template>
<script setup lang="ts">
const { value = 'N/A' } = defineProps<{
label: string
value?: string | number
}>()
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div class="mt-4 overflow-hidden">
<InfoTextSection
v-if="nodePack.description"
:sections="descriptionSections"
/>
<p v-else class="text-muted italic text-sm">
{{ $t('manager.noDescription') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import InfoTextSection, {
type TextSection
} from '@/components/dialog/content/manager/infoPanel/InfoTextSection.vue'
import { components } from '@/types/comfyRegistryTypes'
const { t } = useI18n()
const props = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isValidUrl = (url: string): boolean => {
try {
new URL(url)
return true
} catch {
return false
}
}
const isLicenseFile = (filename: string): boolean => {
// Match LICENSE, LICENSE.md, LICENSE.txt (case insensitive)
const licensePattern = /^license(\.md|\.txt)?$/i
return licensePattern.test(filename)
}
const extractBaseRepoUrl = (repoUrl: string): string => {
// Match GitHub repository URL and extract the base URL
const githubRepoPattern = /^(https?:\/\/github\.com\/[^/]+\/[^/]+)/i
const match = repoUrl.match(githubRepoPattern)
return match ? match[1] : repoUrl
}
const createLicenseUrl = (filename: string, repoUrl: string): string => {
if (!repoUrl || !filename) return ''
// Use the filename if it's a license file, otherwise use LICENSE
const licenseFile = isLicenseFile(filename) ? filename : 'LICENSE'
// Get the base repository URL
const baseRepoUrl = extractBaseRepoUrl(repoUrl)
return `${baseRepoUrl}/blob/main/${licenseFile}`
}
const parseLicenseObject = (
licenseObj: any
): { text: string; isUrl: boolean } => {
// Get the license file or text
const licenseFile = licenseObj.file || licenseObj.text
// If it's a string and a license file, create a URL
if (typeof licenseFile === 'string' && isLicenseFile(licenseFile)) {
const url = createLicenseUrl(licenseFile, props.nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
}
// Otherwise use the text directly
else if (licenseObj.text) {
return {
text: licenseObj.text,
isUrl: false
}
}
// Default fallback
return {
text: JSON.stringify(licenseObj),
isUrl: false
}
}
const formatLicense = (license: string): { text: string; isUrl: boolean } => {
try {
const licenseObj = JSON.parse(license)
return parseLicenseObject(licenseObj)
} catch (e) {
// Not JSON, handle as plain string
// If it's a license file, create a URL
if (isLicenseFile(license)) {
const url = createLicenseUrl(license, props.nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
}
// Otherwise return as is
return {
text: license,
isUrl: false
}
}
}
const descriptionSections = computed<TextSection[]>(() => {
const sections: TextSection[] = [
{
title: t('g.description'),
text: props.nodePack.description || t('manager.noDescription')
}
]
if (props.nodePack.repository) {
sections.push({
title: t('manager.repository'),
text: props.nodePack.repository,
isUrl: isValidUrl(props.nodePack.repository)
})
}
if (props.nodePack.license) {
const { text, isUrl } = formatLicense(props.nodePack.license)
if (text) {
sections.push({
title: t('manager.license'),
text,
isUrl
})
}
}
return sections
})
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-4 mt-4 overflow-auto text-sm">
<div v-if="nodeDefs?.length">
<!-- TODO: when registry returns node defs, use them here -->
</div>
<div
v-else
v-for="i in 3"
:key="i"
class="border border-surface-border rounded-lg p-4"
>
<NodePreview :node-def="placeholderNodeDef" />
</div>
</div>
</template>
<script setup lang="ts">
import NodePreview from '@/components/node/NodePreview.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack: components['schemas']['Node']
nodeDefs?: components['schemas']['ComfyNode'][]
}>()
// TODO: when registry returns node defs, use them here
const placeholderNodeDef = {
display_name: 'Sample Node',
description: 'This is a sample node for preview purposes',
inputs: {
input1: { name: 'Input 1', type: 'IMAGE' },
input2: { name: 'Input 2', type: 'CONDITIONING' }
},
outputs: [
{ name: 'Output 1', type: 'IMAGE', index: 0 },
{ name: 'Output 2', type: 'MASK', index: 1 }
]
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<Card
class="absolute inset-0 flex flex-col overflow-hidden rounded-2xl shadow-[0_0_15px_rgba(0,0,0,0.15),0_10px_15px_-3px_rgba(0,0,0,0.12),0_4px_6px_-4px_rgba(0,0,0,0.08)] transition-all duration-200"
:class="{
'bg-[#ffffff08]': !isLightTheme,
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected
}"
:pt="{
body: { class: 'p-0 flex flex-col h-full rounded-2xl' },
content: { class: 'flex-1 flex flex-col rounded-2xl' },
title: { class: 'p-0 m-0' },
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<div class="flex justify-between p-5 pb-1 align-middle text-sm">
<span class="flex items-start mt-2">
<i
class="pi pi-box text-muted text-2xl ml-1 mr-5"
style="opacity: 0.5"
></i>
<span class="text-lg relative top-[.25rem]">{{
$t('manager.nodePack')
}}</span>
</span>
<div class="flex items-center gap-2.5">
<div
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2"></i>
{{ formatNumber(nodePack.downloads) }}
</div>
<PackInstallButton />
</div>
</div>
</template>
<template #content>
<ContentDivider />
<div class="flex flex-1 p-5 mt-3 cursor-pointer">
<div class="flex-shrink-0 mr-4">
<PackIcon :node-pack="nodePack" />
</div>
<div class="flex flex-col flex-1 min-w-0">
<span
class="text-lg font-bold pb-4 truncate overflow-hidden text-ellipsis"
:title="nodePack.name"
>
{{ nodePack.name }}
</span>
<div class="flex-1">
<p
v-if="nodePack.description"
class="text-sm text-color-secondary m-0 line-clamp-3 overflow-hidden"
:title="nodePack.description"
>
{{ nodePack.description }}
</p>
</div>
</div>
</div>
</template>
<template #footer>
<ContentDivider :width="0.1" />
<div class="flex justify-between p-5 text-xs text-muted">
<div class="flex items-center gap-2 cursor-pointer">
<span v-if="nodePack.publisher?.name">
{{ nodePack.publisher.name }}
</span>
<span v-if="nodePack.latest_version">
{{ nodePack.latest_version.version }}
</span>
</div>
<div v-if="nodePack.latest_version" class="flex items-center gap-2">
{{ $t('g.updated') }}
{{ new Date(nodePack.latest_version.createdAt).toLocaleDateString() }}
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import { computed } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import type { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
defineProps<{
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex flex-col items-center mb-6">
<slot name="thumbnail">
<PackIcon :node-pack="nodePack" width="24" height="24" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">{{ nodePack?.name }}</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
<slot name="install-button">
<PackInstallButton
:full-width="installButtonFullWidth"
:multi="multi"
/>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack?: components['schemas']['Node']
multi?: boolean
installButtonFullWidth?: boolean
}>()
</script>

View File

@@ -0,0 +1,41 @@
<template>
<img
:src="isImageError ? DEFAULT_ICON : imgSrc"
:alt="nodePack.name + ' icon'"
class="object-contain rounded-lg"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_ICON = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '4.5rem',
height = '4.5rem'
} = defineProps<{
nodePack: components['schemas']['Node']
width?: string
height?: string
}>()
const isImageError = ref(false)
const shouldShowFallback = computed(
() => !nodePack.icon || nodePack.icon.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_ICON : nodePack.icon
)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="relative w-24 h-24">
<div
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
:key="pack.id"
class="absolute"
:style="{
bottom: `${index * offset}px`,
right: `${index * offset}px`,
zIndex: maxVisible - index
}"
>
<div
class="border border-surface-border bg-surface-card rounded-lg p-0.5"
>
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
</div>
</div>
<div
v-if="nodePacks.length > maxVisible"
class="absolute -top-2 -right-2 bg-primary rounded-full w-7 h-7 flex items-center justify-center text-xs font-bold shadow-md z-10"
>
+{{ nodePacks.length - maxVisible }}
</div>
</div>
</template>
<script setup lang="ts">
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { components } from '@/types/comfyRegistryTypes'
const {
nodePacks,
maxVisible = 3,
offset = 8
} = defineProps<{
nodePacks: components['schemas']['Node'][]
maxVisible?: number
offset?: number
}>()
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div class="relative w-full p-6">
<div class="flex items-center w-full">
<IconField class="w-5/12">
<InputIcon class="pi pi-search" />
<InputText
:model-value="searchQuery"
@update:model-value="$emit('update:searchQuery', $event)"
:placeholder="$t('manager.searchPlaceholder')"
class="w-full rounded-2xl"
autofocus
/>
</IconField>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
<SearchFilterDropdown
v-model="currentFilter"
:options="filterOptions"
:label="$t('g.filter')"
@update:model-value="handleFilterChange"
/>
<SearchFilterDropdown
v-model="currentSort"
:options="sortOptions"
:label="$t('g.sort')"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center gap-4 ml-6">
<small v-if="hasResults" class="text-color-secondary">
{{ $t('g.resultsCount', { count: searchResults.length }) }}
</small>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import type { NodeField, SearchOption } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_SORT: NodeField = 'downloads'
const DEFAULT_FILTER = 'nodePack'
const props = defineProps<{
searchQuery: string
searchResults?: components['schemas']['Node'][]
}>()
const { t } = useI18n()
const currentSort = ref<NodeField>(DEFAULT_SORT)
const currentFilter = ref<string>(DEFAULT_FILTER)
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:sortBy': [value: NodeField]
'update:filterBy': [value: string]
}>()
const hasResults = computed(
() => props.searchQuery.trim() && props.searchResults?.length
)
const sortOptions: SearchOption<NodeField>[] = [
{ id: 'downloads', label: t('manager.sort.downloads') },
{ id: 'name', label: t('g.name') },
{ id: 'rating', label: t('manager.sort.rating') },
{ id: 'category', label: t('g.category') }
]
const filterOptions: SearchOption<string>[] = [
{ id: 'nodePack', label: t('manager.filter.nodePack') },
{ id: 'node', label: t('g.nodes') }
]
const handleSortChange = () => {
// TODO: emit to Algolia service
emit('update:sortBy', currentSort.value)
}
const handleFilterChange = () => {
// TODO: emit to Algolia service
emit('update:filterBy', currentFilter.value)
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<div class="flex items-center gap-1">
<span class="text-muted">{{ label }}:</span>
<Dropdown
v-model="selectedValue"
:options="options"
optionLabel="label"
optionValue="id"
class="min-w-[6rem]"
@change="handleChange"
:pt="{
root: { class: 'border-none' },
input: { class: 'py-0 px-1 border-none' },
trigger: { class: 'hidden' },
panel: { class: 'shadow-md' },
item: { class: 'py-2 px-3 text-sm' }
}"
/>
</div>
</template>
<script setup lang="ts" generic="T">
import Dropdown from 'primevue/dropdown'
import { computed } from 'vue'
import type { SearchOption } from '@/types/comfyManagerTypes'
const { modelValue, options, label } = defineProps<{
modelValue: T
options: SearchOption<T>[]
label: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: T]
}>()
const selectedValue = computed({
get: () => modelValue,
set: (value) => emit('update:modelValue', value)
})
const handleChange = () => {
emit('update:modelValue', selectedValue.value)
}
</script>