mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
4 Commits
v1.47.8
...
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>
|
||||
Reference in New Issue
Block a user