4ac1817425
Deploy / deploy (push) Successful in 41s
Supervisors log scene/shot/take, per-camera settings, VFX tags, references and photos on iPhone/iPad; works offline, stored on device (localStorage + IndexedDB for photos). Exports CSV + photos and a .json backup/restore. /set/report/view renders a printable A4 report. "Send report link" carries the day's log compressed in the URL fragment, so post gets a formatted page with Save PDF and nothing is uploaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
15 KiB
TypeScript
362 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import Link from "next/link";
|
||
import { site } from "@/content/site";
|
||
import { type Cam, type Report, type Take, CAM_FIELDS, clock, decodeReport, getPhoto, load, photoName, toCsv } from "@/lib/report";
|
||
|
||
// The day's report as a printed sheet: white paper on screen, A4 on print. Settings that
|
||
// stay the same for a whole setup are pulled up into one line, so the take table only
|
||
// shows what changed between takes.
|
||
|
||
type Source = "link" | "device";
|
||
type State =
|
||
| { status: "loading" }
|
||
| { status: "error" }
|
||
| { status: "ready"; report: Report; source: Source; photos: Record<string, string> };
|
||
type Setup = { key: string; scene: string; shot: string; takes: Take[] };
|
||
type Col = { key: keyof Cam; label: string };
|
||
|
||
const COLS: Col[] = [{ key: "roll", label: "Roll" }, { key: "clip", label: "Clip" }, ...CAM_FIELDS.map(([key, label]) => ({ key, label }))];
|
||
// How a constant setting reads in the setup summary
|
||
const SUMMARY: Partial<Record<keyof Cam, (v: string) => string>> = {
|
||
lens: (v) => `${v}mm`,
|
||
stop: (v) => `T${v}`,
|
||
focus: (v) => `Focus ${v}`,
|
||
height: (v) => `Height ${v}`,
|
||
tilt: (v) => `Tilt ${v}${/°$/.test(v) ? "" : "°"}`,
|
||
fps: (v) => `${v} fps`,
|
||
shutter: (v) => `Shutter ${v}`,
|
||
iso: (v) => `ISO ${v}`,
|
||
roll: (v) => `Roll ${v}`,
|
||
filter: (v) => v,
|
||
};
|
||
|
||
function setupsOf(takes: Take[]): Setup[] {
|
||
const out: Setup[] = [];
|
||
for (const t of takes) {
|
||
const key = `${t.scene}/${t.shot}`;
|
||
const s = out.find((x) => x.key === key);
|
||
if (s) s.takes.push(t);
|
||
else out.push({ key, scene: t.scene, shot: t.shot, takes: [t] });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const uniq = <T,>(a: T[]) => [...new Set(a)];
|
||
|
||
export default function ReportView() {
|
||
const [state, setState] = useState<State>({ status: "loading" });
|
||
|
||
useEffect(() => {
|
||
const urls: string[] = [];
|
||
(async () => {
|
||
const hash = location.hash.slice(1);
|
||
if (hash) {
|
||
try {
|
||
setState({ status: "ready", report: await decodeReport(hash), source: "link", photos: {} });
|
||
} catch {
|
||
setState({ status: "error" });
|
||
}
|
||
return;
|
||
}
|
||
// No link data: this device's own log, with its photos
|
||
const { meta, takes } = load();
|
||
const photos: Record<string, string> = {};
|
||
for (const id of takes.flatMap((t) => t.photos)) {
|
||
try {
|
||
const b = await getPhoto(id);
|
||
if (b) urls.push((photos[id] = URL.createObjectURL(b)));
|
||
} catch {}
|
||
}
|
||
setState({ status: "ready", report: { meta, takes }, source: "device", photos });
|
||
})();
|
||
return () => urls.forEach((u) => URL.revokeObjectURL(u));
|
||
}, []);
|
||
|
||
// The document title becomes the PDF's file name
|
||
useEffect(() => {
|
||
if (state.status !== "ready") return;
|
||
const m = state.report.meta;
|
||
document.title = ["VFX report", m.production, m.day && `Day ${m.day}`].filter(Boolean).join(" - ");
|
||
}, [state]);
|
||
|
||
if (state.status === "loading") return <div className="min-h-dvh" />;
|
||
if (state.status === "error")
|
||
return (
|
||
<p className="mx-auto max-w-md px-6 py-24 text-center text-sm leading-relaxed text-muted">
|
||
This report link is incomplete. Some mail apps cut long links short: ask for it to be sent again, or as a
|
||
message instead.
|
||
</p>
|
||
);
|
||
|
||
const { report, source, photos } = state;
|
||
const { meta, takes } = report;
|
||
const setups = setupsOf(takes);
|
||
const first = takes[0]?.at;
|
||
const last = takes[takes.length - 1]?.at;
|
||
const date = first
|
||
? new Date(first).toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long", year: "numeric" })
|
||
: "";
|
||
const cams = uniq(takes.flatMap((t) => t.cams.map((c) => c.id)));
|
||
const photoCount = takes.reduce((n, t) => n + t.photos.length, 0);
|
||
const tagCounts = uniq(takes.flatMap((t) => t.tags)).map((tag) => ({
|
||
tag,
|
||
n: setups.filter((s) => s.takes.some((t) => t.tags.includes(tag))).length,
|
||
}));
|
||
|
||
const downloadCsv = () => {
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(new Blob([toCsv(report)], { type: "text/csv" }));
|
||
a.download = `${document.title}.csv`;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
};
|
||
|
||
return (
|
||
<div className="px-0 pb-16 sm:px-6 print:p-0">
|
||
{/* Toolbar (screen only) */}
|
||
<div className="mx-auto flex max-w-[60rem] items-center gap-2 px-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-3 sm:px-0 print:hidden">
|
||
{source === "device" ? (
|
||
<Link href="/set/report/" className="flex min-h-11 items-center pr-2 text-sm text-muted">
|
||
← Logger
|
||
</Link>
|
||
) : (
|
||
<span className="font-mono text-[11px] tracking-wider text-muted uppercase">Shared report</span>
|
||
)}
|
||
<span className="flex-1" />
|
||
<button onClick={downloadCsv} className="min-h-11 rounded-lg bg-white/5 px-4 text-sm text-fg">
|
||
CSV
|
||
</button>
|
||
<button onClick={() => window.print()} className="min-h-11 rounded-lg bg-accent px-4 text-sm font-medium text-ink">
|
||
Save PDF
|
||
</button>
|
||
</div>
|
||
|
||
<article className="report mx-auto max-w-[60rem] bg-[#fbfaf7] px-5 py-8 text-[#141413] shadow-2xl sm:rounded-sm sm:px-12 sm:py-12 print:max-w-none print:bg-white print:p-0 print:shadow-none">
|
||
{/* Masthead */}
|
||
<header className="flex items-center gap-2.5 border-b border-[#141413] pb-3">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img src="/fox.svg" alt="" className="h-5 w-5 invert" />
|
||
<span className="font-display text-xs tracking-[0.3em] uppercase">Two Tales VFX</span>
|
||
<span className="flex-1" />
|
||
<span className="font-mono text-[10px] tracking-widest text-[#77756f] uppercase">On-set VFX report</span>
|
||
</header>
|
||
|
||
<h1 className="mt-8 font-display text-4xl leading-none font-light tracking-wide uppercase sm:text-5xl">
|
||
{meta.production || "Untitled production"}
|
||
</h1>
|
||
<dl className="mt-6 grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-4">
|
||
<Meta label="Shoot day" value={meta.day} />
|
||
<Meta label="Date" value={date} wide />
|
||
<Meta label="Unit" value={meta.unit} />
|
||
<Meta label="VFX supervisor" value={meta.supervisor} />
|
||
</dl>
|
||
|
||
{/* Day at a glance */}
|
||
<section className="mt-8 grid grid-cols-3 border-y border-[#e3e0d8] sm:grid-cols-6">
|
||
<Stat n={setups.length} label="Setups" />
|
||
<Stat n={takes.length} label="Takes" />
|
||
<Stat n={takes.filter((t) => t.rating === "circle").length} label="Circled" accent />
|
||
<Stat n={cams.length} label={cams.length === 1 ? "Camera" : "Cameras"} />
|
||
<Stat n={photoCount} label="Photos" />
|
||
<Stat n={first ? `${clock(first)}` : "–"} label={last && last !== first ? `to ${clock(last)}` : "First take"} small />
|
||
</section>
|
||
|
||
{tagCounts.length > 0 && (
|
||
<section className="mt-6">
|
||
<Label>VFX work today</Label>
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
{tagCounts.map(({ tag, n }) => (
|
||
<span key={tag} className="rounded-full border border-[#d9d5cc] px-2.5 py-0.5 text-xs">
|
||
{tag} <span className="text-[#77756f]">· {n} setup{n > 1 ? "s" : ""}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{setups.length === 0 && <p className="mt-12 text-center text-sm text-[#77756f]">No takes logged yet.</p>}
|
||
|
||
{setups.map((s) => (
|
||
<SetupBlock key={s.key} setup={s} photos={photos} source={source} />
|
||
))}
|
||
|
||
<footer className="mt-12 flex justify-between border-t border-[#e3e0d8] pt-3 font-mono text-[10px] text-[#77756f]">
|
||
<span>{site.name} · {new URL(site.url).host}</span>
|
||
<span>
|
||
{takes.length} takes · {setups.length} setups
|
||
</span>
|
||
</footer>
|
||
</article>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SetupBlock({ setup, photos, source }: { setup: Setup; photos: Record<string, string>; source: Source }) {
|
||
const rows = setup.takes.flatMap((t) => t.cams.map((c, i) => ({ t, c, first: i === 0, span: t.cams.length })));
|
||
const multiCam = uniq(rows.map((r) => r.c.id)).length > 1 || rows.some((r) => r.c.id !== "A");
|
||
// Same value on every row: say it once above the table. Different: a table column.
|
||
const constant: [Col, string][] = [];
|
||
const varying: Col[] = [];
|
||
for (const col of COLS) {
|
||
const vals = uniq(rows.map((r) => r.c[col.key]));
|
||
if (vals.length === 1) {
|
||
if (vals[0] && col.key !== "clip") constant.push([col, vals[0]]);
|
||
else if (vals[0]) varying.push(col);
|
||
} else varying.push(col);
|
||
}
|
||
const tags = uniq(setup.takes.flatMap((t) => t.tags));
|
||
const refs = uniq(setup.takes.flatMap((t) => t.refs));
|
||
const shots = setup.takes.flatMap((t) => t.photos.map((p, i) => ({ t, p, name: source === "link" ? p : photoName(t, i) })));
|
||
const start = setup.takes[0].at;
|
||
const end = setup.takes[setup.takes.length - 1].at;
|
||
const circled = setup.takes.filter((t) => t.rating === "circle").map((t) => t.take);
|
||
|
||
return (
|
||
<section className="mt-12 break-inside-avoid-page">
|
||
<div className="flex items-end gap-4 border-b border-[#141413] pb-2">
|
||
<h2 className="font-mono text-2xl leading-none">
|
||
<span className="mr-1 align-middle text-[10px] tracking-widest text-[#77756f] uppercase">Sc</span>
|
||
{setup.scene}
|
||
<span className="mx-2 text-[#c9c5bb]">/</span>
|
||
<span className="mr-1 align-middle text-[10px] tracking-widest text-[#77756f] uppercase">Shot</span>
|
||
{setup.shot}
|
||
</h2>
|
||
<span className="flex-1" />
|
||
<span className="text-right font-mono text-[11px] leading-tight text-[#77756f]">
|
||
{setup.takes.length} take{setup.takes.length > 1 ? "s" : ""}
|
||
{circled.length > 0 && <span className="text-[#c4451c]"> · circled {circled.join(", ")}</span>}
|
||
<br />
|
||
{clock(start)}
|
||
{end !== start && `–${clock(end)}`}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-3 grid gap-x-8 gap-y-2 text-[13px] sm:grid-cols-[1fr_auto]">
|
||
<div>
|
||
{constant.length > 0 && (
|
||
<p>
|
||
<Label inline>Camera</Label>
|
||
{constant.map(([col, v]) => (SUMMARY[col.key] ?? ((x: string) => `${col.label} ${x}`))(v)).join(" · ")}
|
||
</p>
|
||
)}
|
||
{tags.length > 0 && (
|
||
<p className="mt-1">
|
||
<Label inline>VFX</Label>
|
||
{tags.join(", ")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{refs.length > 0 && (
|
||
<ul className="flex flex-wrap content-start gap-1 self-start sm:max-w-[18rem] sm:justify-end">
|
||
{refs.map((r) => (
|
||
<li key={r} className="rounded-sm bg-[#141413] px-1.5 py-0.5 text-[10px] tracking-wide whitespace-nowrap text-[#fbfaf7] uppercase">
|
||
✓ {r}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
{/* Takes: scrolls sideways inside itself on a phone, never the page */}
|
||
<div className="-mx-5 mt-4 overflow-x-auto px-5 sm:mx-0 sm:px-0 print:overflow-visible">
|
||
<table className="w-full border-collapse text-left text-[12.5px]">
|
||
<thead>
|
||
<tr className="border-b border-[#d9d5cc] text-[9.5px] tracking-wider text-[#77756f] uppercase">
|
||
<th className="py-1.5 pr-3 font-normal">Take</th>
|
||
{multiCam && <th className="py-1.5 pr-3 font-normal">Cam</th>}
|
||
{varying.map((c) => (
|
||
<th key={c.key} className="py-1.5 pr-3 font-normal whitespace-nowrap">
|
||
{c.label}
|
||
</th>
|
||
))}
|
||
<th className="w-full py-1.5 font-normal">Notes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map(({ t, c, first, span }) => (
|
||
<tr key={`${t.id}-${c.id}`} className={`break-inside-avoid align-top ${first ? "border-t border-[#ebe8e1]" : ""}`}>
|
||
{first && (
|
||
<td rowSpan={span} className="py-2 pr-3 whitespace-nowrap">
|
||
<TakeNo t={t} />
|
||
</td>
|
||
)}
|
||
{multiCam && <td className="py-2 pr-3 font-mono">{c.id}</td>}
|
||
{varying.map((col) => (
|
||
<td key={col.key} className="py-2 pr-3 font-mono whitespace-nowrap">
|
||
{c[col.key] || <span className="text-[#c9c5bb]">–</span>}
|
||
</td>
|
||
))}
|
||
{first && (
|
||
<td rowSpan={span} className="min-w-[12rem] py-2 leading-snug">
|
||
{t.rating === "ng" && <span className="mr-1.5 text-[10px] tracking-wider text-[#77756f] uppercase">NG</span>}
|
||
{t.notes}
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{shots.length > 0 &&
|
||
(source === "device" ? (
|
||
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||
{shots.map(({ p, name }) => (
|
||
<figure key={p} className="break-inside-avoid">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
{photos[p] && <img src={photos[p]} alt={name} className="aspect-[4/3] w-full rounded-sm object-cover" />}
|
||
<figcaption className="mt-1 font-mono text-[9.5px] text-[#77756f]">{name}</figcaption>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="mt-3 text-[11px] leading-relaxed text-[#77756f]">
|
||
<Label inline>Photos</Label>
|
||
<span className="font-mono">{shots.map((s) => s.name).join(" · ")}</span>
|
||
</p>
|
||
))}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function TakeNo({ t }: { t: Take }) {
|
||
if (t.rating === "circle")
|
||
return (
|
||
<span className="inline-flex h-7 min-w-7 items-center justify-center rounded-full border-[1.5px] border-[#e8572a] px-1 font-mono text-[15px] text-[#c4451c]">
|
||
{t.take}
|
||
</span>
|
||
);
|
||
return (
|
||
<span className={`inline-flex h-7 items-center font-mono text-[15px] ${t.rating === "ng" ? "text-[#a5a29a] line-through" : ""}`}>
|
||
T{t.take}
|
||
{t.rating === "good" && <span className="ml-1.5 text-[9px] tracking-wider text-[#4f7a3a] uppercase no-underline">Good</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function Meta({ label, value, wide }: { label: string; value: string; wide?: boolean }) {
|
||
return (
|
||
<div className={wide ? "col-span-2 sm:col-span-1" : ""}>
|
||
<dt className="text-[9.5px] tracking-wider text-[#77756f] uppercase">{label}</dt>
|
||
<dd className="mt-0.5">{value || <span className="text-[#c9c5bb]">–</span>}</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stat({ n, label, accent, small }: { n: number | string; label: string; accent?: boolean; small?: boolean }) {
|
||
return (
|
||
<div className="border-[#e3e0d8] py-4 pr-2 [&:not(:first-child)]:pl-4 sm:[&:not(:first-child)]:border-l">
|
||
<div className={`font-mono leading-none ${small ? "pt-1.5 text-lg" : "text-3xl"} ${accent ? "text-[#c4451c]" : ""}`}>{n}</div>
|
||
<div className="mt-1.5 text-[9.5px] tracking-wider text-[#77756f] uppercase">{label}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Label({ children, inline }: { children: React.ReactNode; inline?: boolean }) {
|
||
return (
|
||
<span className={`text-[9.5px] tracking-wider text-[#77756f] uppercase ${inline ? "mr-2" : "block"}`}>{children}</span>
|
||
);
|
||
}
|