← back to Stayclaim
src/components/PosterPlaceholder.tsx
160 lines
/**
* PosterPlaceholder — deterministic SVG poster used when a production has no
* poster_url. Title, year, and kind drive the look; same input always yields
* the same poster so cards stay stable across renders.
*
* Aesthetic: editorial, in-lineage with the rest of the design system.
*/
const KIND_PALETTE: Record<string, { bg: string; ink: string; accent: string; label: string }> = {
film: { bg: '#1a1410', ink: '#f6f1e8', accent: '#d4664a', label: 'Feature' },
tv: { bg: '#0e2a26', ink: '#f6f1e8', accent: '#d4664a', label: 'Television' },
commercial: { bg: '#3a2515', ink: '#f6f1e8', accent: '#e8a574', label: 'Commercial' },
music_video: { bg: '#2a1a3a', ink: '#f6f1e8', accent: '#e8a574', label: 'Music video' },
documentary: { bg: '#1d2a1a', ink: '#f6f1e8', accent: '#d4664a', label: 'Documentary' },
short: { bg: '#1a1410', ink: '#f6f1e8', accent: '#d4664a', label: 'Short' },
student: { bg: '#222', ink: '#f6f1e8', accent: '#d4664a', label: 'Student' },
};
function fnv(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
export function PosterPlaceholder({
title,
year,
kind,
className = '',
width = 320,
height = 480,
}: {
title: string;
year: number | null;
kind: string;
className?: string;
width?: number;
height?: number;
}) {
const palette = KIND_PALETTE[kind] ?? KIND_PALETTE.film!;
const seed = fnv(`${title}-${year ?? ''}`);
// Deterministic decorative angle 8°-22° tilt
const angle = 8 + (seed % 14);
const stripe1Y = 32 + (seed % 28);
const stripe2Y = stripe1Y + 14 + ((seed >> 4) & 0x0f);
const initials = title
.split(/\s+/)
.filter(w => w.length > 0)
.map(w => w[0])
.join('')
.slice(0, 3)
.toUpperCase();
// Title on multiple lines — break by words, max 4 lines, max 22 chars/line.
const words = title.split(/\s+/);
const lines: string[] = [];
let cur = '';
for (const w of words) {
if ((cur + ' ' + w).trim().length <= 22) cur = (cur + ' ' + w).trim();
else {
if (cur) lines.push(cur);
cur = w;
if (lines.length >= 3) break;
}
}
if (cur && lines.length < 4) lines.push(cur);
const W = 320;
const H = 480;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
width={width}
height={height}
className={className}
role="img"
aria-label={`Poster for ${title}${year ? ` (${year})` : ''}`}
preserveAspectRatio="xMidYMid slice"
>
<rect width={W} height={H} fill={palette.bg} />
{/* Decorative tilted stripes */}
<g transform={`rotate(${angle} ${W / 2} ${H / 2})`}>
<rect x={-50} y={stripe1Y} width={W + 100} height={2} fill={palette.accent} opacity={0.85} />
<rect x={-50} y={stripe2Y} width={W + 100} height={1} fill={palette.ink} opacity={0.35} />
<rect x={-50} y={H - 90} width={W + 100} height={1} fill={palette.ink} opacity={0.25} />
</g>
{/* Top-left mark */}
<text
x={20}
y={36}
fill={palette.accent}
fontFamily="Inter, system-ui, sans-serif"
fontSize={11}
fontWeight={500}
letterSpacing={2}
style={{ textTransform: 'uppercase' as const }}
>
{palette.label}
</text>
{/* Big initials */}
<text
x={W / 2}
y={H / 2 - 30}
fill={palette.ink}
fontFamily="Instrument Serif, Georgia, serif"
fontSize={120}
textAnchor="middle"
opacity={0.9}
>
{initials}
</text>
{/* Title block */}
<g transform={`translate(20, ${H - 110})`}>
{lines.map((line, i) => (
<text
key={i}
x={0}
y={i * 30}
fill={palette.ink}
fontFamily="Instrument Serif, Georgia, serif"
fontSize={26}
letterSpacing={-0.4}
>
{line}
</text>
))}
</g>
{year && (
<text
x={20}
y={H - 18}
fill={palette.ink}
opacity={0.55}
fontFamily="IBM Plex Mono, monospace"
fontSize={11}
letterSpacing={1.5}
>
{year}
</text>
)}
<text
x={W - 20}
y={H - 18}
fill={palette.ink}
opacity={0.4}
fontFamily="IBM Plex Mono, monospace"
fontSize={9}
letterSpacing={1.5}
textAnchor="end"
>
the archive
</text>
</svg>
);
}