Compare commits

...

22 Commits

Author SHA1 Message Date
Robin Huang
2fdb07e2d8 Remove unusued file. 2026-02-03 17:44:01 -08:00
Robin Huang
b17971a75a Bring back templates tab. 2026-02-03 17:42:45 -08:00
Robin Huang
08c35b49f3 Add fake URL when publishing. 2026-02-03 17:17:53 -08:00
Robin Huang
ad849e34ef Fix 2026-02-03 15:28:39 -08:00
Robin Huang
13cdc8109c Link to comfy hub. 2026-02-03 14:49:50 -08:00
Robin Huang
791a1f81d3 Profile page scrolls. 2026-02-03 14:22:18 -08:00
Robin Huang
0c62f09b00 Clean up code. 2026-02-02 19:05:27 -08:00
Robin Huang
f61e754aa4 Improve profile page look and feel. 2026-02-02 18:44:54 -08:00
Robin Huang
8afc9fd20a Add under construction stirng. 2026-02-02 17:40:29 -08:00
Robin Huang
f28c76fff0 Home tab closes app mode. 2026-02-02 17:23:23 -08:00
Robin Huang
11d029fa15 Open workflow after making a copy. 2026-02-02 16:59:32 -08:00
Robin Huang
8af249a046 Move home tab. 2026-02-02 16:21:51 -08:00
Robin Huang
7ddf7bdbc5 Share button tweaks. 2026-02-02 16:02:17 -08:00
Robin Huang
58362c79e9 Add sharing and publishing buttons. 2026-02-02 10:15:41 -08:00
Robin Huang
fd8bcbb090 Make a copy works. 2026-01-31 20:18:21 -08:00
Robin Huang
c5e7ad5e55 Editable workflow. 2026-01-31 19:55:25 -08:00
Robin Huang
c19ea957df Add run count. 2026-01-31 19:36:30 -08:00
Robin Huang
b5d19ace5e Workflow preview added. 2026-01-31 19:30:59 -08:00
Robin Huang
0ffb24b108 Workflow detail page. 2026-01-31 18:59:48 -08:00
Robin Huang
06ed6285ab Remove media asset filter. 2026-01-31 18:33:11 -08:00
Robin Huang
da0d320716 Add initial algolia search. 2026-01-31 18:23:36 -08:00
Robin Huang
9c2e1b6611 Add discover left side tab. 2026-01-31 17:56:26 -08:00
24 changed files with 2642 additions and 13 deletions

View File

