Files
gallery/app/templates/[slug]/TemplatePageClient.tsx
zeekay 18645b2307 the face is Zen, and it ships from @hanzo/font
One family. Font stacks name Zen and fall back to the native faces, tokens
name the role rather than a face, and the packages that used to supply a
face now point at @hanzo/font.

Self-hosted @font-face blocks are removed rather than renamed: rewriting the
family in one would leave a src pointing at a file this repo no longer ships.
@hanzo/font carries the binaries and declares the faces, so the local copies
go with the declaration that used them.

Comments naming a face we no longer use are deleted, not reworded.
2026-09-01 13:15:26 -07:00

581 lines
19 KiB
TypeScript

'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { YStack, XStack, H1, H2, H3, Text, Button, Anchor, Label } from '@hanzo/ui';
import { Grid } from '@hanzo/ui/grid';
import { frame, shot, sizes, type Size } from '../../lib/shot';
import type { Template } from '../../templates-data';
import { c, t, at, clip, hue, tint } from '../../lib/design';
import { Stars } from '../../components/stars';
interface TemplatePageClientProps {
variants: Template[];
prevTemplate: Template | null;
nextTemplate: Template | null;
currentIndex: number;
totalTemplates: number;
allTemplates: Template[];
}
/** The pill in the action bar, in its two states. */
const pill = {
transition: 'quickest',
height: 'auto',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 9999,
borderWidth: 1,
} as const;
const outline = {
...pill,
borderColor: c.neutral700,
backgroundColor: 'transparent',
hoverStyle: { backgroundColor: c.neutral800 },
} as const;
const still = {
...pill,
borderColor: c.neutral800,
backgroundColor: c.neutral900,
} as const;
/** A choice among framework variants, or among screenshot sizes. */
function Choice({ on, children, ...rest }: { on: boolean; children: React.ReactNode; [k: string]: unknown }) {
return (
<Button
transition="quickest"
height="auto"
paddingHorizontal={24}
paddingVertical={12}
borderRadius="var(--radius-lg, 0.75rem)"
borderWidth={2}
{...(on
? { backgroundColor: c.blue500, borderColor: c.blue400 }
: {
backgroundColor: c.white5,
borderColor: c.white10,
hoverStyle: { borderColor: at(c.blue500, 0.5), backgroundColor: 'rgba(255,255,255,0.1)' },
})}
{...rest}
>
<Text fontWeight={500} color={on ? '#fff' : c.gray300} textTransform="capitalize">
{children}
</Text>
</Button>
);
}
/** A page section: centred column, optional lift off the ground. */
function Band({ children, lifted }: { children: React.ReactNode; lifted?: boolean }) {
return (
<YStack
render="section"
paddingHorizontal={16}
paddingVertical={64}
{...(lifted ? { backgroundColor: c.white5 } : {})}
>
<YStack width="100%" maxWidth={1024} marginLeft="auto" marginRight="auto">
{children}
</YStack>
</YStack>
);
}
const mono = { fontFamily: 'var(--font-mono), monospace' } as const;
export function TemplatePageClient({
variants,
prevTemplate,
nextTemplate,
currentIndex,
totalTemplates,
allTemplates,
}: TemplatePageClientProps) {
const router = useRouter();
const [pick, setPick] = useState<Template>(variants[0]);
const [size, setSize] = useState<Size>('desktop');
const [absent, setAbsent] = useState<Set<string>>(new Set());
// Canonical repo URL for the selected variant (single source of truth).
const repo = `https://github.com/hanzo-apps/template-${pick.slug}`;
const deployUrl = `https://hanzo.app/new?template=${encodeURIComponent(repo)}`;
// Templates without a capture at this size fall back to the desktop one.
const src = shot(pick.screenshot, size);
const shown = absent.has(src) ? shot(pick.screenshot) : src;
function toRandom() {
const others = allTemplates.filter((x) => x.slug !== variants[0].slug);
if (others.length > 0) router.push(`/templates/${others[Math.floor(Math.random() * others.length)].slug}`);
}
const openRepo = () => window.open(repo, '_blank');
const toFork = () => {
window.location.href = `/gallery?fork=${pick.id}`;
};
const copyPath = (said: string) => {
navigator.clipboard.writeText(pick.path);
alert(said);
};
const start = pick.framework.toLowerCase().includes('html')
? 'gulp'
: pick.framework.toLowerCase().includes('react') && !pick.framework.toLowerCase().includes('next')
? 'npm start'
: 'npm run dev';
return (
<YStack minHeight="100vh" backgroundColor={c.ink}>
<YStack
render="nav"
position="sticky"
top={0}
zIndex={50}
backgroundColor="#000"
borderBottomWidth={1}
borderColor={c.neutral800}
>
<YStack borderBottomWidth={1} borderColor={c.neutral800}>
<XStack
width="100%"
maxWidth={1280}
marginLeft="auto"
marginRight="auto"
paddingHorizontal={16}
paddingVertical={12}
alignItems="center"
justifyContent="space-between"
>
<Anchor
render={<Link href="/gallery" />}
transition="quickest"
alignItems="center"
gap={8}
{...t.sm}
color={c.neutral400}
textDecorationLine="none"
hoverStyle={{ color: '#fff' }}
>
← Gallery
</Anchor>
<XStack alignItems="center" gap={12}>
<Text {...t.xs} color={c.neutral500} fontWeight={500}>
{currentIndex} / {totalTemplates}
</Text>
<XStack gap={8}>
{prevTemplate ? (
<Button {...outline} render={<Link href={`/templates/${prevTemplate.slug}`} />} paddingVertical={6} paddingHorizontal={12}>
<Text {...t.xs} fontWeight={500} color={c.neutral300}>
←
</Text>
</Button>
) : (
<Button {...pill} disabled borderColor={c.neutral800} backgroundColor="transparent" paddingVertical={6} paddingHorizontal={12} cursor="not-allowed">
<Text {...t.xs} color={c.neutral700}>
←
</Text>
</Button>
)}
<Button
{...outline}
onPress={toRandom}
title="Random Template"
paddingVertical={6}
paddingHorizontal={12}
hoverStyle={{ backgroundColor: c.neutral800, borderColor: at(c.purple500, 0.5) }}
>
<Text {...t.xs} fontWeight={500} color={c.neutral300}>
🎲
</Text>
</Button>
{nextTemplate ? (
<Button {...outline} render={<Link href={`/templates/${nextTemplate.slug}`} />} paddingVertical={6} paddingHorizontal={12}>
<Text {...t.xs} fontWeight={500} color={c.neutral300}>
→
</Text>
</Button>
) : (
<Button {...pill} disabled borderColor={c.neutral800} backgroundColor="transparent" paddingVertical={6} paddingHorizontal={12} cursor="not-allowed">
<Text {...t.xs} color={c.neutral700}>
→
</Text>
</Button>
)}
</XStack>
</XStack>
</XStack>
</YStack>
{/* Actions */}
<XStack
width="100%"
maxWidth={1280}
marginLeft="auto"
marginRight="auto"
paddingHorizontal={16}
paddingVertical={8}
overflow="scroll"
data-scrollbar="none"
style={{ scrollbarWidth: 'none' }}
>
<XStack gap={8} minWidth="max-content" alignItems="center">
<Button {...outline} onPress={openRepo}>
<Text {...t.xs} fontWeight={500} color={c.neutral300} whiteSpace="nowrap">
📦 View on GitHub
</Text>
</Button>
<Button {...outline} onPress={toFork}>
<Text {...t.xs} fontWeight={500} color={c.neutral300} whiteSpace="nowrap">
🚀 Deploy
</Text>
</Button>
<Anchor
href={deployUrl}
target="_blank"
rel="noopener noreferrer"
alignItems="center"
whiteSpace="nowrap"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="https://hanzo.app/deploy-badge.svg" alt="Deploy on Hanzo" height={36} style={{ height: 36 }} />
</Anchor>
<Button {...outline} onPress={() => copyPath('Path copied!')}>
<Text {...t.xs} fontWeight={500} color={c.neutral300} whiteSpace="nowrap">
📋 Copy Path
</Text>
</Button>
<Text {...still} {...t.xs} fontWeight={500} color={c.neutral400} whiteSpace="nowrap">
{pick.framework}
</Text>
<Text {...still} {...t.xs} fontWeight={500} color={c.neutral400} whiteSpace="nowrap">
{pick.category}
</Text>
<Text
{...pill}
{...t.xs}
fontWeight={500}
whiteSpace="nowrap"
{...deep(pick.tier)}
>
Tier {pick.tier}
</Text>
</XStack>
</XStack>
</YStack>
{/* Hero */}
<Band>
<XStack alignItems="center" gap={12} marginBottom={24} flexWrap="wrap">
<H1 {...t.xl6} fontWeight={700} {...clip(c.wash)}>
{pick.displayName}
</H1>
<Stars n={pick.rating} size={t.xl2} />
</XStack>
<Text {...t.xl2} color={c.gray400} marginBottom={32}>
{pick.description || `Premium ${pick.displayName} template with modern design and functionality.`}
</Text>
{variants.length > 1 && (
<YStack marginBottom={32}>
<Label {...t.sm} fontWeight={500} color={c.gray400} marginBottom={12}>
Choose Framework ({variants.length} variants available)
</Label>
<XStack flexWrap="wrap" gap={12}>
{variants.map((v) => (
<Choice key={v.id} on={pick.id === v.id} onPress={() => setPick(v)}>
{v.framework}
</Choice>
))}
</XStack>
</YStack>
)}
{/* Tech stack */}
<XStack flexWrap="wrap" gap={12} marginBottom={48}>
<Chip {...tint('blue')}>{pick.framework}</Chip>
<Chip {...tint(hue(pick.tier))}>
Tier {pick.tier} - {pick.tier === 1 ? 'Excellent' : pick.tier === 2 ? 'Very Good' : 'Good'}
</Chip>
<Chip {...tint('purple')}>{pick.components}</Chip>
<Chip backgroundColor={at(c.gray500, 0.2)} borderColor={at(c.gray500, 0.3)} color={c.gray300}>
{pick.category}
</Chip>
</XStack>
{/* Screenshot */}
<YStack marginBottom={64}>
<XStack flexWrap="wrap" gap={12} marginBottom={16}>
{sizes.map((s) => (
<Choice key={s} on={size === s} onPress={() => setSize(s)}>
{s}
</Choice>
))}
</XStack>
<YStack
position="relative"
marginLeft="auto"
marginRight="auto"
{...frame[size]}
borderRadius="var(--radius-2xl, 1.5rem)"
overflow="hidden"
borderWidth={1}
borderColor={c.white10}
boxShadow={`0 25px 50px ${at(c.blue500, 0.2)}`}
>
<Image
src={shown}
alt={pick.displayName}
fill
unoptimized
onError={() => setAbsent((prev) => new Set(prev).add(src))}
style={{ objectFit: shown === src ? 'cover' : 'contain' }}
priority
/>
</YStack>
</YStack>
</Band>
{/* Features */}
<Band lifted>
<H2 {...t.xl4} fontWeight={700} color="#fff" marginBottom={32}>
Key Features
</H2>
<Grid columns={{ min: 300, max: 2 }} gap={24}>
{pick.features.map((feature, i) => (
<YStack
key={i}
transition="quickest"
backgroundColor={c.white5}
backdropFilter="blur(16px)"
padding={24}
borderRadius="var(--radius-xl, 1rem)"
borderWidth={1}
borderColor={c.white10}
hoverStyle={{ borderColor: at(c.blue500, 0.5) }}
>
<Text {...t.xl3} marginBottom={12}>
✨
</Text>
<H3 {...t.xl} fontWeight={700} color="#fff" marginBottom={8}>
{feature}
</H3>
</YStack>
))}
</Grid>
</Band>
{/* Technology */}
<Band>
<H2 {...t.xl4} fontWeight={700} color="#fff" marginBottom={32}>
Technology Stack
</H2>
<YStack
backgroundImage={`linear-gradient(to right, ${at(c.blue500, 0.1)}, ${at(c.purple500, 0.1)})`}
backdropFilter="blur(16px)"
padding={32}
borderRadius="var(--radius-2xl, 1.5rem)"
borderWidth={1}
borderColor={c.white10}
>
<Grid columns={{ min: 200, max: 3 }} gap={24}>
{(
[
['Framework', pick.framework],
['Use Case', pick.useCase],
[
'Setup Difficulty',
`${pick.easeOfSetup}/5 - ${pick.easeOfSetup >= 5 ? 'Very Easy' : pick.easeOfSetup >= 4 ? 'Easy' : 'Moderate'}`,
],
] as [string, string][]
).map(([head, body]) => (
<YStack key={head}>
<H3 {...t.lg} fontWeight={700} color={c.blue400} marginBottom={8}>
{head}
</H3>
<Text color={c.gray300}>{body}</Text>
</YStack>
))}
</Grid>
</YStack>
</Band>
{/* Quick start */}
<Band lifted>
<H2 {...t.xl4} fontWeight={700} color="#fff" marginBottom={32}>
Quick Start
</H2>
<YStack
backgroundColor="rgba(0,0,0,0.5)"
backdropFilter="blur(16px)"
padding={32}
borderRadius="var(--radius-2xl, 1.5rem)"
borderWidth={1}
borderColor={c.white10}
{...mono}
>
{(
[
['# Navigate to template directory', `cd ${pick.path}`],
['# Install dependencies', 'npm install'],
['# Start development server', start],
...(pick.port ? ([['# Open in browser', `http://localhost:${pick.port}`]] as [string, string][]) : []),
] as [string, string][]
).map(([note, line]) => (
<YStack key={note}>
<Text {...mono} color={c.gray400} marginBottom={16}>
{note}
</Text>
<Text {...mono} color={c.green400} marginBottom={24}>
{line}
</Text>
</YStack>
))}
</YStack>
</Band>
{/* Perfect for */}
<Band>
<H2 {...t.xl4} fontWeight={700} color="#fff" marginBottom={32}>
Perfect For
</H2>
<Grid columns={{ min: 260, max: 3 }} gap={24}>
{(
[
['🚀', pick.useCase, 'Primary use case for this template', [c.blue500, c.purple500]],
['⚡', 'Fast Development', 'Pre-built components ready to use', [c.green500, c.blue500]],
['🎨', 'Modern Design', 'Beautiful UI following latest trends', [c.purple500, c.pink500]],
] as [string, string, string, [string, string]][]
).map(([icon, head, note, [from, to]]) => (
<YStack
key={head}
backgroundImage={`linear-gradient(to bottom right, ${at(from, 0.1)}, ${at(to, 0.1)})`}
padding={24}
borderRadius="var(--radius-xl, 1rem)"
borderWidth={1}
borderColor={c.white10}
>
<Text {...t.xl3} marginBottom={12}>
{icon}
</Text>
<H3 {...t.lg} fontWeight={700} color="#fff" marginBottom={8}>
{head}
</H3>
<Text {...t.sm} color={c.gray400}>
{note}
</Text>
</YStack>
))}
</Grid>
</Band>
{/* Call to action */}
<Band>
<YStack
backgroundImage={`linear-gradient(to right, ${at(c.blue500, 0.2)}, ${at(c.purple500, 0.2)})`}
backdropFilter="blur(16px)"
padding={48}
borderRadius="var(--radius-2xl, 1.5rem)"
borderWidth={1}
borderColor={c.white10}
alignItems="center"
>
<H2 {...t.xl4} fontWeight={700} color="#fff" marginBottom={24} textAlign="center">
Get Started with Hanzo AI
</H2>
<Text {...t.xl} color={c.gray400} marginBottom={32} textAlign="center">
This template is part of the Hanzo AI premium template collection
</Text>
<XStack flexWrap="wrap" gap={16} justifyContent="center">
<Cta onPress={openRepo} ground={c.neutral700} lift={c.neutral600}>
📦 View on GitHub
</Cta>
<Cta onPress={toFork} ground={c.purple500} lift={c.purple600}>
🚀 Deploy to Hanzo
</Cta>
<Cta onPress={() => copyPath('Path copied to clipboard!')} ground={c.blue500} lift={c.blue600}>
📋 Copy Path
</Cta>
<Button
render={<Link href="/gallery" />}
transition="quickest"
height="auto"
paddingHorizontal={32}
paddingVertical={16}
borderRadius="var(--radius-xl, 1rem)"
backgroundColor="rgba(255,255,255,0.1)"
borderWidth={1}
borderColor={c.white20}
hoverStyle={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
>
<Text {...t.lg} fontWeight={700} color="#fff">
Browse More Templates
</Text>
</Button>
</XStack>
</YStack>
</Band>
</YStack>
);
}
/** A rounded chip carrying a tint. */
function Chip({ children, ...rest }: { children: React.ReactNode; [k: string]: unknown }) {
return (
<Text
paddingHorizontal={16}
paddingVertical={8}
borderRadius="var(--radius-lg, 0.75rem)"
borderWidth={1}
fontWeight={500}
{...rest}
>
{children}
</Text>
);
}
/** One of the four buttons at the foot of the page. */
function Cta({
children,
ground,
lift,
onPress,
}: {
children: React.ReactNode;
ground: string;
lift: string;
onPress: () => void;
}) {
return (
<Button
onPress={onPress}
transition="quickest"
height="auto"
paddingHorizontal={32}
paddingVertical={16}
borderRadius="var(--radius-xl, 1rem)"
backgroundColor={ground}
boxShadow={`0 10px 15px ${at(ground, 0.5)}`}
hoverStyle={{ backgroundColor: lift, scale: 1.05, boxShadow: `0 10px 15px ${at(lift, 0.7)}` }}
>
<Text {...t.lg} fontWeight={700} color="#fff">
{children}
</Text>
</Button>
);
}
/** The action bar's tier mark: a deep ground under a bright label. */
function deep(tier: number) {
const h = hue(tier);
const edge = { green: c.green800, blue: c.blue800, purple: c.purple800 }[h];
const ground = { green: c.green950, blue: c.blue950, purple: c.purple950 }[h];
const ink = { green: c.green400, blue: c.blue400, purple: c.purple400 }[h];
return { borderColor: edge, backgroundColor: ground, color: ink };
}