mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
22 Commits
fix/load-a
...
rh/hub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fdb07e2d8 | ||
|
|
b17971a75a | ||
|
|
08c35b49f3 | ||
|
|
ad849e34ef | ||
|
|
13cdc8109c | ||
|
|
791a1f81d3 | ||
|
|
0c62f09b00 | ||
|
|
f61e754aa4 | ||
|
|
8afc9fd20a | ||
|
|
f28c76fff0 | ||
|
|
11d029fa15 | ||
|
|
8af249a046 | ||
|
|
7ddf7bdbc5 | ||
|
|
58362c79e9 | ||
|
|
fd8bcbb090 | ||
|
|
c5e7ad5e55 | ||
|
|
c19ea957df | ||
|
|
b5d19ace5e | ||
|
|
0ffb24b108 | ||
|
|
06ed6285ab | ||
|
|
da0d320716 | ||
|
|
9c2e1b6611 |
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
239
src/components/actionbar/PublishToHubDialogContent.vue
Normal file
239
src/components/actionbar/PublishToHubDialogContent.vue
Normal 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>
|
||||
37
src/components/actionbar/ShareButton.vue
Normal file
37
src/components/actionbar/ShareButton.vue
Normal 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>
|
||||
533
src/components/actionbar/SharePanel.vue
Normal file
533
src/components/actionbar/SharePanel.vue
Normal 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>
|
||||
369
src/components/discover/AuthorProfileView.vue
Normal file
369
src/components/discover/AuthorProfileView.vue
Normal 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>
|
||||
387
src/components/discover/DiscoverView.vue
Normal file
387
src/components/discover/DiscoverView.vue
Normal 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>
|
||||
268
src/components/discover/WorkflowDetailView.vue
Normal file
268
src/components/discover/WorkflowDetailView.vue
Normal 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>
|
||||
188
src/components/discover/WorkflowPreviewCanvas.vue
Normal file
188
src/components/discover/WorkflowPreviewCanvas.vue
Normal 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>
|
||||
@@ -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
|
||||
)
|
||||
|
||||
70
src/components/home/HomePanel.vue
Normal file
70
src/components/home/HomePanel.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
|
||||
100
src/composables/discover/useWorkflowTemplateSearch.ts
Normal file
100
src/composables/discover/useWorkflowTemplateSearch.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
25
src/stores/workspace/homePanelStore.ts
Normal file
25
src/stores/workspace/homePanelStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
25
src/stores/workspace/sharePanelStore.ts
Normal file
25
src/stores/workspace/sharePanelStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
41
src/types/discoverTypes.ts
Normal file
41
src/types/discoverTypes.ts
Normal 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>>
|
||||
}
|
||||
46
src/utils/authorProfileUtil.ts
Normal file
46
src/utils/authorProfileUtil.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
67
src/views/AuthorProfileView.vue
Normal file
67
src/views/AuthorProfileView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user