@@ -42,7 +42,9 @@ const config: KnipConfig = {
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts'
'src/scripts/ui/components/splitButton.ts',
// Unused experimental publish dialog
'src/components/actionbar/PublishToHubDialogContent.vue'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -2,7 +2,7 @@
<div
class="w-full h-full absolute top-0 left-0 z-999 pointer-events-none flex flex-col"
>
<slot name="workflow-tabs" />
<slot v-if="shouldShowWorkflowTabs" name="workflow-tabs" />
<div
class="pointer-events-none flex flex-1 overflow-hidden"
@@ -25,7 +25,9 @@
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
!focusMode &&
!isFullPageOverlayActive &&
(sidebarLocation === 'left' || anyRightPanelVisible)
"
:class="
sidebarLocation === 'left'
@@ -55,9 +57,22 @@
<!-- Main panel (always present) -->
<SplitterPanel :size="80" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<slot
v-if="!isFullPageOverlayActive"
name="topmenu"
:sidebar-panel-visible
/>
<!-- Full page content (replaces graph canvas when active) -->
<div
v-if="isFullPageOverlayActive"
class="pointer-events-auto flex-1 overflow-hidden bg-comfy-menu-bg"
>
<slot name="full-page-content" />
</div>
<Splitter
v-else
class="bg-transparent pointer-events-none border-none splitter-overlay-bottom mr-1 mb-1 ml-1 flex-1"
layout="vertical"
:pt:gutter="
@@ -85,7 +100,9 @@
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
!focusMode &&
!isFullPageOverlayActive &&
(sidebarLocation === 'right' || anyRightPanelVisible)
"
:class="
sidebarLocation === 'right'
@@ -125,7 +142,9 @@ import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -144,11 +163,25 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { activeSidebarTabId, activeSidebarTab, isFullPageTabActive } =
storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const { isOpen: homePanelOpen } = storeToRefs(useHomePanelStore())
const sharePanelStore = useSharePanelStore()
const { isOpen: sharePanelVisible } = storeToRefs(sharePanelStore)
const anyRightPanelVisible = computed(
() => rightSidePanelVisible.value || sharePanelVisible.value
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
const isFullPageOverlayActive = computed(
() => isFullPageTabActive.value || homePanelOpen.value
)
const shouldShowWorkflowTabs = computed(
() => !isFullPageTabActive.value || homePanelOpen.value
)
const sidebarStateKey = computed(() => {
return unifiedWidth.value
@@ -169,7 +202,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
return `main-splitter${anyRightPanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {

View File

@@ -42,6 +42,7 @@
>
<i class="icon-[lucide--x] size-4" />
</Button>
<ShareButton />
</div>
</Panel>
@@ -80,6 +81,7 @@ import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
import ShareButton from './ShareButton.vue'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null

View File

@@ -0,0 +1,239 @@
<template>
<div
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('discover.share.publishToHubDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="icon-[lucide--x] size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 p-4">
<!-- Thumbnail upload -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-base-foreground">
{{ $t('discover.share.publishToHubDialog.thumbnail') }}
</label>
<div
class="relative flex aspect-video w-full cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-dashed border-border-default bg-secondary-background transition-colors hover:border-border-hover"
@click="triggerThumbnailUpload"
>
<img
v-if="thumbnailPreview"
:src="thumbnailPreview"
:alt="$t('discover.share.publishToHubDialog.thumbnailPreview')"
class="size-full object-cover"
/>
<div v-else class="flex flex-col items-center gap-2 text-center">
<i class="icon-[lucide--image-plus] size-8 text-muted-foreground" />
<span class="text-sm text-muted-foreground">
{{ $t('discover.share.publishToHubDialog.uploadThumbnail') }}
</span>
</div>
<input
ref="thumbnailInput"
type="file"
accept="image/*"
class="hidden"
@change="handleThumbnailChange"
/>
</div>
</div>
<!-- Title -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-base-foreground">
{{ $t('g.title') }}
</label>
<input
v-model="title"
type="text"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
:placeholder="
$t('discover.share.publishToHubDialog.titlePlaceholder')
"
/>
</div>
<!-- Description -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-base-foreground">
{{ $t('g.description') }}
</label>
<textarea
v-model="description"
rows="3"
class="w-full resize-none rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
:placeholder="
$t('discover.share.publishToHubDialog.descriptionPlaceholder')
"
/>
</div>
<!-- Tags -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-base-foreground">
{{ $t('discover.filters.tags') }}
</label>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in tags"
:key="tag"
class="flex items-center gap-1 rounded-full bg-secondary-background px-2.5 py-1 text-xs text-base-foreground"
>
{{ tag }}
<button
class="cursor-pointer border-none bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
@click="removeTag(tag)"
>
<i class="icon-[lucide--x] size-3" />
</button>
</span>
<input
v-model="newTag"
type="text"
class="min-w-24 flex-1 border-none bg-transparent text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none"
:placeholder="$t('discover.share.publishToHubDialog.addTag')"
@keydown.enter.prevent="addTag"
/>
</div>
</div>
<!-- Open source toggle -->
<div class="flex items-center justify-between">
<div class="flex flex-col">
<span class="text-sm font-medium text-base-foreground">
{{ $t('discover.share.publishToHubDialog.openSource') }}
</span>
<span class="text-xs text-muted-foreground">
{{ $t('discover.share.publishToHubDialog.openSourceDescription') }}
</span>
</div>
<button
:class="
cn(
'relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors',
isOpenSource ? 'bg-green-500' : 'bg-secondary-background'
)
"
@click="isOpenSource = !isOpenSource"
>
<span
:class="
cn(
'absolute left-0.5 top-0.5 size-5 rounded-full bg-white shadow-sm transition-transform',
isOpenSource && 'translate-x-5'
)
"
/>
</button>
</div>
</div>
<!-- Footer -->
<div
class="flex items-center justify-end gap-3 border-t border-border-default px-4 py-3"
>
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValid"
@click="onPublish"
>
{{ $t('discover.share.publishToHubDialog.publish') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const { workflow } = defineProps<{
workflow: ComfyWorkflow
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toastStore = useToastStore()
const loading = ref(false)
const thumbnailInput = ref<HTMLInputElement>()
const thumbnailFile = ref<File | null>(null)
const thumbnailPreview = ref('')
const title = ref(workflow.filename ?? '')
const description = ref('')
const tags = ref<string[]>([])
const newTag = ref('')
const isOpenSource = ref(false)
const isValid = computed(
() => title.value.trim().length > 0 && description.value.trim().length > 0
)
function triggerThumbnailUpload() {
thumbnailInput.value?.click()
}
function handleThumbnailChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
thumbnailFile.value = file
thumbnailPreview.value = URL.createObjectURL(file)
}
}
function addTag() {
const tag = newTag.value.trim()
if (tag && !tags.value.includes(tag)) {
tags.value.push(tag)
}
newTag.value = ''
}
function removeTag(tag: string) {
tags.value = tags.value.filter((t) => t !== tag)
}
function onCancel() {
dialogStore.closeDialog({ key: 'publish-to-hub' })
}
async function onPublish() {
loading.value = true
try {
toastStore.add({
severity: 'info',
summary: t('g.comingSoon'),
detail: t('discover.share.publishToHubDialog.notImplemented'),
life: 3000
})
onCancel()
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<Button
:variant="isOpen ? 'primary' : 'secondary'"
size="md"
class="px-3 text-sm font-semibold"
:aria-label="$t('discover.share.share')"
:aria-pressed="isOpen"
@click="handleClick"
>
{{ $t('discover.share.share') }}
</Button>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
const { t } = useI18n()
const workflowStore = useWorkflowStore()
const sharePanelStore = useSharePanelStore()
const toastStore = useToastStore()
const { isOpen } = storeToRefs(sharePanelStore)
function handleClick() {
if (!workflowStore.activeWorkflow) {
toastStore.addAlert(t('discover.share.noActiveWorkflow'))
return
}
sharePanelStore.togglePanel()
}
</script>

View File

@@ -0,0 +1,533 @@
<template>
<div class="flex size-full flex-col bg-comfy-menu-bg">
<!-- Panel Header -->
<section
class="sticky top-0 z-10 border-b border-border-default bg-comfy-menu-bg/95 pt-1 backdrop-blur"
>
<div class="flex items-center justify-between pl-4 pr-3">
<h3 class="my-3.5 text-base font-semibold tracking-tight">
{{ $t('discover.share.share') }}
</h3>
<Button
variant="secondary"
size="icon"
:aria-label="$t('g.close')"
@click="closePanel"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
<nav class="overflow-x-auto px-4 pb-3 pt-1">
<div
class="inline-flex rounded-full border border-border-default bg-secondary-background/70 p-1 shadow-sm"
>
<TabList v-model="activeTab" class="gap-1 pb-0">
<Tab
value="invite"
class="rounded-full px-3 py-1.5 text-xs font-semibold tracking-wide transition-all active:scale-95"
>
<i class="icon-[lucide--users] mr-1.5 size-3.5" />
{{ $t('discover.share.inviteTab.title') }}
</Tab>
<Tab
value="publish"
class="rounded-full px-3 py-1.5 text-xs font-semibold tracking-wide transition-all active:scale-95"
>
<i class="icon-[lucide--upload] mr-1.5 size-3.5" />
{{ $t('discover.share.publishTab.title') }}
</Tab>
</TabList>
</div>
</nav>
</section>
<!-- Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<!-- Share Tab -->
<div
v-if="activeTab === 'invite'"
class="mx-auto flex w-full max-w-sm flex-col gap-6 p-4"
>
<!-- People with access -->
<div v-if="invitedUsers.length > 0" class="flex flex-col gap-2">
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('discover.share.inviteByEmail.peopleWithAccess') }}
</span>
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-2"
>
<div
v-for="user in invitedUsers"
:key="user.email"
class="flex items-center gap-3 rounded-lg border border-border-default/70 bg-comfy-menu-bg px-3 py-2.5 shadow-sm transition-colors hover:bg-secondary-background"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary-500/20 text-sm font-medium text-primary-500 ring-1 ring-primary-500/30"
>
{{ user.email.charAt(0).toUpperCase() }}
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-base-foreground">
{{ user.email }}
</div>
<div class="text-xs text-muted-foreground">
{{
user.role === 'edit'
? $t('discover.share.inviteByEmail.roles.edit')
: $t('discover.share.inviteByEmail.roles.viewOnly')
}}
</div>
</div>
<Button
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.delete')"
@click="removeUser(user.email)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
<!-- Invite section header -->
<div v-if="invitedUsers.length > 0" class="h-px bg-border-default" />
<!-- Email + role -->
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('discover.share.inviteByEmail.emailLabel') }}
</span>
<div class="flex flex-col gap-2 sm:flex-row">
<input
v-model="email"
type="email"
class="w-full rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover"
:placeholder="$t('discover.share.inviteByEmail.emailPlaceholder')"
/>
<select
v-model="selectedRole"
class="w-full rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover sm:w-40"
:aria-label="$t('discover.share.inviteByEmail.roleLabel')"
>
<option value="view">
{{ $t('discover.share.inviteByEmail.roles.viewOnly') }}
</option>
<option value="edit">
{{ $t('discover.share.inviteByEmail.roles.edit') }}
</option>
</select>
</div>
</div>
<Button
variant="primary"
size="md"
class="w-full"
:loading="sendingInvite"
:disabled="!isEmailValid"
@click="handleSendInvite"
>
<i class="icon-[lucide--send] size-4" />
{{ $t('discover.share.inviteByEmail.sendInvite') }}
</Button>
<!-- Public URL Section -->
<div
class="rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<i class="icon-[lucide--globe] size-4 text-muted-foreground" />
<span class="text-sm font-semibold text-base-foreground">
{{ $t('discover.share.publicUrl.title') }}
</span>
</div>
<p class="text-sm leading-5 text-muted-foreground">
{{ $t('discover.share.publicUrl.description') }}
</p>
<div class="flex flex-col gap-2">
<Button
variant="secondary"
size="md"
class="w-full justify-between rounded-lg border border-border-default bg-secondary-background/90 px-4 py-3 text-sm font-semibold shadow-sm transition-colors hover:bg-secondary-background-hover"
:aria-label="$t('discover.share.publicUrl.copyLink')"
@click="copyToClipboard(shareUrl)"
>
<span class="min-w-0 truncate">
{{ $t('discover.share.publicUrl.copyLink') }}
</span>
<i
:class="
cn(
'size-3.5',
copied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'
)
"
/>
</Button>
<Button
variant="secondary"
size="md"
class="w-full justify-between rounded-lg border border-border-default bg-secondary-background/90 px-4 py-3 text-sm font-semibold shadow-sm transition-colors hover:bg-secondary-background-hover"
:aria-label="$t('discover.share.publicUrl.copyLinkAppMode')"
@click="copyToClipboard(appModeShareUrl)"
>
<span class="min-w-0 truncate">
{{ $t('discover.share.publicUrl.copyLinkAppMode') }}
</span>
<i class="icon-[lucide--copy] size-3.5" />
</Button>
</div>
</div>
</div>
</div>
<!-- Publish to Comfy Hub Tab -->
<div
v-else-if="activeTab === 'publish'"
class="mx-auto flex w-full max-w-sm flex-col gap-6 p-4"
>
<div
v-if="publishSuccessUrl"
class="flex flex-col gap-4 rounded-xl border border-border-default bg-secondary-background/40 p-4"
>
<div class="flex items-center gap-2 text-base font-semibold">
<i class="icon-[lucide--check-circle] size-5 text-success" />
{{ $t('discover.share.publishToHubDialog.successTitle') }}
</div>
<p class="text-sm text-muted-foreground">
{{ $t('discover.share.publishToHubDialog.successDescription') }}
</p>
<div
class="rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground"
>
{{ publishSuccessUrl }}
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<Button variant="primary" size="md" @click="openPublishedWorkflow">
<i class="icon-[lucide--external-link] size-4" />
{{ $t('discover.share.publishToHubDialog.successOpen') }}
</Button>
<Button
variant="secondary"
size="md"
@click="copyToClipboard(publishSuccessUrl)"
>
<i class="icon-[lucide--copy] size-4" />
{{ $t('discover.share.publishToHubDialog.successCopy') }}
</Button>
</div>
</div>
<template v-else>
<p
class="rounded-xl border border-border-default bg-secondary-background/40 p-3 text-sm leading-5 text-muted-foreground"
>
{{ $t('discover.share.publishTab.description') }}
</p>
<!-- Thumbnail upload -->
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('discover.share.publishToHubDialog.thumbnail') }}
</span>
<div
class="relative flex size-24 cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-dashed border-border-default bg-comfy-menu-bg transition-colors hover:border-border-hover"
@click="triggerThumbnailUpload"
>
<img
v-if="thumbnailPreview"
:src="thumbnailPreview"
:alt="$t('discover.share.publishToHubDialog.thumbnailPreview')"
class="size-full object-cover"
/>
<div v-else class="flex flex-col items-center gap-1 text-center">
<i
class="icon-[lucide--image-plus] size-6 text-muted-foreground"
/>
</div>
<input
ref="thumbnailInput"
type="file"
accept="image/*"
class="hidden"
@change="handleThumbnailChange"
/>
</div>
</div>
<!-- Title -->
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('g.title') }}
</span>
<input
v-model="publishTitle"
type="text"
class="w-full rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover"
:placeholder="
$t('discover.share.publishToHubDialog.titlePlaceholder')
"
/>
</div>
<!-- Description -->
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('g.description') }}
</span>
<textarea
v-model="publishDescription"
rows="3"
class="w-full resize-none rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-secondary-foreground hover:border-border-hover"
:placeholder="
$t('discover.share.publishToHubDialog.descriptionPlaceholder')
"
/>
</div>
<!-- Tags -->
<div
class="flex flex-col gap-2 rounded-xl border border-border-default bg-secondary-background/40 p-3"
>
<span
class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('discover.filters.tags') }}
</span>
<div
class="flex flex-wrap gap-2 rounded-lg border border-border-default bg-comfy-menu-bg px-3 py-2"
>
<span
v-for="tag in publishTags"
:key="tag"
class="flex items-center gap-1 rounded-full bg-secondary-background px-2 py-0.5 text-xs text-base-foreground"
>
{{ tag }}
<button
class="cursor-pointer border-none bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
:aria-label="$t('g.removeTag')"
@click="removeTag(tag)"
>
<i class="icon-[lucide--x] size-3" />
</button>
</span>
<input
v-model="newTag"
type="text"
class="min-w-20 flex-1 border-none bg-transparent text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none"
:placeholder="$t('discover.share.publishToHubDialog.addTag')"
@keydown.enter.prevent="addTag"
/>
</div>
</div>
<Button
variant="primary"
size="md"
class="w-full"
:loading="publishing"
:disabled="!isPublishValid"
@click="handlePublishToHub"
>
<i class="icon-[lucide--upload] size-4" />
{{ $t('discover.share.publishToHubDialog.publish') }}
</Button>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
import { cn } from '@/utils/tailwindUtil'
type ShareTab = 'invite' | 'publish'
const { t } = useI18n()
const workflowStore = useWorkflowStore()
const toastStore = useToastStore()
const sharePanelStore = useSharePanelStore()
interface InvitedUser {
email: string
role: 'view' | 'edit'
}
const activeTab = ref<ShareTab>('invite')
const email = ref('')
const selectedRole = ref<'view' | 'edit'>('view')
const sendingInvite = ref(false)
const publishing = ref(false)
const copied = ref(false)
const invitedUsers = ref<InvitedUser[]>([])
// Publish to Hub state
const thumbnailInput = ref<HTMLInputElement>()
const thumbnailPreview = ref('')
const publishTitle = ref('')
const publishDescription = ref('')
const publishTags = ref<string[]>([])
const newTag = ref('')
const publishSuccessUrl = ref<string | null>(null)
const isEmailValid = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email.value)
})
const isPublishValid = computed(
() =>
publishTitle.value.trim().length > 0 &&
publishDescription.value.trim().length > 0
)
const shareUrl = computed(() => {
const baseUrl = window.location.origin
const workflow = workflowStore.activeWorkflow
if (!workflow) return baseUrl
const workflowId = workflow.key.replace(/\.json$/, '').replace(/\//g, '-')
return new URL(`/app/${workflowId}`, baseUrl).toString()
})
const appModeShareUrl = computed(() => {
const baseUrl = window.location.origin
const workflow = workflowStore.activeWorkflow
if (!workflow) return baseUrl
const workflowId = workflow.key.replace(/\.json$/, '').replace(/\//g, '-')
const url = new URL(`/app/${workflowId}`, baseUrl)
url.searchParams.set('mode', 'linear')
return url.toString()
})
function closePanel() {
sharePanelStore.closePanel()
}
async function handleSendInvite() {
sendingInvite.value = true
try {
await new Promise((resolve) => setTimeout(resolve, 1000))
const existingUser = invitedUsers.value.find((u) => u.email === email.value)
if (existingUser) {
existingUser.role = selectedRole.value
} else {
invitedUsers.value.push({
email: email.value,
role: selectedRole.value
})
}
toastStore.add({
severity: 'success',
summary: t('discover.share.inviteByEmail.inviteSent'),
detail: t('discover.share.inviteByEmail.inviteSentDetail', {
email: email.value
}),
life: 3000
})
email.value = ''
} finally {
sendingInvite.value = false
}
}
function removeUser(emailToRemove: string) {
invitedUsers.value = invitedUsers.value.filter(
(u) => u.email !== emailToRemove
)
}
async function copyToClipboard(url: string) {
try {
await navigator.clipboard.writeText(url)
copied.value = true
toastStore.add({
severity: 'success',
summary: t('g.copied'),
life: 2000
})
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
toastStore.addAlert(t('discover.share.copyFailed'))
}
}
function triggerThumbnailUpload() {
thumbnailInput.value?.click()
}
function handleThumbnailChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
thumbnailPreview.value = URL.createObjectURL(file)
}
}
function addTag() {
const tag = newTag.value.trim()
if (tag && !publishTags.value.includes(tag)) {
publishTags.value.push(tag)
}
newTag.value = ''
}
function removeTag(tag: string) {
publishTags.value = publishTags.value.filter((t) => t !== tag)
}
async function handlePublishToHub() {
publishing.value = true
try {
await new Promise((resolve) => setTimeout(resolve, 1000))
const title = publishTitle.value.trim()
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
const fakeId = slug ? `${slug}-preview` : 'workflow-preview'
publishSuccessUrl.value = `https://comfy-hub.vercel.app/workflows/${fakeId}`
} finally {
publishing.value = false
}
}
function openPublishedWorkflow() {
if (!publishSuccessUrl.value) return
window.open(publishSuccessUrl.value, '_blank')
}
</script>

View File

@@ -0,0 +1,369 @@
<template>
<div
class="flex size-full flex-col overflow-y-auto bg-comfy-menu-bg text-base-foreground"
>
<div
class="flex shrink-0 items-center gap-4 border-b border-interface-stroke bg-comfy-menu-bg px-6 py-4 text-base-foreground"
>
<Button variant="secondary" size="md" @click="emit('back')">
<i class="icon-[lucide--arrow-left]" />
{{ $t('g.back') }}
</Button>
<div class="flex-1" />
</div>
<section class="px-6 pt-6">
<div
class="mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-2xl border border-border-default bg-comfy-menu-secondary-bg p-6 shadow-sm text-base-foreground"
>
<div class="flex flex-wrap items-start gap-6">
<img
:src="authorAvatar"
:alt="authorName"
class="size-20 rounded-2xl bg-secondary-background object-cover ring-2 ring-border-subtle"
/>
<div class="min-w-0 flex-1">
<div
class="text-xs font-semibold uppercase text-base-foreground/60"
>
{{ $t('discover.author.title') }}
</div>
<div class="flex flex-wrap items-center gap-3">
<h1 class="truncate text-3xl font-semibold text-base-foreground">
{{ authorName }}
</h1>
<Button variant="secondary" size="md" @click="openHubProfile">
<i class="icon-[lucide--external-link] size-4" />
{{ $t('discover.author.openInHub') }}
</Button>
<div
class="flex items-center gap-2 rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-xs text-base-foreground/80"
>
<span class="text-base-foreground">
#{{ $t('discover.author.rankValue') }}
</span>
<span class="text-base-foreground/60">
{{ $t('discover.author.rankLabel') }}
</span>
<span class="text-base-foreground/60"></span>
<span class="text-base-foreground/60">
{{ $t('discover.author.rankCaption') }}
</span>
</div>
</div>
<p class="mt-2 max-w-2xl text-sm text-base-foreground/80">
{{ $t('discover.author.tagline') }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs">
<div
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
>
{{ $t('discover.author.badgeCreator') }}
</div>
<div
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
>
{{ $t('discover.author.badgeOpenSource') }}
</div>
<div
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
>
{{ $t('discover.author.badgeTemplates') }}
</div>
</div>
</div>
<div class="flex w-full flex-col gap-3 md:w-64">
<div
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
>
<div class="text-xs uppercase text-base-foreground/60">
{{ $t('discover.author.aboutTitle') }}
</div>
<p class="mt-2 text-sm text-base-foreground/80">
{{ $t('discover.author.aboutDescription') }}
</p>
</div>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
>
<div class="text-xs uppercase text-base-foreground/60">
{{ $t('discover.author.runsLabel') }}
</div>
<div class="mt-2 text-2xl font-semibold text-base-foreground">
{{ formattedRuns }}
</div>
</div>
<div
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
>
<div class="text-xs uppercase text-base-foreground/60">
{{ $t('discover.author.copiesLabel') }}
</div>
<div class="mt-2 text-2xl font-semibold text-base-foreground">
{{ formattedCopies }}
</div>
</div>
</div>
</div>
</section>
<div class="px-6 py-4 bg-comfy-menu-bg">
<div
class="mx-auto mb-4 flex w-full max-w-5xl flex-wrap items-center gap-3 border-b border-interface-stroke pb-4 text-base-foreground"
>
<div class="flex items-center gap-2">
<i
class="icon-[lucide--layout-grid] size-4 text-base-foreground/60"
/>
<span class="text-sm font-semibold text-base-foreground">
{{ $t('discover.author.workflowsTitle') }}
</span>
</div>
<div
class="flex items-center gap-2 rounded-full bg-secondary-background px-3 py-1 text-xs"
>
<i class="icon-[lucide--layers] size-3.5" />
{{
$t(
'discover.author.workflowsCount',
{ count: totalWorkflows },
totalWorkflows
)
}}
</div>
<div class="flex-1" />
<SearchBox
v-model="searchQuery"
:placeholder="$t('discover.author.searchPlaceholder')"
size="lg"
class="max-w-md flex-1"
show-border
@search="handleSearch"
/>
</div>
<div
v-if="isLoading"
class="mx-auto grid w-full max-w-5xl grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4"
>
<CardContainer
v-for="n in 12"
:key="`author-skeleton-${n}`"
size="compact"
variant="ghost"
class="hover:bg-base-background"
>
<template #top>
<CardTop ratio="landscape">
<div class="size-full animate-pulse bg-dialog-surface" />
</CardTop>
</template>
<template #bottom>
<div class="p-3">
<div
class="mb-2 h-5 w-3/4 animate-pulse rounded bg-dialog-surface"
/>
<div class="h-4 w-full animate-pulse rounded bg-dialog-surface" />
</div>
</template>
</CardContainer>
</div>
<div
v-else-if="results && results.templates.length === 0"
class="mx-auto flex h-64 w-full max-w-5xl flex-col items-center justify-center text-muted-foreground"
>
<i class="icon-[lucide--search] mb-4 size-12 opacity-50" />
<p class="mb-2 text-lg">{{ $t('discover.author.noResults') }}</p>
<p class="text-sm">{{ $t('discover.author.noResultsHint') }}</p>
</div>
<div
v-else-if="results"
class="mx-auto grid w-full max-w-5xl grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4"
>
<CardContainer
v-for="template in results.templates"
:key="template.objectID"
size="compact"
custom-aspect-ratio="2/3"
variant="ghost"
rounded="lg"
class="hover:bg-base-background"
@mouseenter="hoveredTemplate = template.objectID"
@mouseleave="hoveredTemplate = null"
@click="emit('selectWorkflow', template)"
>
<template #top>
<div class="shrink-0">
<CardTop ratio="square">
<LazyImage
:src="template.thumbnail_url"
:alt="template.title"
class="size-full rounded-lg object-cover transition-transform duration-300"
:class="hoveredTemplate === template.objectID && 'scale-105'"
/>
<template #bottom-right>
<SquareChip
v-for="tag in template.tags.slice(0, 2)"
:key="tag"
:label="tag"
/>
</template>
</CardTop>
</div>
</template>
<template #bottom>
<div class="flex flex-col gap-1.5 p-3">
<h3
class="line-clamp-1 text-sm font-medium"
:title="template.title"
>
{{ template.title }}
</h3>
<p
class="line-clamp-2 text-xs text-muted-foreground"
:title="template.description"
>
{{ template.description }}
</p>
<div class="mt-1 flex flex-wrap gap-1">
<span
v-for="model in template.models.slice(0, 2)"
:key="model"
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{ model }}
</span>
<span
v-if="template.models.length > 2"
class="text-xs text-muted-foreground"
>
+{{ template.models.length - 2 }}
</span>
</div>
</div>
</template>
</CardContainer>
</div>
</div>
<div
v-if="results && results.totalPages > 1"
class="flex shrink-0 items-center justify-center gap-2 border-t border-interface-stroke px-6 py-3"
>
<Button
variant="secondary"
size="md"
:disabled="currentPage === 0"
@click="goToPage(currentPage - 1)"
>
<i class="icon-[lucide--chevron-left]" />
</Button>
<span class="px-4 text-sm text-muted-foreground">
{{ currentPage + 1 }} / {{ results.totalPages }}
</span>
<Button
variant="secondary"
size="md"
:disabled="currentPage >= results.totalPages - 1"
@click="goToPage(currentPage + 1)"
>
<i class="icon-[lucide--chevron-right]" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import LazyImage from '@/components/common/LazyImage.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowTemplateSearch } from '@/composables/discover/useWorkflowTemplateSearch'
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
const { authorName, authorAvatarUrl, stats } = defineProps<{
authorName: string
authorAvatarUrl?: string
stats?: { runs: number; copies: number }
}>()
const emit = defineEmits<{
back: []
selectWorkflow: [workflow: AlgoliaWorkflowTemplate]
}>()
const { search, isLoading, results } = useWorkflowTemplateSearch()
const searchQuery = ref('')
const currentPage = ref(0)
const hoveredTemplate = ref<string | null>(null)
const hubProfileUrl = 'https://pr-2289.testenvs.comfy.org/profile/Comfy%20Org'
const authorAvatar = computed(
() => authorAvatarUrl ?? '/assets/images/comfy-logo-single.svg'
)
const formattedRuns = computed(() =>
stats?.runs ? stats.runs.toLocaleString() : '--'
)
const formattedCopies = computed(() =>
stats?.copies ? stats.copies.toLocaleString() : '--'
)
const totalWorkflows = computed(() => results.value?.totalHits ?? 0)
const authorFacetFilters = computed(() => [[`author_name:${authorName}`]])
function openHubProfile() {
window.location.assign(hubProfileUrl)
}
async function performSearch() {
await search({
query: searchQuery.value,
pageSize: 24,
pageNumber: currentPage.value,
facetFilters: authorFacetFilters.value
})
}
function handleSearch() {
currentPage.value = 0
performSearch()
}
function goToPage(page: number) {
currentPage.value = page
performSearch()
}
watch(
() => authorName,
() => {
currentPage.value = 0
performSearch()
}
)
watch(
() => searchQuery.value,
() => {
currentPage.value = 0
performSearch()
}
)
onMounted(() => {
performSearch()
})
</script>

View File

@@ -0,0 +1,387 @@
<template>
<WorkflowDetailView
v-if="selectedWorkflow"
:workflow="selectedWorkflow"
@back="selectedWorkflow = null"
@run-workflow="handleRunWorkflow"
@make-copy="handleMakeCopy"
@author-selected="handleAuthorSelected"
/>
<div v-else class="flex size-full flex-col">
<!-- Header with search -->
<div
class="flex shrink-0 items-center gap-4 border-b border-interface-stroke px-6 py-4"
>
<h1 class="text-xl font-semibold text-base-foreground">
{{ $t('sideToolbar.discover') }}
</h1>
<SearchBox
v-model="searchQuery"
:placeholder="$t('discover.searchPlaceholder')"
size="lg"
class="max-w-md flex-1"
show-border
@search="handleSearch"
/>
</div>
<!-- Filters -->
<div class="flex shrink-0 flex-wrap items-center gap-3 px-6 py-3">
<!-- Tags filter -->
<MultiSelect
v-model="selectedTags"
:label="$t('discover.filters.tags')"
:options="tagOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
class="w-48"
>
<template #icon>
<i class="icon-[lucide--tag]" />
</template>
</MultiSelect>
<!-- Models filter -->
<MultiSelect
v-model="selectedModels"
:label="$t('discover.filters.models')"
:options="modelOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
class="w-48"
>
<template #icon>
<i class="icon-[lucide--cpu]" />
</template>
</MultiSelect>
<!-- Open source toggle -->
<Button
:variant="openSourceOnly ? 'primary' : 'secondary'"
size="md"
@click="openSourceOnly = !openSourceOnly"
>
<i class="icon-[lucide--unlock]" />
{{ $t('discover.filters.openSource') }}
</Button>
<!-- Cloud only toggle -->
<Button
:variant="cloudOnly ? 'primary' : 'secondary'"
size="md"
@click="cloudOnly = !cloudOnly"
>
<i class="icon-[lucide--cloud]" />
{{ $t('discover.filters.cloudOnly') }}
</Button>
<div class="flex-1" />
<!-- Results count -->
<span v-if="!isLoading && results" class="text-sm text-muted-foreground">
{{
$t(
'discover.resultsCount',
{ count: results.totalHits },
results.totalHits
)
}}
</span>
</div>
<!-- Content area -->
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- Loading state -->
<div
v-if="isLoading"
class="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4"
>
<CardContainer
v-for="n in 12"
:key="`skeleton-${n}`"
size="compact"
variant="ghost"
class="hover:bg-base-background"
>
<template #top>
<CardTop ratio="landscape">
<div class="size-full animate-pulse bg-dialog-surface" />
</CardTop>
</template>
<template #bottom>
<div class="p-3">
<div
class="mb-2 h-5 w-3/4 animate-pulse rounded bg-dialog-surface"
/>
<div class="h-4 w-full animate-pulse rounded bg-dialog-surface" />
</div>
</template>
</CardContainer>
</div>
<!-- No results state -->
<div
v-else-if="!cloudOnly || (results && results.templates.length === 0)"
class="flex h-64 flex-col items-center justify-center text-muted-foreground"
>
<i class="icon-[lucide--search] mb-4 size-12 opacity-50" />
<p class="mb-2 text-lg">{{ $t('discover.noResults') }}</p>
<p class="text-sm">{{ $t('discover.noResultsHint') }}</p>
</div>
<!-- Results grid -->
<div
v-else-if="results"
class="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4"
>
<CardContainer
v-for="template in results.templates"
:key="template.objectID"
size="compact"
custom-aspect-ratio="2/3"
variant="ghost"
class="hover:bg-base-background"
@mouseenter="hoveredTemplate = template.objectID"
@mouseleave="hoveredTemplate = null"
@click="handleTemplateClick(template)"
>
<template #top>
<div class="shrink-0">
<CardTop ratio="square">
<LazyImage
:src="template.thumbnail_url"
:alt="template.title"
class="size-full rounded-lg object-cover transition-transform duration-300"
:class="hoveredTemplate === template.objectID && 'scale-105'"
/>
<template #bottom-right>
<SquareChip
v-for="tag in template.tags.slice(0, 2)"
:key="tag"
:label="tag"
/>
</template>
</CardTop>
</div>
</template>
<template #bottom>
<div class="flex flex-col gap-1.5 p-3">
<h3
class="line-clamp-1 text-sm font-medium"
:title="template.title"
>
{{ template.title }}
</h3>
<button
v-if="template.author_name"
type="button"
class="flex items-center gap-1.5 rounded px-1 -mx-1 border border-transparent bg-transparent text-muted-foreground transition-colors hover:bg-secondary-background hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-subtle"
@click.stop="handleAuthorClick(template)"
>
<img
:src="
template.author_avatar_url ??
'/assets/images/comfy-logo-single.svg'
"
:alt="template.author_name"
class="size-4 rounded-full bg-secondary-background object-cover"
/>
<span class="text-xs text-muted-foreground">
{{ template.author_name }}
</span>
</button>
<p
class="line-clamp-2 text-xs text-muted-foreground"
:title="template.description"
>
{{ template.description }}
</p>
<div class="mt-1 flex flex-wrap gap-1">
<span
v-for="model in template.models.slice(0, 2)"
:key="model"
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{ model }}
</span>
<span
v-if="template.models.length > 2"
class="text-xs text-muted-foreground"
>
+{{ template.models.length - 2 }}
</span>
</div>
</div>
</template>
</CardContainer>
</div>
<!-- Initial state -->
<div
v-else
class="flex h-64 flex-col items-center justify-center text-muted-foreground"
>
<i class="icon-[lucide--compass] mb-4 size-16 opacity-50" />
<p class="text-lg">{{ $t('sideToolbar.discoverPlaceholder') }}</p>
</div>
</div>
<!-- Pagination (inside v-else block) -->
<div
v-if="results && results.totalPages > 1"
class="flex shrink-0 items-center justify-center gap-2 border-t border-interface-stroke px-6 py-3"
>
<Button
variant="secondary"
size="md"
:disabled="currentPage === 0"
@click="goToPage(currentPage - 1)"
>
<i class="icon-[lucide--chevron-left]" />
</Button>
<span class="px-4 text-sm text-muted-foreground">
{{ currentPage + 1 }} / {{ results.totalPages }}
</span>
<Button
variant="secondary"
size="md"
:disabled="currentPage >= results.totalPages - 1"
@click="goToPage(currentPage + 1)"
>
<i class="icon-[lucide--chevron-right]" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import LazyImage from '@/components/common/LazyImage.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import WorkflowDetailView from '@/components/discover/WorkflowDetailView.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowTemplateSearch } from '@/composables/discover/useWorkflowTemplateSearch'
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
const { search, isLoading, results } = useWorkflowTemplateSearch()
const searchQuery = ref('')
const currentPage = ref(0)
const hoveredTemplate = ref<string | null>(null)
const selectedWorkflow = ref<AlgoliaWorkflowTemplate | null>(null)
const selectedTags = ref<Array<{ name: string; value: string }>>([])
const selectedModels = ref<Array<{ name: string; value: string }>>([])
const openSourceOnly = ref(false)
const cloudOnly = ref(true)
// Store initial facet values to preserve filter options
const initialFacets = ref<Record<string, Record<string, number>> | null>(null)
const tagOptions = computed(() => {
const facets = initialFacets.value?.tags ?? results.value?.facets?.tags
if (!facets) return []
return Object.entries(facets).map(([tag, count]) => ({
name: `${tag} (${count})`,
value: tag
}))
})
const modelOptions = computed(() => {
const facets = initialFacets.value?.models ?? results.value?.facets?.models
if (!facets) return []
return Object.entries(facets).map(([model, count]) => ({
name: `${model} (${count})`,
value: model
}))
})
function buildFacetFilters(): string[][] {
const filters: string[][] = []
if (selectedTags.value.length > 0) {
filters.push(selectedTags.value.map((t) => `tags:${t.value}`))
}
if (selectedModels.value.length > 0) {
filters.push(selectedModels.value.map((m) => `models:${m.value}`))
}
if (openSourceOnly.value) {
filters.push(['open_source:true'])
}
return filters
}
async function performSearch() {
const result = await search({
query: searchQuery.value,
pageSize: 24,
pageNumber: currentPage.value,
facetFilters: buildFacetFilters()
})
// Store initial facets on first search (no filters applied)
if (!initialFacets.value && result.facets) {
initialFacets.value = result.facets
}
}
function handleSearch() {
currentPage.value = 0
performSearch()
}
function goToPage(page: number) {
currentPage.value = page
performSearch()
}
function handleTemplateClick(template: AlgoliaWorkflowTemplate) {
selectedWorkflow.value = template
}
function handleAuthorClick(template: AlgoliaWorkflowTemplate) {
if (!template.author_name) return
window.open(
`https://comfy-hub.vercel.app/profile/${encodeURIComponent(
template.author_name
)}`,
'_blank'
)
}
function handleAuthorSelected(author: { name: string; avatarUrl?: string }) {
window.open(
`https://comfy-hub.vercel.app/profile/${encodeURIComponent(author.name)}`,
'_blank'
)
}
function handleRunWorkflow(_workflow: AlgoliaWorkflowTemplate) {
// TODO: Implement workflow run
}
function handleMakeCopy(_workflow: AlgoliaWorkflowTemplate) {
// TODO: Implement make a copy
}
watch(
[selectedTags, selectedModels, openSourceOnly, cloudOnly],
() => {
currentPage.value = 0
performSearch()
},
{ deep: true }
)
onMounted(() => {
performSearch()
})
</script>

