Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
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.
2026-10-22 19:07:20 -07:00

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>
)
}