22bc903041
Deploy / deploy (push) Successful in 1m5s
- 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>
85 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|