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