View File

@@ -0,0 +1,268 @@
<template>
<div class="flex size-full flex-col">
<!-- Header with back button and actions -->
<div
class="flex shrink-0 items-center gap-4 border-b border-interface-stroke px-6 py-4"
>
<Button variant="secondary" size="md" @click="emit('back')">
<i class="icon-[lucide--arrow-left]" />
{{ $t('g.back') }}
</Button>
<h1 class="flex-1 truncate text-xl font-semibold text-base-foreground">
{{ workflow.title }}
</h1>
<div class="flex items-center gap-2">
<Button variant="primary" size="md" @click="handleMakeCopy">
<i class="icon-[lucide--copy]" />
{{ $t('discover.detail.makeCopy') }}
</Button>
<Button variant="secondary" size="md" @click="openHubWorkflow">
<i class="icon-[lucide--external-link] size-4" />
{{ $t('discover.detail.openInHub') }}
</Button>
</div>
</div>
<!-- Two-column layout -->
<div class="flex flex-1 overflow-hidden">
<!-- Left column: Workflow info -->
<div class="flex w-80 shrink-0 flex-col border-r border-interface-stroke">
<div class="flex-1 space-y-5 overflow-y-auto p-4">
<!-- Thumbnail -->
<div
class="aspect-video overflow-hidden rounded-lg bg-dialog-surface"
>
<LazyImage
:src="workflow.thumbnail_url"
:alt="workflow.title"
class="size-full object-cover"
/>
</div>
<!-- Author -->
<button
type="button"
:disabled="!hasAuthor"
:class="
cn(
'flex w-full items-center gap-3 rounded-lg border border-transparent p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-subtle',
hasAuthor
? 'hover:bg-secondary-background'
: 'cursor-default opacity-80'
)
"
@click="handleAuthorClick"
>
<img
:src="authorAvatar"
:alt="authorName"
class="size-8 rounded-full bg-secondary-background object-cover"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-base-foreground">
{{ authorName }}
</div>
<div class="truncate text-xs text-muted-foreground">
{{ $t('discover.detail.officialWorkflow') }}
</div>
</div>
</button>
<!-- Stats -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<i class="icon-[lucide--play] size-3.5" />
<span>{{ formatCount(runCount) }}</span>
</div>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<i class="icon-[lucide--eye] size-3.5" />
<span>{{ formatCount(viewCount) }}</span>
</div>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<i class="icon-[lucide--copy] size-3.5" />
<span>{{ formatCount(copyCount) }}</span>
</div>
</div>
<!-- Description -->
<div class="space-y-1">
<h3 class="text-xs font-medium text-muted-foreground">
{{ $t('g.description') }}
</h3>
<p class="whitespace-pre-wrap text-sm text-base-foreground">
{{ workflow.description }}
</p>
</div>
<!-- Tags -->
<div v-if="workflow.tags.length > 0" class="space-y-1">
<h3 class="text-xs font-medium text-muted-foreground">
{{ $t('discover.filters.tags') }}
</h3>
<div class="flex flex-wrap gap-1">
<SquareChip
v-for="tag in workflow.tags"
:key="tag"
:label="tag"
/>
</div>
</div>
<!-- Models -->
<div v-if="workflow.models.length > 0" class="space-y-1">
<h3 class="text-xs font-medium text-muted-foreground">
{{ $t('discover.filters.models') }}
</h3>
<div class="flex flex-wrap gap-1">
<span
v-for="model in workflow.models"
:key="model"
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{ model }}
</span>
</div>
</div>
<!-- Open source badge -->
<div v-if="workflow.open_source" class="flex items-center gap-1.5">
<i class="icon-[lucide--unlock] size-4 text-green-500" />
<span class="text-xs text-green-500">
{{ $t('discover.detail.openSource') }}
</span>
</div>
<!-- Required custom nodes -->
<div
v-if="workflow.requires_custom_nodes.length > 0"
class="space-y-1"
>
<h3 class="text-xs font-medium text-muted-foreground">
{{ $t('discover.detail.requiredNodes') }}
</h3>
<div class="flex flex-wrap gap-1">
<span
v-for="node in workflow.requires_custom_nodes"
:key="node"
class="rounded bg-warning-background px-1.5 py-0.5 text-xs text-warning-foreground"
>
{{ node }}
</span>
</div>
</div>
</div>
</div>
<!-- Right column: Workflow preview -->
<div class="min-h-0 min-w-0 flex-1">
<WorkflowPreviewCanvas :workflow-url="workflow.workflow_url" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SquareChip from '@/components/chip/SquareChip.vue'
import LazyImage from '@/components/common/LazyImage.vue'
import WorkflowPreviewCanvas from '@/components/discover/WorkflowPreviewCanvas.vue'
import Button from '@/components/ui/button/Button.vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { cn } from '@/utils/tailwindUtil'
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
const { t } = useI18n()
const { workflow } = defineProps<{
workflow: AlgoliaWorkflowTemplate
}>()
const emit = defineEmits<{
back: []
makeCopy: [workflow: AlgoliaWorkflowTemplate]
}>()
const hasAuthor = computed(() => !!workflow.author_name)
const authorName = computed(
() => workflow.author_name ?? t('discover.detail.author')
)
const authorAvatar = computed(
() => workflow.author_avatar_url ?? '/assets/images/comfy-logo-single.svg'
)
const hubWorkflowBaseUrl = 'https://comfy-hub.vercel.app/workflows'
const runCount = computed(() => workflow.run_count ?? 1_234)
const viewCount = computed(() => workflow.view_count ?? 5_678)
const copyCount = computed(() => workflow.copy_count ?? 890)
function formatCount(count: number): string {
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`
}
return count.toString()
}
const handleAuthorClick = () => {
if (!workflow.author_name) return
window.open(
`https://comfy-hub.vercel.app/profile/${encodeURIComponent(
workflow.author_name
)}`,
'_blank'
)
}
const openHubWorkflow = () => {
window.open(`${hubWorkflowBaseUrl}/${workflow.objectID}`, '_blank')
}
const loadWorkflowFromUrl = async () => {
if (!workflow.workflow_url) return false
// Check that app canvas and graph are initialized
if (!app.canvas?.graph) {
useToastStore().addAlert(t('discover.detail.appNotReady'))
return false
}
try {
const response = await fetch(workflow.workflow_url)
if (!response.ok) {
throw new Error(`Failed to fetch workflow: ${response.status}`)
}
const workflowData = await response.json()
await app.loadGraphData(workflowData, true, true, workflow.title, {
openSource: 'template'
})
// Close overlay panels to show the new workflow
useSidebarTabStore().activeSidebarTabId = null
useHomePanelStore().closePanel()
return true
} catch (error) {
useToastStore().addAlert(
t('discover.detail.makeCopyFailed', { error: String(error) })
)
return false
}
}
async function handleMakeCopy() {
const didLoad = await loadWorkflowFromUrl()
if (didLoad) {
emit('makeCopy', workflow)
}
}
</script>

View File

@@ -0,0 +1,188 @@
<template>
<div ref="containerRef" class="relative size-full min-h-0 bg-graph-canvas">
<canvas ref="canvasRef" class="absolute left-0 top-0" />
<!-- Loading state -->
<div
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center"
>
<i
class="icon-[lucide--loader-2] size-8 animate-spin text-muted-foreground"
/>
</div>
<!-- Error state -->
<div
v-else-if="error"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-muted-foreground"
>
<i class="icon-[lucide--alert-circle] size-8" />
<span class="text-sm">{{ $t('discover.detail.previewError') }}</span>
<span class="text-xs opacity-50">{{ error.message }}</span>
</div>
<!-- Empty state -->
<div
v-else-if="!workflowUrl"
class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground"
>
<i class="icon-[lucide--workflow] size-16 opacity-30" />
<span class="text-sm">{{ $t('discover.detail.workflowPreview') }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import {
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
watch
} from 'vue'
import { LGraph } from '@/lib/litegraph/src/LGraph'
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
const { workflowUrl } = defineProps<{
workflowUrl?: string
}>()
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const graph = shallowRef<LGraph>()
const canvas = shallowRef<LGraphCanvas>()
const isLoading = ref(false)
const error = ref<Error | null>(null)
const isInitialized = ref(false)
function updateCanvasSize() {
if (!canvasRef.value || !containerRef.value || !canvas.value) return
const rect = containerRef.value.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
const dpr = Math.max(window.devicePixelRatio, 1)
canvas.value.resize(
Math.round(rect.width * dpr),
Math.round(rect.height * dpr)
)
}
function initCanvas() {
if (!canvasRef.value || !containerRef.value || isInitialized.value) return
const rect = containerRef.value.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
const dpr = Math.max(window.devicePixelRatio, 1)
canvasRef.value.width = Math.round(rect.width * dpr)
canvasRef.value.height = Math.round(rect.height * dpr)
graph.value = new LGraph()
canvas.value = new LGraphCanvas(canvasRef.value, graph.value, {
skip_render: true
})
canvas.value.startRendering()
isInitialized.value = true
}
function fitGraphToCanvas() {
if (!graph.value || !canvas.value) return
const nodes = graph.value.nodes
if (nodes.length === 0) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of nodes) {
minX = Math.min(minX, node.pos[0])
minY = Math.min(minY, node.pos[1])
maxX = Math.max(maxX, node.pos[0] + node.size[0])
maxY = Math.max(maxY, node.pos[1] + node.size[1])
}
const graphWidth = maxX - minX
const graphHeight = maxY - minY
const dpr = Math.max(window.devicePixelRatio, 1)
const canvasWidth = canvas.value.canvas.width / dpr
const canvasHeight = canvas.value.canvas.height / dpr
const padding = 50
if (graphWidth <= 0 || graphHeight <= 0) return
if (canvasWidth <= 0 || canvasHeight <= 0) return
const scaleX = (canvasWidth - padding * 2) / graphWidth
const scaleY = (canvasHeight - padding * 2) / graphHeight
const scale = Math.min(scaleX, scaleY, 1)
canvas.value.ds.scale = scale
canvas.value.ds.offset[0] = -minX + padding / scale
canvas.value.ds.offset[1] = -minY + padding / scale
canvas.value.setDirty(true, true)
}
async function loadWorkflow() {
if (!workflowUrl) return
// Wait for canvas to be initialized
if (!isInitialized.value) {
await nextTick()
initCanvas()
}
if (!graph.value || !canvas.value) return
isLoading.value = true
error.value = null
try {
const response = await fetch(workflowUrl)
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
const data = await response.json()
// Check if node types are registered
const registeredTypes = Object.keys(LiteGraph.registered_node_types)
if (registeredTypes.length === 0) {
throw new Error('No node types registered yet')
}
graph.value.configure(data)
await nextTick()
updateCanvasSize()
fitGraphToCanvas()
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
isLoading.value = false
}
}
useResizeObserver(containerRef, () => {
updateCanvasSize()
fitGraphToCanvas()
})
watch(
() => workflowUrl,
() => {
loadWorkflow()
}
)
onMounted(async () => {
await nextTick()
initCanvas()
await loadWorkflow()
})
onBeforeUnmount(() => {
canvas.value?.stopRendering()
})
</script>

View File

@@ -21,16 +21,23 @@
</div>
</div>
</template>
<template v-if="showUI" #side-toolbar>
<template v-if="showUI && !isHomePanelOpen" #side-toolbar>
<SideToolbar />
</template>
<template v-if="showUI" #side-bar-panel>
<template v-if="showUI && !isFullPageOverlayActive" #side-bar-panel>
<div
class="sidebar-content-container h-full w-full overflow-x-hidden overflow-y-auto"
>
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
</div>
</template>
<template v-if="showUI && isFullPageOverlayActive" #full-page-content>
<HomePanel v-if="isHomePanelOpen" />
<ExtensionSlot
v-else-if="activeSidebarTab"
:extension="activeSidebarTab"
/>
</template>
<template v-if="showUI" #topmenu>
<TopMenuSection />
</template>
@@ -38,7 +45,8 @@
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<NodePropertiesPanel />
<SharePanel v-if="sharePanelStore.isOpen" />
<NodePropertiesPanel v-else />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
@@ -105,6 +113,8 @@ import {
watchEffect
} from 'vue'
import SharePanel from '@/components/actionbar/SharePanel.vue'
import HomePanel from '@/components/home/HomePanel.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -157,7 +167,9 @@ import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useSharePanelStore } from '@/stores/workspace/sharePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -182,6 +194,8 @@ const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const colorPaletteStore = useColorPaletteStore()
const sharePanelStore = useSharePanelStore()
const homePanelStore = useHomePanelStore()
const colorPaletteService = useColorPaletteService()
const canvasInteractions = useCanvasInteractions()
const bootstrapStore = useBootstrapStore()
@@ -205,6 +219,13 @@ const selectionToolboxEnabled = computed(() =>
const activeSidebarTab = computed(() => {
return workspaceStore.sidebarTab.activeSidebarTab
})
const isFullPageTabActive = computed(() => {
return workspaceStore.sidebarTab.isFullPageTabActive
})
const isHomePanelOpen = computed(() => homePanelStore.isOpen)
const isFullPageOverlayActive = computed(
() => isFullPageTabActive.value || isHomePanelOpen.value
)
const showUI = computed(
() => !workspaceStore.focusMode && betaMenuEnabled.value
)

View File

@@ -0,0 +1,70 @@
<template>
<div class="flex h-full w-full bg-comfy-menu-bg">
<aside
class="flex w-56 shrink-0 flex-col gap-2 border-r border-border-default bg-comfy-menu-bg p-3"
>
<div
class="px-2 pb-2 pt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ $t('home.title') }}
</div>
<button
:class="
cn(
'flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm font-medium transition-colors',
activeTab === 'recents'
? 'bg-secondary-background text-base-foreground border-border-hover'
: 'text-base-foreground/80 hover:bg-secondary-background'
)
"
@click="activeTab = 'recents'"
>
<i class="icon-[lucide--clock] size-4" />
{{ $t('home.tabs.recents') }}
</button>
<button
:class="
cn(
'flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm font-medium transition-colors',
activeTab === 'discover'
? 'bg-secondary-background text-base-foreground border-border-hover'
: 'text-base-foreground/80 hover:bg-secondary-background'
)
"
@click="activeTab = 'discover'"
>
<i class="icon-[lucide--compass] size-4" />
{{ $t('home.tabs.discover') }}
</button>
</aside>
<main class="flex min-w-0 flex-1 flex-col">
<div
v-if="activeTab === 'recents'"
class="flex flex-1 items-center justify-center"
>
<div class="flex max-w-md flex-col items-center gap-2 text-center">
<i class="icon-[lucide--clock] size-10 text-muted-foreground" />
<h2 class="text-lg font-semibold text-base-foreground">
{{ $t('home.recentsStubTitle') }}
</h2>
<p class="text-sm text-muted-foreground">
{{ $t('home.recentsStubDescription') }}
</p>
</div>
</div>
<DiscoverView v-else class="flex-1" />
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import DiscoverView from '@/components/discover/DiscoverView.vue'
import { cn } from '@/utils/tailwindUtil'
type HomeTab = 'recents' | 'discover'
const activeTab = ref<HomeTab>('discover')
</script>

View File

@@ -19,6 +19,7 @@
>
<div ref="topToolbarRef" :class="groupClasses">
<ComfyMenuButton />
<SidebarTemplatesButton :is-small="isSmall" />
<SidebarIcon
v-for="tab in tabs"
:key="tab.id"
@@ -32,7 +33,6 @@
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarTemplatesButton />
</div>
<div ref="bottomToolbarRef" class="mt-auto" :class="groupClasses">
@@ -64,6 +64,7 @@ import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -80,7 +81,6 @@ import { cn } from '@/utils/tailwindUtil'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()

View File

@@ -116,6 +116,8 @@
icon="pi pi-folder"
:title="$t('g.empty')"
:message="$t('g.noWorkflowsFound')"
:button-label="$t('sideToolbar.browseTemplates')"
@action="openTemplates"
/>
</div>
</div>
@@ -155,6 +157,7 @@ import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import { appendJsonExt } from '@/utils/formatUtil'
@@ -303,6 +306,12 @@ const selectionKeys = computed(() => ({
}))
const workflowBookmarkStore = useWorkflowBookmarkStore()
const { show } = useWorkflowTemplateSelectorDialog()
function openTemplates() {
show('sidebar')
}
onMounted(async () => {
searchBoxRef.value?.focus()
await workflowBookmarkStore.loadBookmarks()

View File

@@ -4,6 +4,25 @@
class="workflow-tabs-container flex h-full max-w-full flex-auto flex-row overflow-hidden"
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
>
<Button
v-tooltip.bottom="{ value: $t('home.title'), showDelay: 300 }"
class="no-drag shrink-0 rounded-none h-full w-auto aspect-square"
:class="
cn(
'transition-colors',
homePanelStore.isOpen
? 'bg-secondary-background text-base-foreground'
: 'text-muted-foreground hover:bg-secondary-background'
)
"
variant="muted-textonly"
size="icon"
:aria-label="$t('home.title')"
:aria-pressed="homePanelStore.isOpen"
@click="toggleHomePanel"
>
<i class="icon-[lucide--home] size-4" />
</Button>
<Button
v-if="showOverflowArrows"
variant="muted-textonly"
@@ -113,10 +132,14 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
import { cn } from '@/utils/tailwindUtil'
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
@@ -135,6 +158,9 @@ const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const homePanelStore = useHomePanelStore()
const sidebarTabStore = useSidebarTabStore()
const canvasStore = useCanvasStore()
const { isLoggedIn } = useCurrentUser()
const isIntegratedTabBar = computed(
@@ -174,9 +200,20 @@ const onWorkflowChange = async (option: WorkflowOption) => {
return
}
homePanelStore.closePanel()
await workflowService.openWorkflow(option.workflow)
}
const toggleHomePanel = () => {
if (homePanelStore.isOpen) {
homePanelStore.closePanel()
return
}
sidebarTabStore.activeSidebarTabId = null
canvasStore.linearMode = false
homePanelStore.openPanel()
}
const closeWorkflows = async (options: WorkflowOption[]) => {
for (const opt of options) {
if (

View File

@@ -0,0 +1,100 @@
import type { SearchResponse } from 'algoliasearch/dist/lite/browser'
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
import { ref } from 'vue'
import type {
AlgoliaWorkflowTemplate,
WorkflowTemplateSearchParams,
WorkflowTemplateSearchResult
} from '@/types/discoverTypes'
const ALGOLIA_APP_ID = 'CSTOFZ5FPH'
const ALGOLIA_SEARCH_KEY = 'bbb304247bd251791b8653e9a816b322'
const INDEX_NAME = 'workflow_templates'
const RETRIEVE_ATTRIBUTES = [
'objectID',
'name',
'title',
'description',
'thumbnail_url',
'thumbnail_urls',
'thumbnail_count',
'thumbnail_variant',
'media_type',
'media_subtype',
'tags',
'models',
'open_source',
'requires_custom_nodes',
'author_name',
'author_avatar_url',
'run_count',
'view_count',
'copy_count',
'workflow_url'
] as const
export function useWorkflowTemplateSearch() {
const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_KEY)
const isLoading = ref(false)
const error = ref<Error | null>(null)
const results = ref<WorkflowTemplateSearchResult | null>(null)
async function search(
params: WorkflowTemplateSearchParams
): Promise<WorkflowTemplateSearchResult> {
isLoading.value = true
error.value = null
try {
const response = await searchClient.search<AlgoliaWorkflowTemplate>({
requests: [
{
indexName: INDEX_NAME,
query: params.query,
attributesToRetrieve: [...RETRIEVE_ATTRIBUTES],
hitsPerPage: params.pageSize,
page: params.pageNumber,
filters: params.filters,
facetFilters: params.facetFilters,
facets: ['tags', 'models', 'media_type', 'open_source']
}
]
})
const searchResponse = response
.results[0] as SearchResponse<AlgoliaWorkflowTemplate>
const result: WorkflowTemplateSearchResult = {
templates: searchResponse.hits,
totalHits: searchResponse.nbHits ?? 0,
totalPages: searchResponse.nbPages ?? 0,
page: searchResponse.page ?? 0,
facets: searchResponse.facets
}
results.value = result
return result
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
throw error.value
} finally {
isLoading.value = false
}
}
function clearResults() {
results.value = null
error.value = null
}
return {
search,
clearResults,
isLoading,
error,
results
}
}

View File

@@ -1,4 +1,115 @@
{
"discover": {
"searchPlaceholder": "Search workflows...",
"filters": {
"tags": "Tags",
"models": "Models",
"mediaType": "Media Type",
"openSource": "Open Source models only",
"cloudOnly": "Cloud only"
},
"resultsCount": "{count} result | {count} results",
"noResults": "No templates found",
"noResultsHint": "Try adjusting your search or filters",
"detail": {
"officialWorkflow": "Official Workflow",
"mediaType": "Media Type",
"openSource": "Uses open source models only",
"requiredNodes": "Required Custom Nodes",
"gallery": "Gallery",
"author": "Comfy Org",
"runWorkflow": "Run Workflow",
"makeCopy": "Make a Copy",
"openInHub": "Open in Hub",
"appMode": "App Mode",
"workflowPreview": "Workflow Preview",
"previewError": "Failed to load preview",
"previewHint": "Make a copy to save your changes",
"runWorkflowNotImplemented": "Run workflow is not implemented yet",
"makeCopyFailed": "Failed to copy workflow: {error}",
"appNotReady": "ComfyUI is still loading. Please wait and try again."
},
"author": {
"title": "Creator",
"searchPlaceholder": "Search this creator's workflows",
"workflowsCount": "{count} workflow | {count} workflows",
"runsLabel": "runs",
"copiesLabel": "copies",
"rankLabel": "rank",
"rankValue": "31",
"rankCaption": "Last 30 days",
"workflowsTitle": "Workflows",
"tagline": "Designing workflows that feel polished, practical, and production-ready.",
"aboutTitle": "About",
"aboutDescription": "Focused on building clean, reliable workflows with thoughtful defaults and clear documentation.",
"badgeCreator": "Creator profile",
"badgeOpenSource": "Open-source friendly",
"badgeTemplates": "Workflow templates",
"openInHub": "Open in Hub",
"noResults": "No workflows found for this creator",
"noResultsHint": "Try a different search or check back later."
},
"share": {
"share": "Share",
"copyFailed": "Failed to copy link to clipboard",
"noActiveWorkflow": "No active workflow to share",
"inviteTab": {
"title": "Share"
},
"publishTab": {
"title": "Publish",
"description": "Publish your workflow to Comfy Hub to share it with the community. Your workflow will be publicly visible and discoverable."
},
"inviteByEmail": {
"emailLabel": "Email Address",
"emailPlaceholder": "colleague{'@'}example.com",
"roleLabel": "Permission Level",
"roles": {
"viewOnly": "View Only",
"viewOnlyDescription": "Can view and run the workflow, but cannot make changes",
"edit": "Can Edit",
"editDescription": "Can view, run, and make changes to the workflow"
},
"sendInvite": "Send Invite",
"inviteSent": "Invite Sent",
"inviteSentDetail": "An invitation has been sent to {email}",
"peopleWithAccess": "People with access",
"inviteAnother": "Invite another person"
},
"publicUrl": {
"title": "Share with public link",
"description": "Anyone with this link can view and run your workflow, but they cannot edit it.",
"copyLink": "Copy link",
"copyLinkAppMode": "Copy link to App mode"
},
"publishToHubDialog": {
"title": "Publish to Hub",
"thumbnail": "Thumbnail",
"thumbnailPreview": "Thumbnail preview",
"uploadThumbnail": "Click to upload a thumbnail image",
"titlePlaceholder": "Enter a title for your workflow",
"descriptionPlaceholder": "Describe what your workflow does...",
"addTag": "Add a tag and press Enter",
"openSource": "Open Source",
"openSourceDescription": "Uses only open source models",
"publish": "Publish",
"notImplemented": "Publishing to Hub is not implemented yet",
"successTitle": "Published to Hub",
"successDescription": "Your workflow is live on Comfy Hub.",
"successOpen": "Open in Hub",
"successCopy": "Copy link"
}
}
},
"home": {
"title": "Home",
"tabs": {
"recents": "Recents",
"discover": "Discover"
},
"recentsStubTitle": "No recent workflows yet",
"recentsStubDescription": "Your recently opened workflows will appear here."
},
"g": {
"user": "User",
"you": "You",
@@ -6,6 +117,7 @@
"empty": "Empty",
"noWorkflowsFound": "No workflows found.",
"comingSoon": "Coming Soon",
"appModePlaceholderDescription": "Will show the workflow in app mode and you will be able to run it",
"download": "Download",
"downloadImage": "Download image",
"downloadVideo": "Download video",
@@ -718,6 +830,8 @@
"workflows": "Workflows",
"templates": "Templates",
"assets": "Assets",
"discover": "Discover",
"discoverPlaceholder": "Discover content coming soon",
"mediaAssets": {
"title": "Media Assets",
"sortNewestFirst": "Newest first",
@@ -830,7 +944,8 @@
"browse": "Browse",
"bookmarks": "Bookmarks",
"open": "Open"
}
},
"discoverWorkflows": "Discover Workflows"
}
},
"helpCenter": {

View File

@@ -68,6 +68,11 @@ const router = createRouter({
path: 'user-select',
name: 'UserSelectView',
component: () => import('@/views/UserSelectView.vue')
},
{
path: 'profiles/:slug',
name: 'AuthorProfileView',
component: () => import('@/views/AuthorProfileView.vue')
}
]
}

View File

@@ -0,0 +1,25 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
export const useHomePanelStore = defineStore('homePanel', () => {
const isOpen = useLocalStorage('Comfy.HomePanel.Open', false)
const openPanel = () => {
isOpen.value = true
}
const closePanel = () => {
isOpen.value = false
}
const togglePanel = () => {
isOpen.value = !isOpen.value
}
return {
isOpen,
openPanel,
closePanel,
togglePanel
}
})

View File

@@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSharePanelStore = defineStore('sharePanel', () => {
const isOpen = ref(false)
function openPanel() {
isOpen.value = true
}
function closePanel() {
isOpen.value = false
}
function togglePanel() {
isOpen.value = !isOpen.value
}
return {
isOpen,
openPanel,
closePanel,
togglePanel
}
})

View File

@@ -22,6 +22,15 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
)
})
const FULL_PAGE_TAB_IDS: string[] = []
const isFullPageTabActive = computed(() => {
return (
activeSidebarTabId.value !== null &&
FULL_PAGE_TAB_IDS.includes(activeSidebarTabId.value)
)
})
const toggleSidebarTab = (tabId: string) => {
activeSidebarTabId.value = activeSidebarTabId.value === tabId ? null : tabId
}
@@ -131,6 +140,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
sidebarTabs,
activeSidebarTabId,
activeSidebarTab,
isFullPageTabActive,
toggleSidebarTab,
registerSidebarTab,
unregisterSidebarTab,

View File

@@ -0,0 +1,41 @@
/**
* Workflow template record from the Algolia workflow_templates index.
*/
export interface AlgoliaWorkflowTemplate {
objectID: string
name: string
title: string
description: string
thumbnail_url: string
thumbnail_urls: string[]
thumbnail_count: number
thumbnail_variant: '' | 'compareSlider' | 'hoverDissolve' | 'hoverZoom'
media_type: 'image' | 'video' | 'audio' | '3d'
media_subtype: string
tags: string[]
models: string[]
open_source: boolean
requires_custom_nodes: string[]
author_name?: string
author_avatar_url?: string
run_count?: number
view_count?: number
copy_count?: number
workflow_url?: string
}
export interface WorkflowTemplateSearchParams {
query: string
pageSize: number
pageNumber: number
filters?: string
facetFilters?: string[][]
}
export interface WorkflowTemplateSearchResult {
templates: AlgoliaWorkflowTemplate[]
totalHits: number
totalPages: number
page: number
facets?: Record<string, Record<string, number>>
}

View File

@@ -0,0 +1,46 @@
type AuthorProfileStats = {
runs: number
copies: number
}
type AuthorProfile = {
slug: string
name: string
avatarUrl?: string
stats?: AuthorProfileStats
}
const KNOWN_AUTHORS: Record<string, AuthorProfile> = {
comfyorg: {
slug: 'comfyorg',
name: 'Comfy Org',
avatarUrl: '/assets/images/comfy-logo-single.svg',
stats: { runs: 128_400, copies: 4_260 }
}
}
const slugify = (value: string) =>
value
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, '')
.trim()
export const authorNameToSlug = (name: string) =>
KNOWN_AUTHORS[slugify(name)]?.slug ?? slugify(name)
const titleize = (value: string) =>
value
.replaceAll(/[-_]+/g, ' ')
.replaceAll(/\s+/g, ' ')
.trim()
.replaceAll(/\b\w/g, (char) => char.toUpperCase())
export const authorSlugToProfile = (slug: string): AuthorProfile => {
const normalized = slugify(slug)
return (
KNOWN_AUTHORS[normalized] ?? {
slug: normalized,
name: titleize(slug)
}
)
}

View File

@@ -0,0 +1,67 @@
<template>
<WorkflowDetailView
v-if="selectedWorkflow"
:workflow="selectedWorkflow"
@back="selectedWorkflow = null"
@author-selected="handleAuthorSelected"
@run-workflow="handleRunWorkflow"
@make-copy="handleMakeCopy"
/>
<AuthorProfileView
v-else
:author-name="profile.name"
:author-avatar-url="profile.avatarUrl"
:stats="profile.stats"
@back="handleBack"
@select-workflow="handleAuthorWorkflowSelect"
/>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AuthorProfileView from '@/components/discover/AuthorProfileView.vue'
import WorkflowDetailView from '@/components/discover/WorkflowDetailView.vue'
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
import {
authorNameToSlug,
authorSlugToProfile
} from '@/utils/authorProfileUtil'
const route = useRoute()
const router = useRouter()
const selectedWorkflow = ref<AlgoliaWorkflowTemplate | null>(null)
const profile = computed(() =>
authorSlugToProfile(String(route.params.slug ?? ''))
)
const handleAuthorWorkflowSelect = (template: AlgoliaWorkflowTemplate) => {
selectedWorkflow.value = template
}
const handleBack = () => {
void router.push({ name: 'GraphView' })
}
const handleAuthorSelected = (author: { name: string; avatarUrl?: string }) => {
const slug = authorNameToSlug(author.name)
void router.push({ name: 'AuthorProfileView', params: { slug } })
}
const handleRunWorkflow = (_workflow: AlgoliaWorkflowTemplate) => {
// TODO: Implement workflow run
}
const handleMakeCopy = (_workflow: AlgoliaWorkflowTemplate) => {
// TODO: Implement make a copy
}
watch(
() => route.params.slug,
() => {
selectedWorkflow.value = null
}
)
</script>