mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
Every import, the catalog pin and the docs name @hanzo/app and github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and components/rooms.tsx is components/app.tsx.
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useMemo, useCallback, type ReactNode } from 'react'
|
|
import { Carousel, CarouselContent, CarouselItem, Text, View, XStack, YStack, type CarouselApi } from '@hanzo/ui'
|
|
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react'
|
|
import { GUTTER_X } from './band'
|
|
import { ready, useTemplates } from '@/lib/api/use'
|
|
import type { Template } from '@/lib/api/read'
|
|
import { Loading, Unreachable } from '@/components/api/state'
|
|
import wires from '@/lib/catalog-shots.json'
|
|
import { Lights } from '@hanzo/app/bots/Lights'
|
|
import { Action } from '@hanzo/ui/marketing'
|
|
|
|
/**
|
|
* Wireframe catalog shots, plus pages that still read as a template marketplace
|
|
* rather than a finished product: placeholder portraits, repeated dummy rows,
|
|
* "logoipsum", broken grammar, or a "Get this template" chrome.
|
|
*/
|
|
const HIDDEN = new Set<string>([
|
|
...wires,
|
|
'construct',
|
|
'saas-landing',
|
|
'soar',
|
|
'unity',
|
|
'launch',
|
|
'mint',
|
|
'deploy',
|
|
'folio',
|
|
'cipher',
|
|
'forge',
|
|
'circle',
|
|
])
|
|
|
|
const PRIORITY_SLUGS = [
|
|
'studio',
|
|
'oasis',
|
|
'vault',
|
|
'drive',
|
|
'savor',
|
|
'prism',
|
|
'kinetic',
|
|
'portfolio',
|
|
'pixel',
|
|
'hygge',
|
|
'solo',
|
|
'loop',
|
|
]
|
|
|
|
|
|
/**
|
|
* The catalogue as a carousel, on @hanzo/ui's `Carousel`: the browser pages it
|
|
* (scroll-snap), the package advances it every 4.2s and holds it while a
|
|
* pointer or focus is inside it — and never starts under reduced motion. This
|
|
* file adds the cards, the category filter, the two round turns and the dots.
|
|
*
|
|
* The templates are the catalog's (GET /v1/templates), read when the page opens
|
|
* (lib/api); until they are, it says so, and if the backend does not answer,
|
|
* it says that.
|
|
*/
|
|
export default function Templates() {
|
|
const reading = useTemplates()
|
|
if (reading.state === 'loading') return <Loading what="the templates" />
|
|
if (reading.state === 'unreachable') return <Unreachable what="the templates" />
|
|
return <Shelf all={reading.data} />
|
|
}
|
|
|
|
function Shelf({ all }: { all: Template[] }) {
|
|
const [selectedCategory, setSelectedCategory] = useState('All')
|
|
const [activeIndex, setActiveIndex] = useState(0)
|
|
const [api, setApi] = useState<CarouselApi | null>(null)
|
|
|
|
// Order templates: priority flagship templates first, then remaining
|
|
const allTemplates = useMemo(() => {
|
|
const map = new Map(all.filter((t) => !HIDDEN.has(t.slug)).map((t) => [t.slug, t]))
|
|
const featured: Template[] = []
|
|
for (const slug of PRIORITY_SLUGS) {
|
|
const t = map.get(slug)
|
|
if (t) {
|
|
featured.push(t)
|
|
map.delete(slug)
|
|
}
|
|
}
|
|
return [...featured, ...Array.from(map.values())]
|
|
}, [all])
|
|
|
|
const categories = useMemo(() => {
|
|
const names = [...new Set(allTemplates.map((t) => t.category))].sort((a, b) => a.localeCompare(b, 'en'))
|
|
return ['All', ...names]
|
|
}, [allTemplates])
|
|
|
|
const templates = useMemo(() => {
|
|
if (selectedCategory === 'All') return allTemplates
|
|
return allTemplates.filter((t) => t.category === selectedCategory)
|
|
}, [allTemplates, selectedCategory])
|
|
|
|
// A new category opens on its first card, placed there rather than scrolled to.
|
|
useEffect(() => {
|
|
api?.scrollTo(0, true)
|
|
setActiveIndex(0)
|
|
}, [api, selectedCategory])
|
|
|
|
const go = useCallback((i: number) => api?.scrollTo(i), [api])
|
|
|
|
return (
|
|
<Carousel
|
|
group
|
|
aria-label="Product templates"
|
|
options={{ loop: true, autoplay: 4200 }}
|
|
setApi={setApi}
|
|
onCarouselSelect={(a) => setActiveIndex(a.selectedScrollSnap())}
|
|
position="relative"
|
|
overflow="hidden"
|
|
width="100%"
|
|
mt="$2"
|
|
mb="$3"
|
|
>
|
|
{/* Category filter pills */}
|
|
<XStack
|
|
role="tablist"
|
|
aria-label="Template categories"
|
|
justify="center"
|
|
items="center"
|
|
flexWrap="wrap"
|
|
gap="$2"
|
|
maxW={860}
|
|
mx="auto"
|
|
mb={28}
|
|
px={GUTTER_X}
|
|
>
|
|
{categories.map((cat) => {
|
|
const on = selectedCategory === cat
|
|
return (
|
|
<Text
|
|
key={cat}
|
|
render={<button type="button" />}
|
|
role="tab"
|
|
aria-selected={on}
|
|
onClick={() => setSelectedCategory(cat)}
|
|
px={14}
|
|
py={6}
|
|
rounded="$10"
|
|
borderWidth={1}
|
|
borderColor={on ? '$ink' : '$edge'}
|
|
bg={on ? '$ink' : '$panel'}
|
|
fontFamily="$mono"
|
|
fontSize="$1"
|
|
fontWeight={on ? '500' : '400'}
|
|
letterSpacing={0.66}
|
|
color={on ? 'var(--background)' : 'var(--muted-foreground)'}
|
|
cursor="pointer"
|
|
hoverStyle={on ? undefined : { borderColor: '$rim', color: '$ink' }}
|
|
>
|
|
{cat}
|
|
</Text>
|
|
)
|
|
})}
|
|
</XStack>
|
|
|
|
{/* 3-up Preview Viewport */}
|
|
<YStack position="relative" width="100%">
|
|
<Turn label="Previous template" side="l" onClick={() => api?.scrollPrev()}>
|
|
<ChevronLeft size={24} strokeWidth={2.4} color="var(--pure-black)" />
|
|
</Turn>
|
|
<Turn label="Next template" side="r" onClick={() => api?.scrollNext()}>
|
|
<ChevronRight size={24} strokeWidth={2.4} color="var(--pure-black)" />
|
|
</Turn>
|
|
|
|
{/* The scroller: a card a column, the first and last able to reach the
|
|
middle because the row is padded by half of what a card leaves. */}
|
|
<CarouselContent
|
|
role="list"
|
|
aria-label="Product templates"
|
|
gridAutoColumns={CARD}
|
|
gap={GAP}
|
|
items="center"
|
|
pt="$4"
|
|
pb="$5"
|
|
px={`calc((100% - ${CARD}) / 2)`}
|
|
$max-md={{ gridAutoColumns: CARD_PHONE, gap: 12, px: `calc((100% - ${CARD_PHONE}) / 2)` }}
|
|
>
|
|
{templates.map((t, idx) => {
|
|
const isActive = idx === activeIndex
|
|
return (
|
|
<CarouselItem
|
|
key={t.slug}
|
|
role="listitem"
|
|
data-state={isActive ? 'active' : 'inactive'}
|
|
onClick={() => {
|
|
if (!isActive) go(idx)
|
|
}}
|
|
rounded="var(--radius-xl)"
|
|
borderWidth={1}
|
|
borderColor="$edge"
|
|
bg="$panel"
|
|
overflow="hidden"
|
|
select="none"
|
|
transition="slow"
|
|
{...(isActive ? ACTIVE : RESTING)}
|
|
>
|
|
{/* Browser chrome header */}
|
|
<XStack items="center" justify="space-between" px="$4" py="$2.5" bg="var(--white-03)" borderBottomWidth={1} borderColor="$edge">
|
|
<Lights />
|
|
<Text
|
|
fontFamily="$mono"
|
|
fontSize="$1"
|
|
color="var(--text-tertiary)"
|
|
bg="var(--white-05)"
|
|
px={14}
|
|
py={3}
|
|
rounded="$10"
|
|
borderWidth={1}
|
|
borderColor="var(--white-06)"
|
|
>
|
|
{t.slug}.hanzo.app
|
|
</Text>
|
|
<Text fontFamily="$mono" fontSize="$1" color="var(--text-tertiary)" bg="$hover" px="$2" py={2} rounded={4}>
|
|
{t.category}
|
|
</Text>
|
|
</XStack>
|
|
|
|
{/* 16:10 preview, the shot's own ratio, so it fills without cropping. */}
|
|
<View render={<a href={`/chat?template=${encodeURIComponent(t.slug)}`} tabIndex={isActive ? 0 : -1} />} display="block">
|
|
<View position="relative" width="100%" aspectRatio={16 / 10} overflow="hidden" bg="var(--neutral-950)">
|
|
<View
|
|
render={
|
|
<img
|
|
src={`/templates/${t.slug}.webp`}
|
|
alt={`${t.title} template preview`}
|
|
width={1280}
|
|
height={800}
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
}
|
|
display="block"
|
|
width="100%"
|
|
height="100%"
|
|
objectFit="cover"
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Card footer details */}
|
|
<XStack items="center" justify="space-between" gap="$4" px={22} py="$4" bg="$panel">
|
|
<YStack flex={1} minW={0} gap="$1">
|
|
<Text render="h3" fontSize="$6" lineHeight="$6" fontWeight="600" color="$ink" text="left">
|
|
{t.title}
|
|
</Text>
|
|
{/* A phone has a line for the name and the action, not a description. */}
|
|
<Text render="p" fontSize="$2" color="var(--muted-foreground)" text="left" numberOfLines={1} $max-md={{ display: 'none' }}>
|
|
{t.description}
|
|
</Text>
|
|
</YStack>
|
|
<Action href={`/chat?template=${encodeURIComponent(t.slug)}`} fill tabIndex={isActive ? 0 : -1}>
|
|
<span>Edit in Hanzo</span>
|
|
<ArrowRight size={14} />
|
|
</Action>
|
|
</XStack>
|
|
</CarouselItem>
|
|
)
|
|
})}
|
|
</CarouselContent>
|
|
</YStack>
|
|
|
|
{/* Where the row is: a counter, and a dot per card for the first ten,
|
|
the current one drawn long. Dots in the page's own ink, so they read
|
|
on either ground; a target narrow enough that ten of them and the
|
|
counter fit a 360px phone even under a thumb. */}
|
|
<XStack items="center" justify="center" gap="$4" mt={18}>
|
|
<Text fontFamily="$mono" fontSize="$1" letterSpacing={0.88} color="var(--text-tertiary)">
|
|
{String(activeIndex + 1).padStart(2, '0')} / {String(templates.length).padStart(2, '0')}
|
|
</Text>
|
|
<XStack items="center">
|
|
{templates.slice(0, 10).map((_, i) => {
|
|
const on = i === activeIndex % 10
|
|
return (
|
|
<View
|
|
key={i}
|
|
render={<button type="button" />}
|
|
aria-label={`Go to slide ${i + 1}`}
|
|
aria-current={on ? 'true' : undefined}
|
|
onClick={() => go(i)}
|
|
group
|
|
width={24}
|
|
height={24}
|
|
items="center"
|
|
justify="center"
|
|
cursor="pointer"
|
|
$touchable={{ width: 28, height: 44 }}
|
|
>
|
|
<View
|
|
width={on ? 18 : 6}
|
|
height={6}
|
|
rounded="var(--radius-full)"
|
|
bg={on ? '$ink' : 'var(--text-tertiary)'}
|
|
transition="quickest"
|
|
$group-hover={on ? undefined : { bg: 'var(--muted-foreground)' }}
|
|
/>
|
|
</View>
|
|
)
|
|
})}
|
|
</XStack>
|
|
</XStack>
|
|
</Carousel>
|
|
)
|
|
}
|
|
|
|
/** A card's width: a third of a wide screen and most of a laptop's, one card a
|
|
* phone at a time with a sliver of the next on either side.
|
|
*
|
|
* "A phone" is `$max-md`, under 768. The rule this replaced was
|
|
* `max-width: 640px`, so 640 itself was a phone; gui's `sm` starts AT 640 and
|
|
* its next rung is md, so the first boundary that keeps 640 a phone is 768. */
|
|
const CARD = 'max(340px, min(56vw, 840px))'
|
|
const CARD_PHONE = 'min(340px, calc(100vw - 36px))'
|
|
const GAP = 'max(16px, min(2.5vw, 32px))'
|
|
|
|
/** The card in front, and the ones either side of it, stepped back. */
|
|
const ACTIVE = {
|
|
scale: 1,
|
|
opacity: 1,
|
|
filter: 'brightness(1)',
|
|
z: 5,
|
|
boxShadow: 'var(--shadow-floating)',
|
|
} as const
|
|
const RESTING = {
|
|
scale: 0.91,
|
|
opacity: 0.52,
|
|
filter: 'brightness(0.78)',
|
|
z: 2,
|
|
cursor: 'pointer',
|
|
hoverStyle: { scale: 0.93, opacity: 0.78, filter: 'brightness(0.92)' },
|
|
} as const
|
|
|
|
/** Next and previous: round, white, over the middle of the row. They show
|
|
* while the pointer is over the carousel or a key has reached them, and
|
|
* always on a phone, where there is no hover to wait for. */
|
|
function Turn({ label, side, onClick, children }: { label: string; side: 'l' | 'r'; onClick: () => void; children: ReactNode }) {
|
|
return (
|
|
<View
|
|
render={<button type="button" />}
|
|
aria-label={label}
|
|
onClick={onClick}
|
|
position="absolute"
|
|
t="calc(50% - 10px)"
|
|
mt={-26}
|
|
{...(side === 'l' ? { l: 'max(16px, min(4vw, 48px))' } : { r: 'max(16px, min(4vw, 48px))' })}
|
|
z="var(--z-raised)"
|
|
width={52}
|
|
height={52}
|
|
rounded="$10"
|
|
borderWidth={0}
|
|
bg="var(--pure-white)"
|
|
items="center"
|
|
justify="center"
|
|
cursor="pointer"
|
|
boxShadow="var(--shadow-lg)"
|
|
opacity={0}
|
|
pointerEvents="none"
|
|
transition="quick"
|
|
hoverStyle={{ scale: 1.1, boxShadow: 'var(--shadow-xl)' }}
|
|
focusVisibleStyle={{ opacity: 1, pointerEvents: 'auto' }}
|
|
$group-hover={{ opacity: 1, pointerEvents: 'auto' }}
|
|
$max-md={{
|
|
opacity: 0.85,
|
|
pointerEvents: 'auto',
|
|
width: 42,
|
|
height: 42,
|
|
mt: -21,
|
|
...(side === 'l' ? { l: 6 } : { r: 6 }),
|
|
}}
|
|
>
|
|
{children}
|
|
</View>
|
|
)
|
|
}
|