mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
4 Commits
move-image
...
prototype/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0055cba2e | ||
|
|
b8153ed055 | ||
|
|
3fe50b20bb | ||
|
|
348019ba9d |
99
apps/website/src/components/prototype/PrototypeSwitcher.vue
Normal file
99
apps/website/src/components/prototype/PrototypeSwitcher.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
// PROTOTYPE — floating variant switcher bar. Not part of the design under
|
||||
// review. Shown in all builds: it only ever mounts on the throwaway
|
||||
// /learning-prototype route, which is deleted once a variant wins.
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
interface PrototypeVariant {
|
||||
key: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const { variants, current } = defineProps<{
|
||||
variants: PrototypeVariant[]
|
||||
current: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:current': [key: string]
|
||||
}>()
|
||||
|
||||
const currentIndex = () =>
|
||||
Math.max(
|
||||
0,
|
||||
variants.findIndex((variant) => variant.key === current)
|
||||
)
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const next = (currentIndex() + direction + variants.length) % variants.length
|
||||
emit('update:current', variants[next].key)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowLeft') cycle(-1)
|
||||
if (event.key === 'ArrowRight') cycle(1)
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-primary-comfy-yellow fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-1 rounded-full border-2 border-primary-comfy-ink py-1.5 pr-4 pl-1.5 text-primary-comfy-ink shadow-[0_8px_30px_rgba(0,0,0,0.5)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous variant"
|
||||
class="flex size-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-primary-comfy-ink/10"
|
||||
@click="cycle(-1)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="15 6 9 12 15 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span class="text-xs font-bold tracking-wide uppercase select-none">
|
||||
Prototype {{ current.toUpperCase() }} —
|
||||
{{ variants[currentIndex()].name }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next variant"
|
||||
class="flex size-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-primary-comfy-ink/10"
|
||||
@click="cycle(1)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
// PROTOTYPE — /learning redesign: three variants switchable via ?variant=a|b|c
|
||||
// (floating bottom bar, ← / → keys also cycle). The active category filter is
|
||||
// mirrored to ?category=vfx|animations|ads, standing in for the eventual
|
||||
// /learning/vfx-style routes. Mounted client:only, so URL access is safe here.
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import type { CategoryFilter } from './prototypeData'
|
||||
|
||||
import PrototypeSwitcher from '../PrototypeSwitcher.vue'
|
||||
import VariantA from './VariantA.vue'
|
||||
import VariantB from './VariantB.vue'
|
||||
import VariantC from './VariantC.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const VARIANTS = [
|
||||
{ key: 'a', name: 'Filter bar + featured lead' },
|
||||
{ key: 'b', name: 'Sidebar directory' },
|
||||
{ key: 'c', name: 'Spotlight + shelves' }
|
||||
]
|
||||
|
||||
const CATEGORY_KEYS: readonly CategoryFilter[] = [
|
||||
'all',
|
||||
'vfx',
|
||||
'animations',
|
||||
'ads'
|
||||
]
|
||||
|
||||
const readParam = <T extends string>(
|
||||
key: string,
|
||||
allowed: readonly T[],
|
||||
fallback: T
|
||||
): T => {
|
||||
const value = new URLSearchParams(window.location.search).get(key)
|
||||
return allowed.includes(value as T) ? (value as T) : fallback
|
||||
}
|
||||
|
||||
const variant = ref(
|
||||
readParam(
|
||||
'variant',
|
||||
VARIANTS.map((v) => v.key),
|
||||
'a'
|
||||
)
|
||||
)
|
||||
const category = ref<CategoryFilter>(
|
||||
readParam('category', CATEGORY_KEYS, 'all')
|
||||
)
|
||||
|
||||
const syncParam = (key: string, value: string, omitWhen?: string) => {
|
||||
const url = new URL(window.location.href)
|
||||
if (value === omitWhen) url.searchParams.delete(key)
|
||||
else url.searchParams.set(key, value)
|
||||
history.replaceState(null, '', url)
|
||||
}
|
||||
|
||||
watch(variant, (value) => syncParam('variant', value))
|
||||
watch(category, (value) => syncParam('category', value, 'all'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VariantA v-if="variant === 'a'" v-model:category="category" :locale />
|
||||
<VariantB v-else-if="variant === 'b'" v-model:category="category" :locale />
|
||||
<VariantC v-else v-model:category="category" :locale />
|
||||
|
||||
<PrototypeSwitcher
|
||||
:variants="VARIANTS"
|
||||
:current="variant"
|
||||
@update:current="variant = $event"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
// PROTOTYPE VARIANT A — "Filter bar + featured lead".
|
||||
// The hero is gone: a slim header plus a sticky category filter bar take its
|
||||
// place, and the featured tutorial becomes a double-width lead card at the top
|
||||
// of the grid (it follows the active filter).
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import type { CategoryFilter, PrototypeTutorial } from './prototypeData'
|
||||
|
||||
import { getTutorialPosterSrc } from '../../../data/learningTutorials'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import Badge from '../../ui/badge/Badge.vue'
|
||||
import { ButtonMask } from '../../ui/button-mask'
|
||||
import TutorialDetailDialog from '../../learning/TutorialDetailDialog.vue'
|
||||
import {
|
||||
categoryLabel,
|
||||
categoryOptions,
|
||||
featuredFor,
|
||||
filterByCategory
|
||||
} from './prototypeData'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const category = defineModel<CategoryFilter>('category', { default: 'all' })
|
||||
|
||||
const featured = computed(() => featuredFor(category.value))
|
||||
const rest = computed(() =>
|
||||
filterByCategory(category.value).filter(
|
||||
(tutorial) => tutorial.id !== featured.value?.id
|
||||
)
|
||||
)
|
||||
const total = computed(() => filterByCategory(category.value).length)
|
||||
|
||||
const activeTutorial = ref<PrototypeTutorial | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 pt-16 lg:pt-20">
|
||||
<header class="flex flex-wrap items-end justify-between gap-6">
|
||||
<div>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
Learning
|
||||
</p>
|
||||
<h1
|
||||
class="mt-3 text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
Tutorials & workflows
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
{{ total }} tutorial{{ total === 1 ? '' : 's' }}
|
||||
</p>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="sticky top-0 z-30 mt-10 border-y border-white/10 bg-primary-comfy-ink/85 backdrop-blur-md"
|
||||
>
|
||||
<nav
|
||||
class="max-w-9xl mx-auto flex scrollbar-none items-center gap-3 overflow-x-auto px-6 py-4"
|
||||
aria-label="Category filter"
|
||||
>
|
||||
<button
|
||||
v-for="option in categoryOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:aria-pressed="category === option.value"
|
||||
class="shrink-0 cursor-pointer rounded-lg px-4 py-2 text-xs font-semibold tracking-wide whitespace-nowrap transition-colors"
|
||||
:class="
|
||||
category === option.value
|
||||
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
: 'bg-transparency-white-t4 text-primary-warm-gray hover:text-primary-comfy-canvas'
|
||||
"
|
||||
@click="category = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span class="ml-1 opacity-60">
|
||||
{{ filterByCategory(option.value).length }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section class="max-w-9xl mx-auto px-6 py-12 lg:py-16">
|
||||
<ul
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<!-- Featured lead card -->
|
||||
<li v-if="featured" class="md:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video w-full cursor-pointer overflow-hidden rounded-3xl text-left"
|
||||
:aria-label="`Featured: ${featured.title[locale]}`"
|
||||
@click="activeTutorial = featured"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(featured)"
|
||||
:poster="featured.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 bg-linear-to-t from-primary-comfy-ink/90 via-primary-comfy-ink/20 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col gap-3 p-6 lg:p-8"
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
<Badge variant="accent">Featured</Badge>
|
||||
<Badge variant="category">
|
||||
{{ categoryLabel(featured.category) }}
|
||||
</Badge>
|
||||
</span>
|
||||
<span
|
||||
class="text-xl font-light text-primary-comfy-canvas lg:text-3xl"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ featured.title[locale] }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="absolute top-6 right-6 flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Regular cards -->
|
||||
<li
|
||||
v-for="tutorial in rest"
|
||||
:key="tutorial.id"
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
|
||||
@click="activeTutorial = tutorial"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col space-y-3 p-4">
|
||||
<Badge variant="category">
|
||||
{{ categoryLabel(tutorial.category) }}
|
||||
</Badge>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3
|
||||
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<ButtonMask
|
||||
v-if="tutorial.href"
|
||||
as="a"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</ButtonMask>
|
||||
</div>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge>{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<TutorialDetailDialog
|
||||
v-if="activeTutorial"
|
||||
:tutorial="activeTutorial"
|
||||
:locale="locale"
|
||||
@close="activeTutorial = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
// PROTOTYPE VARIANT B — "Sidebar directory".
|
||||
// No hero at all: a persistent left sidebar carries the page title and the
|
||||
// category nav (with counts and blurbs), while the content column shows a
|
||||
// compact featured banner followed by a dense, scannable row list — a
|
||||
// directory, not a gallery.
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import type { CategoryFilter, PrototypeTutorial } from './prototypeData'
|
||||
|
||||
import { getTutorialPosterSrc } from '../../../data/learningTutorials'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import Badge from '../../ui/badge/Badge.vue'
|
||||
import { ButtonMask } from '../../ui/button-mask'
|
||||
import TutorialDetailDialog from '../../learning/TutorialDetailDialog.vue'
|
||||
import {
|
||||
categoryLabel,
|
||||
categoryOptions,
|
||||
featuredFor,
|
||||
filterByCategory
|
||||
} from './prototypeData'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const category = defineModel<CategoryFilter>('category', { default: 'all' })
|
||||
|
||||
const featured = computed(() => featuredFor(category.value))
|
||||
const rows = computed(() =>
|
||||
filterByCategory(category.value).filter(
|
||||
(tutorial) => tutorial.id !== featured.value?.id
|
||||
)
|
||||
)
|
||||
|
||||
const activeTutorial = ref<PrototypeTutorial | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-20">
|
||||
<div class="flex flex-col gap-10 lg:flex-row lg:gap-16">
|
||||
<!-- Sidebar -->
|
||||
<aside class="lg:w-72 lg:shrink-0">
|
||||
<div class="lg:sticky lg:top-10">
|
||||
<h1
|
||||
class="text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-4xl"
|
||||
>
|
||||
Learning
|
||||
</h1>
|
||||
<p class="text-primary-warm-gray mt-3 text-sm/relaxed">
|
||||
Hands-on ComfyUI tutorials and workflows, by discipline.
|
||||
</p>
|
||||
|
||||
<nav
|
||||
class="mt-8 flex scrollbar-none gap-3 overflow-x-auto lg:flex-col lg:overflow-visible"
|
||||
aria-label="Category filter"
|
||||
>
|
||||
<button
|
||||
v-for="option in categoryOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:aria-pressed="category === option.value"
|
||||
class="shrink-0 cursor-pointer rounded-xl px-4 py-3 text-left transition-colors lg:w-full"
|
||||
:class="
|
||||
category === option.value
|
||||
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
: 'bg-transparency-white-t4 text-primary-comfy-canvas hover:bg-white/10'
|
||||
"
|
||||
@click="category = option.value"
|
||||
>
|
||||
<span class="flex items-baseline justify-between gap-6">
|
||||
<span class="text-xs font-semibold tracking-wide uppercase">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs tabular-nums"
|
||||
:class="
|
||||
category === option.value
|
||||
? 'text-primary-comfy-ink/70'
|
||||
: 'text-primary-warm-gray'
|
||||
"
|
||||
>
|
||||
{{ filterByCategory(option.value).length }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="mt-1 hidden text-[11px]/snug lg:block"
|
||||
:class="
|
||||
category === option.value
|
||||
? 'text-primary-comfy-ink/70'
|
||||
: 'text-primary-warm-gray'
|
||||
"
|
||||
>
|
||||
{{ option.blurb }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content column -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Featured banner -->
|
||||
<article
|
||||
v-if="featured"
|
||||
class="bg-transparency-white-t4 rounded-4.5xl grid items-center gap-6 p-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-10 lg:p-8"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge variant="accent">Featured</Badge>
|
||||
<Badge variant="category">
|
||||
{{ categoryLabel(featured.category) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<h2
|
||||
class="text-2xl font-light tracking-tight text-primary-comfy-canvas lg:text-3xl"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ featured.title[locale] }}
|
||||
</h2>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in featured.tags" :key="tag">
|
||||
<Badge variant="subtle">{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="featured.href">
|
||||
<ButtonMask
|
||||
as="a"
|
||||
:href="featured.href"
|
||||
icon-position="right"
|
||||
variant="ghost"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</ButtonMask>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`Play ${featured.title[locale]}`"
|
||||
@click="activeTutorial = featured"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(featured)"
|
||||
:poster="featured.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<!-- Row list -->
|
||||
<ul class="mt-6 divide-y divide-white/10">
|
||||
<li
|
||||
v-for="tutorial in rows"
|
||||
:key="tutorial.id"
|
||||
class="flex flex-wrap items-center gap-4 py-5 lg:flex-nowrap lg:gap-6"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video w-36 shrink-0 cursor-pointer overflow-hidden rounded-2xl lg:w-44"
|
||||
:aria-label="`Play ${tutorial.title[locale]}`"
|
||||
@click="activeTutorial = tutorial"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-9 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm"
|
||||
>
|
||||
<svg
|
||||
class="ml-0.5 size-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<Badge variant="category" size="xs">
|
||||
{{ categoryLabel(tutorial.category) }}
|
||||
</Badge>
|
||||
<h3
|
||||
class="mt-1 text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<ul class="mt-2 flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge size="xs">{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ButtonMask
|
||||
v-if="tutorial.href"
|
||||
as="a"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</ButtonMask>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TutorialDetailDialog
|
||||
v-if="activeTutorial"
|
||||
:tutorial="activeTutorial"
|
||||
:locale="locale"
|
||||
@close="activeTutorial = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script setup lang="ts">
|
||||
// PROTOTYPE VARIANT C — "Spotlight hero + category shelves".
|
||||
// The hero earns its keep by merging with the featured element: headline on
|
||||
// the left, featured video playing inline on the right. Below, one horizontal
|
||||
// shelf per category (VFX / Animations / Ads); "View all" drills into a
|
||||
// single-category grid — this is the shape that maps most directly onto
|
||||
// /learning/vfx-style routes.
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import type { CategoryFilter, PrototypeTutorial } from './prototypeData'
|
||||
|
||||
import { getTutorialPosterSrc } from '../../../data/learningTutorials'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import Badge from '../../ui/badge/Badge.vue'
|
||||
import { ButtonMask } from '../../ui/button-mask'
|
||||
import VideoPlayer from '../../common/VideoPlayer.vue'
|
||||
import TutorialDetailDialog from '../../learning/TutorialDetailDialog.vue'
|
||||
import {
|
||||
categoryLabel,
|
||||
featuredFor,
|
||||
filterByCategory,
|
||||
realCategories
|
||||
} from './prototypeData'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const category = defineModel<CategoryFilter>('category', { default: 'all' })
|
||||
|
||||
const featured = computed(() => featuredFor('all'))
|
||||
const drilledIn = computed(() => category.value !== 'all')
|
||||
const drilledTutorials = computed(() => filterByCategory(category.value))
|
||||
const drilledLabel = computed(() =>
|
||||
category.value === 'all' ? '' : categoryLabel(category.value)
|
||||
)
|
||||
|
||||
const activeTutorial = ref<PrototypeTutorial | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Spotlight hero: headline + featured element merged -->
|
||||
<section class="max-w-9xl mx-auto px-6 pt-20 pb-8 lg:pt-24 lg:pb-12">
|
||||
<div class="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1
|
||||
class="text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span
|
||||
>{{ t('learning.heroTitle.after', locale) }}
|
||||
{{ t('learning.heroTitle.line2', locale) }}
|
||||
</h1>
|
||||
|
||||
<div v-if="featured" class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge variant="accent">Featured</Badge>
|
||||
<Badge variant="category">
|
||||
{{ categoryLabel(featured.category) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm/relaxed text-primary-comfy-canvas lg:text-base">
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ featured.title[locale] }}
|
||||
</p>
|
||||
<div v-if="featured.href">
|
||||
<ButtonMask
|
||||
as="a"
|
||||
:href="featured.href"
|
||||
icon-position="right"
|
||||
variant="ghost"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</ButtonMask>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="featured"
|
||||
class="border-primary-warm-gray rounded-4.5xl border p-4"
|
||||
>
|
||||
<VideoPlayer
|
||||
:locale
|
||||
:src="featured.videoSrc"
|
||||
:poster="featured.poster"
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Drilled-in single-category grid -->
|
||||
<section v-if="drilledIn" class="max-w-9xl mx-auto px-6 py-8 lg:py-12">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-warm-gray flex cursor-pointer items-center gap-2 text-xs font-semibold tracking-wide uppercase transition-colors hover:text-primary-comfy-canvas"
|
||||
@click="category = 'all'"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="15 6 9 12 15 18" />
|
||||
</svg>
|
||||
All categories
|
||||
</button>
|
||||
|
||||
<h2
|
||||
class="mt-4 mb-10 text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ drilledLabel }}
|
||||
<span class="text-primary-warm-gray text-xl lg:text-3xl">
|
||||
— {{ drilledTutorials.length }} tutorial{{
|
||||
drilledTutorials.length === 1 ? '' : 's'
|
||||
}}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<li
|
||||
v-for="tutorial in drilledTutorials"
|
||||
:key="tutorial.id"
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`Play ${tutorial.title[locale]}`"
|
||||
@click="activeTutorial = tutorial"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<Badge variant="category">
|
||||
{{ categoryLabel(tutorial.category) }}
|
||||
</Badge>
|
||||
<h3 class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug">
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Category shelves -->
|
||||
<template v-else>
|
||||
<section
|
||||
v-for="option in realCategories"
|
||||
:key="option.value"
|
||||
class="max-w-9xl mx-auto px-6 py-8 lg:py-10"
|
||||
>
|
||||
<header class="mb-6 flex items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2
|
||||
class="text-2xl font-light tracking-tight text-primary-comfy-canvas lg:text-4xl"
|
||||
>
|
||||
{{ option.label }}
|
||||
</h2>
|
||||
<p class="text-primary-warm-gray mt-1 text-sm">
|
||||
{{ option.blurb }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-comfy-yellow flex shrink-0 cursor-pointer items-center gap-2 text-xs font-semibold tracking-wide uppercase transition-opacity hover:opacity-80"
|
||||
@click="category = option.value"
|
||||
>
|
||||
View all {{ filterByCategory(option.value).length }}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<ul
|
||||
class="flex snap-x scrollbar-none gap-6 overflow-x-auto pb-2 lg:gap-8"
|
||||
>
|
||||
<li
|
||||
v-for="tutorial in filterByCategory(option.value)"
|
||||
:key="tutorial.id"
|
||||
class="bg-transparency-white-t4 flex w-72 shrink-0 snap-start flex-col gap-3 overflow-hidden rounded-3xl p-2 lg:w-80"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`Play ${tutorial.title[locale]}`"
|
||||
@click="activeTutorial = tutorial"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-12 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105"
|
||||
>
|
||||
<svg
|
||||
class="ml-0.5 size-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex flex-col gap-2 p-3">
|
||||
<Badge variant="category" size="xs">
|
||||
{{ categoryLabel(tutorial.category) }}
|
||||
</Badge>
|
||||
<h3 class="text-sm/snug text-primary-comfy-canvas">
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<TutorialDetailDialog
|
||||
v-if="activeTutorial"
|
||||
:tutorial="activeTutorial"
|
||||
:locale="locale"
|
||||
@close="activeTutorial = null"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
// PROTOTYPE — throwaway data layer for the /learning redesign prototype.
|
||||
// Assigns each existing tutorial a category (vfx | animations | ads) and picks
|
||||
// a featured item. In production the category would live on LearningTutorial
|
||||
// itself and drive /learning/vfx-style routes; here it's bolted on so the
|
||||
// variants have something real to filter.
|
||||
import type { LearningTutorial } from '../../../data/learningTutorials'
|
||||
|
||||
import { learningTutorials } from '../../../data/learningTutorials'
|
||||
|
||||
type LearningCategory = 'vfx' | 'animations' | 'ads'
|
||||
export type CategoryFilter = 'all' | LearningCategory
|
||||
|
||||
export type PrototypeTutorial = LearningTutorial & {
|
||||
category: LearningCategory
|
||||
}
|
||||
|
||||
// PROTOTYPE guesses — the real assignment is a content decision.
|
||||
const categoryById: Record<string, LearningCategory> = {
|
||||
cleanplate_walkthrough_v03: 'vfx',
|
||||
deaging_workflow_v03: 'animations',
|
||||
frame_adjustments_demo_v03: 'animations',
|
||||
mattes_and_utilities_v03: 'vfx',
|
||||
seedance_demo_comfyui_v03: 'ads',
|
||||
skyreplacement_smaller_v06: 'vfx'
|
||||
}
|
||||
|
||||
const prototypeTutorials: readonly PrototypeTutorial[] = learningTutorials.map(
|
||||
(tutorial) => ({
|
||||
...tutorial,
|
||||
category: categoryById[tutorial.id] ?? 'vfx'
|
||||
})
|
||||
)
|
||||
|
||||
// Same tutorial the current FeaturedWorkflowSection showcases.
|
||||
const FEATURED_ID = 'skyreplacement_smaller_v06'
|
||||
|
||||
interface CategoryOption {
|
||||
value: CategoryFilter
|
||||
label: string
|
||||
blurb: string
|
||||
}
|
||||
|
||||
export const categoryOptions: readonly CategoryOption[] = [
|
||||
{ value: 'all', label: 'All', blurb: 'Every tutorial and workflow' },
|
||||
{ value: 'vfx', label: 'VFX', blurb: 'Compositing, cleanup and shot work' },
|
||||
{
|
||||
value: 'animations',
|
||||
label: 'Animations',
|
||||
blurb: 'Motion, retiming and character'
|
||||
},
|
||||
{ value: 'ads', label: 'Ads', blurb: 'Product shots and campaign assets' }
|
||||
]
|
||||
|
||||
export const realCategories = categoryOptions.filter(
|
||||
(option): option is CategoryOption & { value: LearningCategory } =>
|
||||
option.value !== 'all'
|
||||
)
|
||||
|
||||
export const categoryLabel = (category: LearningCategory): string =>
|
||||
realCategories.find((option) => option.value === category)?.label ?? category
|
||||
|
||||
export const filterByCategory = (
|
||||
category: CategoryFilter
|
||||
): readonly PrototypeTutorial[] =>
|
||||
category === 'all'
|
||||
? prototypeTutorials
|
||||
: prototypeTutorials.filter((tutorial) => tutorial.category === category)
|
||||
|
||||
/** Featured item for the current filter — the global pick when visible, else the category's first. */
|
||||
export const featuredFor = (category: CategoryFilter): PrototypeTutorial => {
|
||||
const pool = filterByCategory(category)
|
||||
return pool.find((tutorial) => tutorial.id === FEATURED_ID) ?? pool[0]
|
||||
}
|
||||
25
apps/website/src/pages/learning-prototype.astro
Normal file
25
apps/website/src/pages/learning-prototype.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
// PROTOTYPE ROUTE — throwaway. Three redesign variants of the /learning page
|
||||
// (category filters vfx/animations/ads, featured element, category-labelled
|
||||
// cards), switchable via ?variant=a|b|c. The real /learning page is untouched.
|
||||
// Delete this file once a variant has been chosen and folded into /learning.
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import LearningRedesignPrototype from '../components/prototype/learning-redesign/LearningRedesignPrototype.vue'
|
||||
import CallToActionSection from '../components/common/CallToActionSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
import { externalLinks } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout title="Learning (Prototype) — Comfy">
|
||||
<LearningRedesignPrototype client:only="vue" />
|
||||
<CallToActionSection
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 89 KiB |
@@ -30,7 +30,6 @@
|
||||
"content": "content",
|
||||
"audioProgress": "Audio progress",
|
||||
"viewImageOfTotal": "View image {index} of {total}",
|
||||
"imageIndexOfTotal": "{index} / {total}",
|
||||
"viewVideoOfTotal": "View video {index} of {total}",
|
||||
"imageLightbox": "Image preview",
|
||||
"imagePreview": "Image preview - Use arrow keys to navigate between images",
|
||||
|
||||
@@ -17,7 +17,7 @@ const meta: Meta<typeof ImagePreview> = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Node output image preview. Controls (download, edit/mask, prev/next navigation) sit in a row below the image, kept off the media itself; arrow keys also navigate between images.'
|
||||
'Node output image preview with navigation dots, keyboard controls, and hover action buttons (download, remove, edit/mask).'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,10 +28,7 @@ const i18n = createI18n({
|
||||
editOrMaskImage: 'Edit or mask image',
|
||||
downloadImage: 'Download image',
|
||||
removeImage: 'Remove image',
|
||||
previousImage: 'Previous image',
|
||||
nextImage: 'Next image',
|
||||
viewImageOfTotal: 'View image {index} of {total}',
|
||||
imageIndexOfTotal: '{index} / {total}',
|
||||
imagePreview:
|
||||
'Image preview - Use arrow keys to navigate between images',
|
||||
errorLoadingImage: 'Error loading image',
|
||||
@@ -114,30 +111,26 @@ describe('ImagePreview', () => {
|
||||
screen.getByText('Calculating dimensions')
|
||||
})
|
||||
|
||||
it('shows prev/next navigation buttons for multiple images in gallery mode', async () => {
|
||||
it('shows navigation dots for multiple images in gallery mode', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
await switchToGallery(user)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Previous image' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Next image' })
|
||||
).toBeInTheDocument()
|
||||
const navigationDots = screen.getAllByRole('button', {
|
||||
name: /View image/
|
||||
})
|
||||
expect(navigationDots).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show prev/next navigation for a single image', () => {
|
||||
it('does not show navigation dots for single image', () => {
|
||||
renderImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Previous image' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Next image' })
|
||||
).not.toBeInTheDocument()
|
||||
const navigationDots = screen.queryAllByRole('button', {
|
||||
name: /View image/
|
||||
})
|
||||
expect(navigationDots).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not show mask/edit button for multiple images in gallery mode', async () => {
|
||||
@@ -195,7 +188,7 @@ describe('ImagePreview', () => {
|
||||
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
|
||||
})
|
||||
|
||||
it('switches images with the next button', async () => {
|
||||
it('switches images when navigation dots are clicked', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
await switchToGallery(user)
|
||||
@@ -206,7 +199,11 @@ describe('ImagePreview', () => {
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Next image' }))
|
||||
// Click second navigation dot
|
||||
const navigationDots = screen.getAllByRole('button', {
|
||||
name: /View image/
|
||||
})
|
||||
await user.click(navigationDots[1])
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
@@ -215,31 +212,25 @@ describe('ImagePreview', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps to the last image with the previous button', async () => {
|
||||
it('marks active navigation dot with aria-current', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
await switchToGallery(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Previous image' }))
|
||||
const navigationDots = screen.getAllByRole('button', {
|
||||
name: /View image/
|
||||
})
|
||||
|
||||
// First dot should be active
|
||||
expect(navigationDots[0]).toHaveAttribute('aria-current', 'true')
|
||||
expect(navigationDots[1]).not.toHaveAttribute('aria-current')
|
||||
|
||||
await user.click(navigationDots[1])
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
defaultProps.imageUrls[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the current image position in the counter', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
await switchToGallery(user)
|
||||
|
||||
screen.getByText('1 / 2')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Next image' }))
|
||||
await nextTick()
|
||||
|
||||
screen.getByText('2 / 2')
|
||||
// Second dot should now be active
|
||||
expect(navigationDots[0]).not.toHaveAttribute('aria-current')
|
||||
expect(navigationDots[1]).toHaveAttribute('aria-current', 'true')
|
||||
})
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
@@ -257,7 +248,11 @@ describe('ImagePreview', () => {
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 1 of 2')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Next image' }))
|
||||
// Switch to second image
|
||||
const navigationDots = screen.getAllByRole('button', {
|
||||
name: /View image/
|
||||
})
|
||||
await user.click(navigationDots[1])
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 2 of 2')
|
||||
@@ -437,7 +432,7 @@ describe('ImagePreview', () => {
|
||||
expect(mainImg).toHaveAttribute('src', defaultProps.imageUrls[1])
|
||||
})
|
||||
|
||||
it('shows back-to-grid button in gallery mode', async () => {
|
||||
it('shows back-to-grid button next to navigation dots', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
await switchToGallery(user)
|
||||
@@ -507,8 +502,9 @@ describe('ImagePreview', () => {
|
||||
container.querySelector('[aria-busy="true"]')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
// Advance to the next (identical) image
|
||||
await user.click(screen.getByRole('button', { name: 'Next image' }))
|
||||
// Click second navigation dot to cycle
|
||||
const dots = screen.getAllByRole('button', { name: /View image/ })
|
||||
await user.click(dots[1])
|
||||
await nextTick()
|
||||
|
||||
// Advance past the delayed loader timeout
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="image-preview relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
|
||||
class="image-preview group relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<!-- Grid View -->
|
||||
@@ -47,7 +47,7 @@
|
||||
<div
|
||||
v-if="viewMode === 'gallery'"
|
||||
ref="galleryPanelEl"
|
||||
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
|
||||
class="group/panel relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-roledescription="$t('g.imageGallery')"
|
||||
@@ -101,6 +101,44 @@
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover and focus) -->
|
||||
<div
|
||||
class="actions invisible absolute top-2 right-2 flex gap-1 group-focus-within/panel:visible group-hover/panel:visible"
|
||||
>
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
v-if="!hasMultipleImages && !imageError && !currentImageIsHdr"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.editOrMaskImage')"
|
||||
:aria-label="$t('g.editOrMaskImage')"
|
||||
@click="handleEditMask"
|
||||
>
|
||||
<i-comfy:mask class="size-4" />
|
||||
</button>
|
||||
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
v-if="!imageError"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.downloadImage')"
|
||||
:aria-label="$t('g.downloadImage')"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</button>
|
||||
|
||||
<!-- Back to Grid Button -->
|
||||
<button
|
||||
v-if="hasMultipleImages"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.viewGrid')"
|
||||
:aria-label="$t('g.viewGrid')"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions (gallery mode only) -->
|
||||
@@ -123,71 +161,35 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Controls (below image, kept off the media) -->
|
||||
<!-- Multiple Images Navigation (gallery mode only) -->
|
||||
<div
|
||||
v-if="viewMode === 'gallery' && (hasMultipleImages || !imageError)"
|
||||
class="flex items-center justify-between gap-2 pt-2"
|
||||
v-if="viewMode === 'gallery' && hasMultipleImages"
|
||||
class="flex flex-wrap items-center justify-center gap-1 pt-4"
|
||||
>
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="hasMultipleImages"
|
||||
:class="controlButtonClass"
|
||||
:title="$t('g.viewGrid')"
|
||||
:aria-label="$t('g.viewGrid')"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!hasMultipleImages && !imageError && !currentImageIsHdr"
|
||||
:class="controlButtonClass"
|
||||
:title="$t('g.editOrMaskImage')"
|
||||
:aria-label="$t('g.editOrMaskImage')"
|
||||
@click="handleEditMask"
|
||||
>
|
||||
<i-comfy:mask class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!imageError"
|
||||
:class="controlButtonClass"
|
||||
:title="$t('g.downloadImage')"
|
||||
:aria-label="$t('g.downloadImage')"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Back to Grid button -->
|
||||
<button
|
||||
class="mr-1 flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-base-foreground/50 transition-colors hover:text-base-foreground"
|
||||
:title="$t('g.viewGrid')"
|
||||
:aria-label="$t('g.viewGrid')"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-3.5" />
|
||||
</button>
|
||||
|
||||
<!-- Previous / Next navigation -->
|
||||
<div v-if="hasMultipleImages" class="flex items-center gap-1">
|
||||
<button
|
||||
:class="controlButtonClass"
|
||||
:title="$t('g.previousImage')"
|
||||
:aria-label="$t('g.previousImage')"
|
||||
@click="goToPrevious"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left] size-4" />
|
||||
</button>
|
||||
<span
|
||||
class="min-w-10 text-center text-xs text-base-foreground/70 tabular-nums"
|
||||
>
|
||||
{{
|
||||
$t('g.imageIndexOfTotal', {
|
||||
index: currentIndex + 1,
|
||||
total: imageUrls.length
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
:class="controlButtonClass"
|
||||
:title="$t('g.nextImage')"
|
||||
:aria-label="$t('g.nextImage')"
|
||||
@click="goToNext"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Navigation Dots -->
|
||||
<button
|
||||
v-for="(_, index) in imageUrls"
|
||||
:key="index"
|
||||
:class="getNavigationDotClass(index)"
|
||||
:aria-current="index === currentIndex ? 'true' : undefined"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: imageUrls.length
|
||||
})
|
||||
"
|
||||
@click="setCurrentIndex(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -208,6 +210,7 @@ import type { NodeId } from '@/types/nodeId'
|
||||
import { isHdrImageUrl } from '@/utils/hdrFormatUtil'
|
||||
import { getGridThumbnailUrl } from '@/utils/imageUtil'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
@@ -223,8 +226,8 @@ const maskEditor = useMaskEditor()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const controlButtonClass =
|
||||
'flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-0 bg-transparent text-base-foreground/60 transition-colors duration-200 hover:bg-base-foreground/10 hover:text-base-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground'
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
|
||||
type ViewMode = 'gallery' | 'grid'
|
||||
|
||||
@@ -353,18 +356,6 @@ function setCurrentIndex(index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
setCurrentIndex(
|
||||
currentIndex.value > 0 ? currentIndex.value - 1 : imageUrls.length - 1
|
||||
)
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
setCurrentIndex(
|
||||
currentIndex.value < imageUrls.length - 1 ? currentIndex.value + 1 : 0
|
||||
)
|
||||
}
|
||||
|
||||
async function openImageInGallery(index: number) {
|
||||
setCurrentIndex(index)
|
||||
viewMode.value = 'gallery'
|
||||
@@ -381,6 +372,15 @@ function handleGridClick(index: number) {
|
||||
void openImageInGallery(index)
|
||||
}
|
||||
|
||||
function getNavigationDotClass(index: number) {
|
||||
return cn(
|
||||
'size-2 cursor-pointer rounded-full border-0 p-0 transition-all duration-200',
|
||||
index === currentIndex.value
|
||||
? 'bg-base-foreground'
|
||||
: 'bg-base-foreground/50 hover:bg-base-foreground/80'
|
||||
)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
@@ -397,11 +397,15 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault()
|
||||
goToPrevious()
|
||||
setCurrentIndex(
|
||||
currentIndex.value > 0 ? currentIndex.value - 1 : imageUrls.length - 1
|
||||
)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
goToNext()
|
||||
setCurrentIndex(
|
||||
currentIndex.value < imageUrls.length - 1 ? currentIndex.value + 1 : 0
|
||||
)
|
||||
break
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
|
||||
Reference in New Issue
Block a user