diff --git a/site/package.json b/site/package.json index 5b1f4b336..5b74233c6 100644 --- a/site/package.json +++ b/site/package.json @@ -69,6 +69,8 @@ "cheerio": "1.0.0-rc.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-vue": "^8.6.0", "lucide-vue-next": "^0.575.0", "minisearch": "^7.2.0", "openai": "^4.0.0", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index f2219e73d..0308d52dd 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + embla-carousel-autoplay: + specifier: ^8.6.0 + version: 8.6.0(embla-carousel@8.6.0) + embla-carousel-vue: + specifier: ^8.6.0 + version: 8.6.0(vue@3.5.29(typescript@5.9.3)) lucide-vue-next: specifier: ^0.575.0 version: 0.575.0(vue@3.5.29(typescript@5.9.3)) @@ -2458,6 +2464,24 @@ packages: electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + embla-carousel-autoplay@8.6.0: + resolution: {integrity: sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-vue@8.6.0: + resolution: {integrity: sha512-v8UO5UsyLocZnu/LbfQA7Dn2QHuZKurJY93VUmZYP//QRWoCWOsionmvLLAlibkET3pGPs7++03VhJKbWD7vhQ==} + peerDependencies: + vue: ^3.2.37 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emmet@2.4.11: resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} @@ -7348,6 +7372,22 @@ snapshots: electron-to-chromium@1.5.302: {} + embla-carousel-autoplay@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-vue@8.6.0(vue@3.5.29(typescript@5.9.3)): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + vue: 3.5.29(typescript@5.9.3) + + embla-carousel@8.6.0: {} + emmet@2.4.11: dependencies: '@emmetio/abbreviation': 2.3.3 diff --git a/site/public/brand/comfy-hub-logo.svg b/site/public/brand/comfy-hub-logo.svg index 3aa05e881..5f3f4973a 100644 --- a/site/public/brand/comfy-hub-logo.svg +++ b/site/public/brand/comfy-hub-logo.svg @@ -1,6 +1,8 @@ - - - - - + + + + + + + diff --git a/site/public/icons/arrow-right.svg b/site/public/icons/arrow-right.svg new file mode 100644 index 000000000..84ff4ceb3 --- /dev/null +++ b/site/public/icons/arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/public/icons/arrow-up-right.svg b/site/public/icons/arrow-up-right.svg new file mode 100644 index 000000000..d74a0c9f3 --- /dev/null +++ b/site/public/icons/arrow-up-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/site/public/icons/breadthumb.svg b/site/public/icons/breadthumb.svg new file mode 100644 index 000000000..aebdec5c1 --- /dev/null +++ b/site/public/icons/breadthumb.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/public/icons/close.svg b/site/public/icons/close.svg new file mode 100644 index 000000000..1fcb5bdbe --- /dev/null +++ b/site/public/icons/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/site/public/icons/logomark.svg b/site/public/icons/logomark.svg new file mode 100644 index 000000000..4e7ccc51c --- /dev/null +++ b/site/public/icons/logomark.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/public/icons/node-left.svg b/site/public/icons/node-left.svg new file mode 100644 index 000000000..5e4bf3833 --- /dev/null +++ b/site/public/icons/node-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/public/icons/node-right.svg b/site/public/icons/node-right.svg new file mode 100644 index 000000000..b9465b1d9 --- /dev/null +++ b/site/public/icons/node-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/public/icons/node-union.svg b/site/public/icons/node-union.svg new file mode 100644 index 000000000..e8ef05de5 --- /dev/null +++ b/site/public/icons/node-union.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/public/icons/social/github.svg b/site/public/icons/social/github.svg new file mode 100644 index 000000000..54b40b9c8 --- /dev/null +++ b/site/public/icons/social/github.svg @@ -0,0 +1 @@ + diff --git a/site/scripts/lib/search/build-index.ts b/site/scripts/lib/search/build-index.ts index d69a6656a..0b5723db6 100644 --- a/site/scripts/lib/search/build-index.ts +++ b/site/scripts/lib/search/build-index.ts @@ -8,6 +8,12 @@ import MiniSearch from 'minisearch'; import { CONTENT_DIR, SITE_DIR } from '../paths'; import { logger } from '../logger'; import { tagDisplayName, tagSearchText } from '../../../src/lib/tag-aliases'; +import { + SEARCH_FIELDS, + STORE_FIELDS, + tokenize, + searchOptions, +} from '../../../src/lib/search-config'; interface SearchDocument { id: string; @@ -38,6 +44,7 @@ interface IndexEntry { username?: string; thumbnailUrl?: string; shareId?: string; + usage?: number; } const MEDIA_TYPE_LABELS: Record = { @@ -58,7 +65,9 @@ async function fetchIndexEntries(): Promise { const approvedOnly = process.env.PUBLIC_APPROVED_ONLY === 'true'; const statuses = approvedOnly ? 'approved' : 'pending,approved,rejected,deprecated'; - const res = await fetch(`${apiUrl}/api/hub/workflows/index?status=${statuses}`); + const res = await fetch(`${apiUrl}/api/hub/workflows/index?status=${statuses}`, { + signal: AbortSignal.timeout(10000), + }); if (!res.ok) { throw new Error(`Hub API returned ${res.status}: ${res.statusText}`); @@ -74,7 +83,9 @@ async function fetchProfileDisplayName(username: string): Promise { const apiUrl = (process.env.PUBLIC_HUB_API_URL || '').replace(/\/$/, ''); if (!apiUrl) return username; try { - const res = await fetch(`${apiUrl}/api/hub/profiles/${encodeURIComponent(username)}`); + const res = await fetch(`${apiUrl}/api/hub/profiles/${encodeURIComponent(username)}`, { + signal: AbortSignal.timeout(10000), + }); if (!res.ok) return username; const profile = (await res.json()) as { display_name?: string }; return profile.display_name || username; @@ -130,7 +141,7 @@ export async function buildSearchIndex(): Promise { name, slug, thumbnail: data.thumbnailUrl || '', - usage: 0, + usage: data.usage || 0, tagsArray: tags.map(tagDisplayName), }); } @@ -178,44 +189,15 @@ export async function buildSearchIndex(): Promise { logger.info(`Indexing ${documents.length} templates...`); - // Custom tokenizer: keep hyphenated terms as single tokens AND emit sub-parts. - // e.g. "flux-image-to-video" → ["flux-image-to-video", "flux", "image", "to", "video"] - // This lets "z-image" match as a prefix against "z-image-to-video" style titles. - const tokenize = (text: string): string[] => { - const tokens: string[] = []; - const words = text.toLowerCase().split(/\s+/).filter(Boolean); - for (const word of words) { - tokens.push(word); - if (word.includes('-')) { - for (const part of word.split('-')) { - if (part) tokens.push(part); - } - } - } - return tokens; - }; - + // Field config + tokenizer come from the SHARED search-config so the index and + // the client query layer never drift. (searchOptions is NOT serialized by + // MiniSearch — the client re-specifies it at query time; we set sensible + // defaults here only for any in-process searches.) const miniSearch = new MiniSearch({ - fields: ['title', 'description', 'tags', 'models', 'mediaType', 'creatorName'], - storeFields: [ - 'title', - 'mediaType', - 'mediaTypeLabel', - 'name', - 'slug', - 'thumbnail', - 'username', - 'creatorName', - 'usage', - 'tagsArray', - ], + fields: [...SEARCH_FIELDS], + storeFields: [...STORE_FIELDS], tokenize, - searchOptions: { - boost: { title: 3, models: 2, tags: 2, creatorName: 1.5, mediaType: 1, description: 0.5 }, - prefix: true, - fuzzy: 0.2, - tokenize, - }, + searchOptions: searchOptions() as Record, }); miniSearch.addAll(documents); diff --git a/site/src/components/ThumbnailDisplay.astro b/site/src/components/ThumbnailDisplay.astro index 261756faa..b281204ea 100644 --- a/site/src/components/ThumbnailDisplay.astro +++ b/site/src/components/ThumbnailDisplay.astro @@ -44,7 +44,8 @@ function thumbUrl(thumb: string): string { return `/workflows/thumbnails/${thumb}`; } -const imgClass = naturalAspect ? 'w-full h-auto' : 'w-full h-full object-cover'; +const mediaClass = naturalAspect ? 'w-full h-auto' : 'w-full h-full object-cover'; +const imgClass = mediaClass; const primaryThumb = thumbnails[0]; const secondaryThumb = thumbnails[1]; @@ -116,7 +117,7 @@ const secondaryVideoPosterUrl = isSecondaryVideo ? getVideoFrameUrl(secondarySrc
+ + + + + + + + +
+ + + + + + + + + + + + {{ group.label }} + + {{ activeCountForType(group.type) }} + + + + + + +
+ +
+ +
    +
  • + +
  • +
  • + {{ labels.noResults }} +
  • +
+
+ + +
+ +
+
+
+
+
+ + + +
+
+ diff --git a/site/src/components/hub/FeaturedCarousel.vue b/site/src/components/hub/FeaturedCarousel.vue new file mode 100644 index 000000000..faa873a2e --- /dev/null +++ b/site/src/components/hub/FeaturedCarousel.vue @@ -0,0 +1,301 @@ + + + diff --git a/site/src/components/hub/HubBrowse.vue b/site/src/components/hub/HubBrowse.vue index 6d210f74a..c9fbbfb88 100644 --- a/site/src/components/hub/HubBrowse.vue +++ b/site/src/components/hub/HubBrowse.vue @@ -1,19 +1,17 @@ diff --git a/site/src/components/hub/HubHero.astro b/site/src/components/hub/HubHero.astro index d0a4e257a..c2fd7ab5e 100644 --- a/site/src/components/hub/HubHero.astro +++ b/site/src/components/hub/HubHero.astro @@ -1,19 +1,29 @@ --- /** - * HubHero - Centered hero section for the hub page - * Matches Figma: "ComfyHub" in yellow italic + muted subtitle + * HubHero - Centered hero/section heading for the hub library grid. + * Browse-oriented, SEO-friendly copy with a live workflow count. */ import { getLocaleFromPath } from '../../i18n/utils'; import { t } from '../../i18n/ui'; +interface Props { + /** Total number of templates, rendered into the title. */ + count: number; +} + +const { count } = Astro.props; const locale = getLocaleFromPath(Astro.url.pathname); --- -
-

- {t('hub.title', locale)} -

-

- {t('hub.subtitle', locale)} +

+

+ {t('hub.browse.eyebrow', locale)}

+

+ {count.toLocaleString(locale)}{t('hub.browse.title.suffix', locale)} +

diff --git a/site/src/components/hub/HubNavbar.astro b/site/src/components/hub/HubNavbar.astro index f2255f828..af2baf3f1 100644 --- a/site/src/components/hub/HubNavbar.astro +++ b/site/src/components/hub/HubNavbar.astro @@ -1,100 +1,65 @@ --- import { getCollection } from 'astro:content'; import { getLocaleFromPath, localizeUrl } from '../../i18n/utils'; +import HeaderMain from './nav/HeaderMain.vue'; import SearchPopover from './SearchPopover.vue'; -import MobileFilterDrawer from './MobileFilterDrawer.vue'; import { getProfileCache, - serializeCollectionEntry, + getCreatorsList, + loadSerializedTemplates, type SerializedTemplate, } from '../../lib/hub-api'; -import { getCloudLandingUrl } from '../../lib/urls'; -import { featureFlags } from '../../config/featureFlags'; - -const tryCloudCtaText = featureFlags.cloudFreeTier ? 'Try Comfy Cloud for free' : 'Try Comfy Cloud'; +import { fetchGitHubStars, formatStarCount } from '../../lib/github'; interface Props { - templates?: SerializedTemplate[]; + /** Render the search bar in the header. Index pages set false (render it in-body). */ + showSearch?: boolean; + /** Pin the header to the top. Defaults to showSearch so existing callers are unchanged. */ + sticky?: boolean; } +const { showSearch = true, sticky = showSearch } = Astro.props; + +const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI'); +const githubStars = rawStars ? formatStarCount(rawStars) : ''; + const profiles = await getProfileCache(); const locale = getLocaleFromPath(Astro.url.pathname); -// Derive creators list from profile cache (hub API) -const creatorsList = Array.from(profiles.entries()).map(([username, profile]) => ({ - username, - displayName: profile.display_name || username, - summary: profile.description || '', - social: profile.website_urls || '', - avatarUrl: profile.avatar_url || '', -})); - -// Use provided templates or fetch and serialize them -let templates: SerializedTemplate[]; -if (Astro.props.templates && Astro.props.templates.length > 0) { - templates = Astro.props.templates; -} else { - const allTemplates = (await getCollection('templates')) - .filter((tmpl) => !tmpl.id.includes('/')) - .sort((a, b) => (b.data.usage || 0) - (a.data.usage || 0)); +// Top creators for the mobile menu (locale-aware profile links). +const creatorLinks = Array.from(profiles.entries()) + .slice(0, 5) + .map(([username, profile]) => ({ + username, + displayName: profile.display_name || username, + href: localizeUrl(`/workflows/creators/${username}/`, locale), + })); - templates = allTemplates.map((tmpl) => serializeCollectionEntry(tmpl.data, profiles)); +// Fetch search data only when the header renders the search bar. +let searchTemplates: SerializedTemplate[] = []; +const searchCreators = showSearch ? getCreatorsList(profiles) : []; +if (showSearch) { + searchTemplates = await loadSerializedTemplates(() => getCollection('templates')); } --- - - - - + diff --git a/site/src/components/hub/HubSearchBar.astro b/site/src/components/hub/HubSearchBar.astro new file mode 100644 index 000000000..4d8adddbc --- /dev/null +++ b/site/src/components/hub/HubSearchBar.astro @@ -0,0 +1,18 @@ +--- +/** Inline search: sits among the filters, then docks flush below the sticky navbar + * (top-hub-nav) once scrolled past. SearchPopover keeps its own pill shape. */ +import SearchPopover from './SearchPopover.vue'; +import { getProfileCache, getCreatorsList, type SerializedTemplate } from '../../lib/hub-api'; + +interface Props { + templates: SerializedTemplate[]; + locale: string; +} + +const { templates, locale } = Astro.props; +const creators = getCreatorsList(await getProfileCache()); +--- + +
+ +
diff --git a/site/src/components/hub/HubWorkflowCard.vue b/site/src/components/hub/HubWorkflowCard.vue index ad7e971ac..d37d1a8ac 100644 --- a/site/src/components/hub/HubWorkflowCard.vue +++ b/site/src/components/hub/HubWorkflowCard.vue @@ -2,49 +2,18 @@ /** * HubWorkflowCard - Unified workflow card component. * Used inside Vue islands (WorkflowGrid.vue) and SSR-rendered in Astro pages. - * Visual structure: square thumbnail, logo overlay, title, author, tag pills. + * Visual structure: landscape thumbnail with the title and provider logo overlaid + * on it; creator line + CTA and tag pills beneath. */ -import { Badge } from '@/components/ui/badge'; import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'; -import { tagSlug, tagDisplayName } from '@/lib/tag-aliases'; -import { slugify } from '@/lib/slugify'; +import TagRow from '@/components/hub/TagRow.vue'; import type { ThumbnailVariant } from '@/lib/hub-api'; import { initCompareSlider } from '@/lib/initCompareSlider'; import { getVideoFrameUrl } from '@/lib/video-thumbnail'; import { isVideoFile, isAudioFile, isMediaFile } from '@/lib/media-utils'; -import { workflowDetailPath } from '@/lib/routes'; - -const MODEL_TO_LOGO: Record = { - Grok: 'grok', - OpenAI: 'openai', - Stability: 'stability', - 'Stable Diffusion': 'stability', - SDXL: 'stability', - Wan: 'wan', - Flux: 'bfl', - Google: 'google', - Runway: 'runway', - Luma: 'luma', - Kling: 'kling', - Hunyuan: 'hunyuan', - ByteDance: 'bytedance', - HitPaw: 'hitpaw', - Recraft: 'recraft', - Topaz: 'topaz', - Vidu: 'vidu', - WaveSpeed: 'wavespeed', - Mochi: 'mochi', - Pika: 'pika', - Sora: 'sora', - Minimax: 'minimax', - Lightricks: 'lightricks', - Ideogram: 'ideogram', - Magnific: 'magnific', - Rodin: 'rodin', - Tripo: 'tripo', - PixVerse: 'pixverse', - Bria: 'bria', -}; +import { workflowDetailPath, creatorPath, thumbnailPath } from '@/lib/routes'; +import { providerLogos } from '@/lib/provider-logos'; +import { ButtonPill } from '@/components/ui/button-pill'; interface Props { name: string; @@ -60,6 +29,7 @@ interface Props { isApp?: boolean; hideAuthor?: boolean; thumbnailVariant?: ThumbnailVariant; + mediaType?: string; mediaSubtype?: string; } @@ -73,25 +43,20 @@ const props = withDefaults(defineProps(), { creatorAvatarUrl: '', isApp: false, hideAuthor: false, + mediaType: '', mediaSubtype: '', }); -function getLogoPath(name: string): string | null { - const slug = MODEL_TO_LOGO[name]; - if (slug) return `/logos/${slug}.png`; - const lower = name.toLowerCase(); - for (const [key, val] of Object.entries(MODEL_TO_LOGO)) { - if (lower.includes(key.toLowerCase())) return `/logos/${val}.png`; - } - return null; -} - -const providerName = computed(() => { - const p = props.logos?.[0]?.provider; - return Array.isArray(p) ? p[0] : p || null; -}); +/** Neutral fallback chip when a card has no tags, derived from media type. */ +const MEDIA_TYPE_LABELS: Record = { + image: 'Image', + video: 'Video', + audio: 'Audio', + '3d': '3D', +}; +const tagFallbackLabel = computed(() => MEDIA_TYPE_LABELS[props.mediaType] ?? ''); -const logoPath = computed(() => (providerName.value ? getLogoPath(providerName.value) : null)); +const modelLogos = computed(() => providerLogos(props.logos).slice(0, 3)); const authorName = computed(() => props.creatorDisplayName || 'ComfyUI'); @@ -112,9 +77,7 @@ const isVideoPrimary = computed(() => { const videoUrl = computed(() => { if (!isVideoPrimary.value) return null; - const f = primaryFile.value!; - if (f.startsWith('http://') || f.startsWith('https://')) return f; - return `/workflows/thumbnails/${f}`; + return thumbnailPath(primaryFile.value!); }); const posterUrl = computed(() => { @@ -130,12 +93,8 @@ function onVideoError() { const primaryUrl = computed(() => { const f = primaryFile.value; - if (!f) return null; - if (isMediaFile(f)) { - return null; - } - if (f.startsWith('http://') || f.startsWith('https://')) return f; - return `/workflows/thumbnails/${f}`; + if (!f || isMediaFile(f)) return null; + return thumbnailPath(f); }); const hasSecondImage = computed(() => { @@ -149,9 +108,7 @@ const hasSecondImage = computed(() => { const secondaryUrl = computed(() => { if (!hasSecondImage.value || !secondaryFile.value) return null; - const f = secondaryFile.value; - if (f.startsWith('http://') || f.startsWith('https://')) return f; - return `/workflows/thumbnails/${f}`; + return thumbnailPath(secondaryFile.value); }); const showCompare = computed( @@ -212,18 +169,9 @@ onUnmounted(() => { removeCompareListeners?.(); }); -const displayTags = computed(() => props.tags.slice(0, 3)); - -function getTagUrl(tag: string): string { - const base = `/workflows/tag/${tagSlug(tag)}/`; - return props.locale && props.locale !== 'en' ? `/${props.locale}${base}` : base; -} - -const creatorUrl = computed(() => { - if (!props.username) return null; - const base = `/workflows/${slugify(props.username)}/`; - return props.locale && props.locale !== 'en' ? `/${props.locale}${base}` : base; -}); +const creatorUrl = computed(() => + props.username ? creatorPath(props.username, props.locale) : null +); function handleCardClick() { if (!templateUrl.value) return; @@ -233,13 +181,11 @@ function handleCardClick() { diff --git a/site/src/components/hub/MobileFilterDrawer.vue b/site/src/components/hub/MobileFilterDrawer.vue deleted file mode 100644 index 379a11b89..000000000 --- a/site/src/components/hub/MobileFilterDrawer.vue +++ /dev/null @@ -1,225 +0,0 @@ - - - diff --git a/site/src/components/hub/SearchPopover.vue b/site/src/components/hub/SearchPopover.vue index 7f7565446..11c62a3f8 100644 --- a/site/src/components/hub/SearchPopover.vue +++ b/site/src/components/hub/SearchPopover.vue @@ -20,10 +20,11 @@ import { IconApps, IconWorkflow } from '@/components/ui/icons'; import { tagDisplayName } from '@/lib/tag-aliases'; import { slugify } from '@/lib/slugify'; import { trackSearchPerformed, trackFilterApplied } from '@/lib/posthog'; -import type { MediaType } from '@/lib/hub-api'; +import type { MediaType, CreatorEntry } from '@/lib/hub-api'; import { isAudioFile, isVideoFile } from '@/lib/media-utils'; import { getVideoFrameUrl } from '@/lib/video-thumbnail'; -import { workflowDetailPath, workflowDetailSlug } from '@/lib/routes'; +import { workflowDetailPath, workflowDetailSlug, thumbnailPath } from '@/lib/routes'; +import { cn } from '@/lib/utils'; export interface SearchTemplate { name: string; @@ -43,14 +44,6 @@ export interface SearchTemplate { isApp: boolean; } -export interface CreatorEntry { - username: string; - displayName: string; - summary?: string; - social?: string | string[]; - avatarUrl?: string; -} - const props = defineProps<{ templates: SearchTemplate[]; creators: CreatorEntry[]; @@ -64,7 +57,6 @@ const searchQuery = ref(''); const containerRef = ref(null); const inputRef = ref(null); -// Search state const searchResults = ref(null); const isSearching = ref(false); const hasSearched = ref(false); @@ -80,8 +72,6 @@ const overflowCountDesktop = computed(() => Math.max(0, store.filterBadges.value.length - MAX_BADGES_DESKTOP) ); -// ── All tags and models with counts (for filter suggestions) ── - const allTags = computed(() => { const counts = new Map(); for (const tmpl of props.templates) { @@ -106,8 +96,6 @@ const allModels = computed(() => { .map(([name, count]) => ({ name, count })); }); -// ── Filter suggestions (shown while typing) ── - const filterSuggestions = computed(() => { const q = searchQuery.value.trim().toLowerCase(); if (!q) return { tags: [], models: [] }; @@ -139,8 +127,6 @@ const hasFilterSuggestions = computed(() => { return filterSuggestions.value.tags.length > 0 || filterSuggestions.value.models.length > 0; }); -// ── Badge-filtered templates ── - const badgeFilteredTemplates = computed(() => { if (!hasBadges.value) return props.templates; @@ -183,8 +169,6 @@ const badgeOnlyResults = computed(() => { return [...badgeFilteredTemplates.value].sort((a, b) => b.usage - a.usage); }); -// ── Search with debounce ── - watchDebounced( [searchQuery, () => store.filterBadges.value], async ([query]) => { @@ -200,6 +184,9 @@ watchDebounced( allowedNames: badgeFilteredNames.value ?? undefined, }); trackSearchPerformed(trimmed); + } catch (err) { + console.error('Search failed:', err); + searchResults.value = { workflows: [], creators: [] }; } finally { isSearching.value = false; hasSearched.value = true; @@ -208,8 +195,6 @@ watchDebounced( { debounce: 200 } ); -// ── Displayed results (combines badge-only and text search) ── - const displayedWorkflows = computed(() => { // Text query active → show search results if (hasQuery.value && searchResults.value) { @@ -264,6 +249,15 @@ const matchedCreators = computed(() => { .slice(0, 5); }); +// Screen-reader announcement for the live result count while searching. +const resultAnnouncement = computed(() => { + if (!isOpen.value || !hasQuery.value) return ''; + if (isSearching.value) return 'Searching…'; + if (!hasSearched.value) return ''; + const count = displayedWorkflows.value.length + matchedCreators.value.length; + return count === 0 ? 'No results found' : `${count} result${count === 1 ? '' : 's'} found`; +}); + const MEDIA_TYPE_LABELS: Record = { image: 'Image', video: 'Video', @@ -271,8 +265,6 @@ const MEDIA_TYPE_LABELS: Record = { '3d': '3D', }; -// ── Helpers ── - // Build a workflow detail URL from a pre-computed slug (search-index `slug` // field). Returns null when the slug is empty/undefined so callers can avoid // rendering /workflows/undefined/ links when the search index is malformed. @@ -298,7 +290,6 @@ watch( } ); -// Popular workflows — top 4 by usage. const popularWorkflows = computed(() => [...props.templates].sort((a, b) => b.usage - a.usage).slice(0, 4) ); @@ -343,14 +334,11 @@ const remainingModelCount = computed(() => Math.max(0, allModels.value.length - DISCOVERY_PREVIEW_COUNT) ); -// Mode filter items for the discovery panel const modeItems = [ { name: 'Apps', value: 'app' }, { name: 'Node Graphs', value: 'nodeGraph' }, ]; -// ── Badge actions ── - function addFilterBadge(type: 'tag' | 'model' | 'mode', value: string) { store.addBadge({ type, value }); trackFilterApplied(type, value); @@ -365,9 +353,6 @@ function removeLastBadge() { } } -// ── Keyboard navigation ── - -// Discovery panel offsets const discCreatorOffset = computed(() => popularWorkflows.value.length); const discTagOffset = computed(() => discCreatorOffset.value + topCreators.value.length); const discModelOffset = computed(() => discTagOffset.value + previewTags.value.length); @@ -481,6 +466,17 @@ function handleKeydown(e: KeyboardEvent) { } } +// Native form submit (Enter). Activate the highlighted item, else the first +// result if one exists. `@submit.prevent` stops the default page reload. +function onSubmit() { + if (!isOpen.value) return; + if (activeIndex.value >= 0) { + activateItem(activeIndex.value); + } else if (totalNavigable.value > 0) { + activateItem(0); + } +} + function clearSearch() { searchQuery.value = ''; inputRef.value?.focus(); @@ -492,7 +488,6 @@ function clearAll() { inputRef.value?.focus(); } -// Creator color palette const CREATOR_COLORS = ['#f2ff59', '#ff4444', '#ff4444']; function getCreatorColor(index: number): string { @@ -517,11 +512,6 @@ function formatUsage(usage: number): string { return String(usage); } -function resolveThumbUrl(file: string): string { - if (file.startsWith('http://') || file.startsWith('https://')) return file; - return `/workflows/thumbnails/${file}`; -} - /** * Returns an src for image thumbnails, or a Cloudflare poster-frame URL * for CDN-hosted videos. Returns null when the file has no static preview @@ -531,8 +521,8 @@ function resolveThumbUrl(file: string): string { function getImageThumb(file: string | undefined | null): string | null { if (!file) return null; if (isAudioFile(file)) return null; - if (isVideoFile(file)) return getVideoFrameUrl(resolveThumbUrl(file)); - return resolveThumbUrl(file); + if (isVideoFile(file)) return getVideoFrameUrl(thumbnailPath(file)); + return thumbnailPath(file); } /** @@ -542,7 +532,7 @@ function getImageThumb(file: string | undefined | null): string | null { */ function videoThumbUrl(file: string | undefined | null): string | null { if (!file || !isVideoFile(file)) return null; - return resolveThumbUrl(file); + return thumbnailPath(file); } function handleFocus() { @@ -571,12 +561,10 @@ function handleGlobalSlash(e: KeyboardEvent) { inputRef.value?.focus(); } -// Reset active index when context changes watch([searchQuery, () => store.filterBadges.value.length, isOpen, searchResults], () => { activeIndex.value = -1; }); -// Scroll active item into view watch(activeIndex, (idx) => { if (idx < 0) return; nextTick(() => { @@ -599,13 +587,18 @@ onUnmounted(() => {