mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 01:39:47 +00:00
Add custom nodes manager UI (#2923)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
202
src/components/dialog/content/manager/ManagerDialogContent.vue
Normal file
202
src/components/dialog/content/manager/ManagerDialogContent.vue
Normal 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>
|
||||
14
src/components/dialog/content/manager/ManagerHeader.vue
Normal file
14
src/components/dialog/content/manager/ManagerHeader.vue
Normal 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>
|
||||
50
src/components/dialog/content/manager/ManagerNavSidebar.vue
Normal file
50
src/components/dialog/content/manager/ManagerNavSidebar.vue
Normal 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>
|
||||
24
src/components/dialog/content/manager/PackInstallButton.vue
Normal file
24
src/components/dialog/content/manager/PackInstallButton.vue
Normal 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>
|
||||
82
src/components/dialog/content/manager/PackStatusMessage.vue
Normal file
82
src/components/dialog/content/manager/PackStatusMessage.vue
Normal 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>
|
||||
23
src/components/dialog/content/manager/PackVersionBadge.vue
Normal file
23
src/components/dialog/content/manager/PackVersionBadge.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
37
src/components/dialog/content/manager/infoPanel/InfoTabs.vue
Normal file
37
src/components/dialog/content/manager/infoPanel/InfoTabs.vue
Normal 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>
|
||||
@@ -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>
|
||||
108
src/components/dialog/content/manager/infoPanel/MarkdownText.vue
Normal file
108
src/components/dialog/content/manager/infoPanel/MarkdownText.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
103
src/components/dialog/content/manager/packCard/PackCard.vue
Normal file
103
src/components/dialog/content/manager/packCard/PackCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
41
src/components/dialog/content/manager/packIcon/PackIcon.vue
Normal file
41
src/components/dialog/content/manager/packIcon/PackIcon.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user