Files
ttvfx/components/previz/ShotList.tsx
T
twotalesanimation 22bc903041
Deploy / deploy (push) Successful in 1m5s
/previz: password protect, add PDF export of the crowd previz
- nginx basic auth on /previz/ (login file lives on the server, mounted
  from docker/auth/, see DEPLOY.md step 8); private, no-store so
  Cloudflare never caches it
- Print styles for previz pages: dark, A4 landscape, one section a page;
  videos print as poster frames with a link
- scripts/previz-pdf.mjs (npm run previz:pdf) prints the page to
  public/previz/crowd/crowd-previz.pdf via Edge/Chrome DevTools
- "PDF" download link on the page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 06:21:25 +02:00

85 lines
3.6 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
export type Shot = { id: string; name: string; setup: string; notes: string };
// Ticks live in this device's localStorage only (static site, no server).
export default function ShotList({ shots, storeKey }: { shots: Shot[]; storeKey: string }) {
const [done, setDone] = useState<string[] | null>(null);
// Storage is read after mount so the static HTML matches on first paint
useEffect(() => {
let saved: string[] = [];
try {
saved = JSON.parse(localStorage.getItem(storeKey) ?? "[]");
} catch {}
setDone(Array.isArray(saved) ? saved : []); // eslint-disable-line react-hooks/set-state-in-effect
}, [storeKey]);
useEffect(() => {
if (!done) return;
try {
localStorage.setItem(storeKey, JSON.stringify(done));
} catch {}
}, [done, storeKey]);
const ticked = done ?? [];
const count = shots.filter((s) => ticked.includes(s.id)).length;
const toggle = (id: string) =>
setDone((d) => ((d ?? []).includes(id) ? (d ?? []).filter((x) => x !== id) : [...(d ?? []), id]));
return (
<div>
<div className="flex items-center justify-between gap-4 border border-b-0 border-line px-4 py-3 md:px-5">
<p className="font-mono text-xs tracking-[0.2em] text-muted uppercase">
<span className="text-fg">{count}</span> / {shots.length} shot
{count === shots.length && <span className="ml-3 text-fg">· All done</span>}
</p>
{count > 0 && (
<button
type="button"
onClick={() => setDone([])}
className="-my-2 py-2 font-mono text-[11px] tracking-[0.2em] text-muted uppercase hover:text-fg print:hidden"
>
Clear ticks
</button>
)}
</div>
<ul className="divide-y divide-line border border-line">
{shots.map((s, i) => {
const on = ticked.includes(s.id);
return (
<li key={s.id}>
<label className="flex cursor-pointer items-start gap-4 p-4 transition-colors hover:bg-ink-2 md:gap-5 md:p-5">
<input type="checkbox" checked={on} onChange={() => toggle(s.id)} className="peer sr-only" />
<span
aria-hidden="true"
className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center border-2 text-base leading-none font-bold peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent ${
on ? "border-fg bg-fg text-ink" : "border-muted"
}`}
>
{on && "✓"}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-mono text-xs text-accent">{String(i + 1).padStart(2, "0")}</span>
<span className={`text-lg ${on ? "text-muted line-through" : ""}`}>{s.name}</span>
<span className="font-mono text-[10px] tracking-[0.2em] text-muted uppercase">{s.setup}</span>
{on && (
<span className="font-mono text-[10px] font-semibold tracking-[0.2em] text-fg uppercase">Done</span>
)}
</span>
<span className={`mt-1.5 block text-sm leading-relaxed text-muted md:text-base ${on ? "opacity-60" : ""}`}>
{s.notes}
</span>
</span>
</label>
</li>
);
})}
</ul>
<p className="mt-3 text-xs text-muted print:hidden">Ticks are saved on this device only.</p>
</div>
);
}