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>
695 lines
27 KiB
TypeScript
695 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import {
|
||
type Cam, type Rating, type Store, type Take,
|
||
CAM_FIELDS, STORE_KEY, blankTake, blobToDataUrl, clearPhotos, delPhoto, encodeReport, getPhoto,
|
||
clock, load, normalise, photoName, putPhoto, shrink, toCsv, today, uid,
|
||
} from "@/lib/report";
|
||
|
||
// On-set VFX take log. Built for one thumb between takes: everything carries over from
|
||
// the last take, "Save · next take" bumps the take number (and the clip name), so most
|
||
// takes are one tap plus whatever changed. At wrap: a report link for post (/set/report/view),
|
||
// CSV + photos, and a .json backup that carries everything (photos inline) to another device.
|
||
|
||
// Must match CACHE in public/set/sw.js
|
||
const OFFLINE_CACHE = "ttvfx-set-v1";
|
||
|
||
const REFS = ["Grey ball", "Chrome ball", "Colour chart", "Clean plate", "HDRI", "Witness cam", "Set photos", "Lidar / scan", "Measurements", "Markers"];
|
||
const TAGS = ["Green screen", "Blue screen", "Screen replace", "Clean-up", "Set extension", "CG character", "FX / sim", "Muzzle flash", "Stunt rig", "Driving", "Crowd"];
|
||
const RATINGS: [Rating, string][] = [
|
||
["circle", "◯ Circled"],
|
||
["good", "Good"],
|
||
["ng", "NG"],
|
||
];
|
||
|
||
// A003C007 -> A003C008: bump the last run of digits, keeping its width
|
||
function bumpClip(clip: string) {
|
||
return clip.replace(/(\d+)(?!.*\d)/, (d) => String(Number(d) + 1).padStart(d.length, "0"));
|
||
}
|
||
|
||
// Next take keeps the whole setup; only per-take things reset
|
||
function nextFrom(t: Take, newShot = false): Take {
|
||
return {
|
||
...t,
|
||
id: uid(),
|
||
at: 0,
|
||
shot: newShot ? "" : t.shot,
|
||
take: newShot ? 1 : t.take + 1,
|
||
cams: t.cams.map((c) => ({ ...c, clip: c.clip ? bumpClip(c.clip) : "" })),
|
||
refs: newShot ? [] : t.refs,
|
||
rating: "",
|
||
notes: "",
|
||
photos: [],
|
||
};
|
||
}
|
||
|
||
// iPhone/iPad: share sheet (AirDrop, Mail, Save to Files). Elsewhere: plain downloads.
|
||
async function shareFiles(files: File[]) {
|
||
if (navigator.canShare?.({ files })) {
|
||
try {
|
||
await navigator.share({ files, title: files[0].name });
|
||
return;
|
||
} catch (e) {
|
||
if ((e as Error).name === "AbortError") return;
|
||
}
|
||
}
|
||
for (const f of files) {
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(f);
|
||
a.download = f.name;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
}
|
||
}
|
||
|
||
export default function ShotReport() {
|
||
const [store, setStore] = useState<Store | null>(null);
|
||
const [view, setView] = useState<"take" | "log" | "day">("take");
|
||
const [editing, setEditing] = useState(false); // draft is an existing take from the log
|
||
const [cam, setCam] = useState(0);
|
||
const [flash, setFlash] = useState("");
|
||
const [busy, setBusy] = useState("");
|
||
const [offline, setOffline] = useState(false);
|
||
const photoInput = useRef<HTMLInputElement>(null);
|
||
const restoreInput = useRef<HTMLInputElement>(null);
|
||
|
||
// Storage is read after mount so the static HTML matches on first paint
|
||
useEffect(() => {
|
||
setStore(load()); // eslint-disable-line react-hooks/set-state-in-effect
|
||
}, []);
|
||
useEffect(() => {
|
||
if (!store) return;
|
||
try {
|
||
localStorage.setItem(STORE_KEY, JSON.stringify(store));
|
||
} catch {}
|
||
}, [store]);
|
||
|
||
// Offline: same service worker as /set; save this page and its assets on first visit
|
||
useEffect(() => {
|
||
if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator) || !("caches" in window)) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
await navigator.serviceWorker.register("/set/sw.js", { scope: "/set/" });
|
||
await navigator.serviceWorker.ready;
|
||
const assets = performance
|
||
.getEntriesByType("resource")
|
||
.map((e) => new URL(e.name))
|
||
.filter((u) => u.origin === location.origin && /^\/(_next\/|icon|apple-icon)/.test(u.pathname))
|
||
.map((u) => u.pathname + u.search);
|
||
const cache = await caches.open(OFFLINE_CACHE);
|
||
await cache.addAll(["/set/report/", "/set/report/view/", ...new Set(assets)]);
|
||
if (!cancelled) setOffline(true);
|
||
} catch {}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!flash) return;
|
||
const t = setTimeout(() => setFlash(""), 1600);
|
||
return () => clearTimeout(t);
|
||
}, [flash]);
|
||
|
||
const lenses = useMemo(() => {
|
||
const seen = new Set<string>();
|
||
for (const t of [...(store?.takes ?? [])].reverse()) for (const c of t.cams) if (c.lens) seen.add(c.lens);
|
||
return [...seen].slice(0, 8);
|
||
}, [store?.takes]);
|
||
|
||
if (!store) return <div className="min-h-dvh" />;
|
||
|
||
const d = store.draft;
|
||
const c = d.cams[Math.min(cam, d.cams.length - 1)];
|
||
const setDraft = (patch: Partial<Take>) => setStore({ ...store, draft: { ...d, ...patch } });
|
||
const setCamField = (k: keyof Cam, v: string) =>
|
||
setDraft({ cams: d.cams.map((x) => (x.id === c.id ? { ...x, [k]: v } : x)) });
|
||
const toggle = (k: "refs" | "tags", v: string) =>
|
||
setDraft({ [k]: d[k].includes(v) ? d[k].filter((x) => x !== v) : [...d[k], v] });
|
||
|
||
const save = (newShot = false) => {
|
||
if (!d.scene.trim() || !d.shot.trim()) {
|
||
setFlash("Scene and shot first");
|
||
return;
|
||
}
|
||
const saved = { ...d, at: d.at || Date.now() };
|
||
const exists = store.takes.some((t) => t.id === d.id);
|
||
const takes = exists ? store.takes.map((t) => (t.id === d.id ? saved : t)) : [...store.takes, saved];
|
||
// After editing an old take, carry on from the latest one rather than the edited one
|
||
const base = editing ? takes[takes.length - 1] : saved;
|
||
setStore({ ...store, takes, draft: nextFrom(base, newShot) });
|
||
setFlash(`Saved ${saved.scene}/${saved.shot} T${saved.take}`);
|
||
setEditing(false);
|
||
setCam(0);
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
};
|
||
|
||
const edit = (t: Take) => {
|
||
setStore({ ...store, draft: structuredClone(t) });
|
||
setEditing(true);
|
||
setCam(0);
|
||
setView("take");
|
||
window.scrollTo({ top: 0 });
|
||
};
|
||
|
||
const cancelEdit = () => {
|
||
const last = store.takes[store.takes.length - 1];
|
||
setStore({ ...store, draft: last ? nextFrom(last) : blankTake() });
|
||
setEditing(false);
|
||
};
|
||
|
||
const remove = (t: Take) => {
|
||
if (!confirm(`Delete ${t.scene}/${t.shot} take ${t.take}?`)) return;
|
||
setStore({ ...store, takes: store.takes.filter((x) => x.id !== t.id) });
|
||
t.photos.forEach((id) => delPhoto(id).catch(() => {}));
|
||
};
|
||
|
||
const addPhotos = async (files: FileList) => {
|
||
setBusy("Saving photos…");
|
||
try {
|
||
const ids: string[] = [];
|
||
for (const f of Array.from(files)) {
|
||
const id = uid();
|
||
await putPhoto(id, await shrink(f));
|
||
ids.push(id);
|
||
}
|
||
setStore((s) => s && { ...s, draft: { ...s.draft, photos: [...s.draft.photos, ...ids] } });
|
||
} catch {
|
||
setFlash("Couldn't save that photo");
|
||
}
|
||
setBusy("");
|
||
};
|
||
|
||
const removePhoto = (id: string) => {
|
||
setDraft({ photos: d.photos.filter((x) => x !== id) });
|
||
// A logged take still uses it until the edit is saved
|
||
if (!store.takes.some((t) => t.photos.includes(id))) delPhoto(id).catch(() => {});
|
||
};
|
||
|
||
const fileBase = () => {
|
||
const m = store.meta;
|
||
return `VFX-report_${[m.production, m.day ? `day${m.day}` : "", today()].filter(Boolean).join("_").replace(/\s+/g, "-")}`;
|
||
};
|
||
|
||
// CSV plus every photo, named to match the CSV's Photos column
|
||
// The whole day rides in the link's #fragment: nothing is uploaded, the server never sees it
|
||
const shareLink = async () => {
|
||
const url = `${location.origin}/set/report/view/#${await encodeReport(store)}`;
|
||
const m = store.meta;
|
||
const title = `VFX report${m.production ? ` · ${m.production}` : ""}${m.day ? ` · Day ${m.day}` : ""}`;
|
||
if (navigator.share) {
|
||
try {
|
||
await navigator.share({ title, url });
|
||
return;
|
||
} catch (e) {
|
||
if ((e as Error).name === "AbortError") return;
|
||
}
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
setFlash("Link copied");
|
||
} catch {
|
||
window.open(url, "_blank");
|
||
}
|
||
};
|
||
|
||
const exportReport = async () => {
|
||
setBusy("Preparing…");
|
||
const files = [new File([toCsv(store)], `${fileBase()}.csv`, { type: "text/csv" })];
|
||
try {
|
||
for (const t of store.takes)
|
||
for (const [i, id] of t.photos.entries()) {
|
||
const b = await getPhoto(id);
|
||
if (b) files.push(new File([b], photoName(t, i), { type: "image/jpeg" }));
|
||
}
|
||
} catch {}
|
||
setBusy("");
|
||
await shareFiles(files);
|
||
};
|
||
|
||
// One self-contained file: the whole log with photos inline
|
||
const backup = async () => {
|
||
setBusy("Preparing…");
|
||
const photos: Record<string, string> = {};
|
||
try {
|
||
for (const t of [...store.takes, store.draft])
|
||
for (const id of t.photos) {
|
||
const b = await getPhoto(id);
|
||
if (b) photos[id] = await blobToDataUrl(b);
|
||
}
|
||
} catch {}
|
||
const json = JSON.stringify({ app: "ttvfx-report", version: 1, ...store, photos });
|
||
setBusy("");
|
||
await shareFiles([new File([json], `${fileBase()}.json`, { type: "application/json" })]);
|
||
};
|
||
|
||
const restore = async (f: File) => {
|
||
try {
|
||
const data = JSON.parse(await f.text());
|
||
if (data.app !== "ttvfx-report") throw new Error();
|
||
const n = data.takes?.length ?? 0;
|
||
if (store.takes.length && !confirm(`Replace the ${store.takes.length} takes on this device with the ${n} in this backup?`)) return;
|
||
setBusy("Restoring…");
|
||
await clearPhotos();
|
||
for (const [id, url] of Object.entries(data.photos ?? {}) as [string, string][])
|
||
await putPhoto(id, await (await fetch(url)).blob());
|
||
setStore(normalise(data));
|
||
setEditing(false);
|
||
setFlash(`Restored ${n} takes`);
|
||
} catch {
|
||
setFlash("That isn't a VFX report backup");
|
||
}
|
||
setBusy("");
|
||
};
|
||
|
||
const newDay = () => {
|
||
if (!confirm("Start a new day? This clears every logged take and photo on this device. Back up first.")) return;
|
||
setStore({ meta: { ...store.meta, day: store.meta.day ? String(Number(store.meta.day) + 1 || "") : "" }, takes: [], draft: blankTake() });
|
||
clearPhotos().catch(() => {});
|
||
setEditing(false);
|
||
setView("take");
|
||
};
|
||
|
||
return (
|
||
<div className="mx-auto max-w-2xl px-4 pb-40">
|
||
<header className="flex items-center gap-3 pt-[max(0.75rem,env(safe-area-inset-top))] pb-2">
|
||
<Link href="/set/" className="flex min-h-11 items-center pr-2 text-sm text-muted">
|
||
← Markers
|
||
</Link>
|
||
<h1 className="flex-1 text-center font-display text-lg font-light tracking-wide uppercase">VFX report</h1>
|
||
<span className="w-20 text-right font-mono text-[10px] text-muted">{offline ? "✓ Offline" : ""}</span>
|
||
</header>
|
||
|
||
<nav className="sticky top-0 z-20 -mx-4 flex gap-1 bg-ink/95 px-4 pt-[env(safe-area-inset-top)] pb-3 backdrop-blur">
|
||
{([["take", editing ? "Editing" : "Take"], ["log", `Log · ${store.takes.length}`], ["day", "Day"]] as const).map(([v, l]) => (
|
||
<button
|
||
key={v}
|
||
onClick={() => setView(v)}
|
||
className={`min-h-11 flex-1 rounded-lg text-sm ${view === v ? "bg-fg text-ink" : "bg-white/5 text-muted"}`}
|
||
>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
{view === "take" && (
|
||
<>
|
||
{/* Slate */}
|
||
<section className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||
<BigField label="Scene" value={d.scene} onChange={(v) => setDraft({ scene: v })} />
|
||
<BigField label="Shot" value={d.shot} onChange={(v) => setDraft({ shot: v })} />
|
||
<div className="rounded-xl bg-white/5 px-2 pt-2 pb-1">
|
||
<span className="block px-1 text-[11px] tracking-wider text-muted uppercase">Take</span>
|
||
<div className="flex items-center">
|
||
<StepBtn onClick={() => setDraft({ take: Math.max(1, d.take - 1) })}>−</StepBtn>
|
||
<input
|
||
value={d.take}
|
||
inputMode="numeric"
|
||
onChange={(e) => setDraft({ take: Math.max(1, Number(e.target.value.replace(/\D/g, "")) || 1) })}
|
||
className="w-12 bg-transparent text-center font-mono text-3xl outline-none"
|
||
aria-label="Take"
|
||
/>
|
||
<StepBtn onClick={() => setDraft({ take: d.take + 1 })}>+</StepBtn>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Rating */}
|
||
<section className="mt-3 grid grid-cols-3 gap-2">
|
||
{RATINGS.map(([r, l]) => (
|
||
<button
|
||
key={r}
|
||
onClick={() => setDraft({ rating: d.rating === r ? "" : r })}
|
||
className={`min-h-12 rounded-xl text-sm font-medium ${
|
||
d.rating === r ? (r === "ng" ? "bg-white/25 text-fg line-through" : "bg-accent text-ink") : "bg-white/5 text-muted"
|
||
}`}
|
||
>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</section>
|
||
|
||
{/* Cameras */}
|
||
<Heading>Camera</Heading>
|
||
<div className="flex gap-1">
|
||
{d.cams.map((x, i) => (
|
||
<button
|
||
key={x.id}
|
||
onClick={() => setCam(i)}
|
||
className={`min-h-11 min-w-12 rounded-lg font-mono ${x.id === c.id ? "bg-fg text-ink" : "bg-white/5 text-muted"}`}
|
||
>
|
||
{x.id}
|
||
</button>
|
||
))}
|
||
{d.cams.length < 6 && (
|
||
<button
|
||
onClick={() => {
|
||
const id = String.fromCharCode(65 + d.cams.length);
|
||
setDraft({ cams: [...d.cams, { ...c, id, roll: "", clip: "" }] });
|
||
setCam(d.cams.length);
|
||
}}
|
||
className="min-h-11 min-w-12 rounded-lg bg-white/5 text-muted"
|
||
aria-label="Add camera"
|
||
>
|
||
+
|
||
</button>
|
||
)}
|
||
{d.cams.length > 1 && (
|
||
<button
|
||
onClick={() => {
|
||
if (!confirm(`Remove camera ${c.id}?`)) return;
|
||
const cams = d.cams.filter((x) => x.id !== c.id).map((x, i) => ({ ...x, id: String.fromCharCode(65 + i) }));
|
||
setDraft({ cams });
|
||
setCam(0);
|
||
}}
|
||
className="ml-auto min-h-11 px-3 text-sm text-muted"
|
||
>
|
||
Remove {c.id}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||
<Field label="Roll" value={c.roll} placeholder="A003" onChange={(v) => setCamField("roll", v.toUpperCase())} />
|
||
<Field label="Clip" value={c.clip} placeholder="A003C007" onChange={(v) => setCamField("clip", v.toUpperCase())} />
|
||
</div>
|
||
{lenses.length > 0 && (
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
{lenses.map((l) => (
|
||
<Chip key={l} on={c.lens === l} onClick={() => setCamField("lens", l)}>
|
||
{l}mm
|
||
</Chip>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="mt-2 grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||
{CAM_FIELDS.map(([k, l, mode, ph]) => (
|
||
<Field key={k} label={l} value={c[k]} placeholder={ph} inputMode={mode} onChange={(v) => setCamField(k, v)} />
|
||
))}
|
||
</div>
|
||
|
||
<Heading>VFX</Heading>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{TAGS.map((t) => (
|
||
<Chip key={t} on={d.tags.includes(t)} onClick={() => toggle("tags", t)}>
|
||
{t}
|
||
</Chip>
|
||
))}
|
||
</div>
|
||
|
||
<Heading>References shot</Heading>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{REFS.map((t) => (
|
||
<Chip key={t} on={d.refs.includes(t)} onClick={() => toggle("refs", t)} check>
|
||
{t}
|
||
</Chip>
|
||
))}
|
||
</div>
|
||
|
||
<Heading>Photos</Heading>
|
||
<div className="flex flex-wrap gap-2">
|
||
{d.photos.map((id) => (
|
||
<Thumb key={id} id={id} onRemove={() => removePhoto(id)} />
|
||
))}
|
||
<button
|
||
onClick={() => photoInput.current?.click()}
|
||
className="flex h-20 w-20 flex-col items-center justify-center rounded-lg bg-white/5 text-xs text-muted"
|
||
>
|
||
<span className="text-2xl leading-none">+</span>
|
||
Photo
|
||
</button>
|
||
</div>
|
||
<input
|
||
ref={photoInput}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
if (e.target.files?.length) addPhotos(e.target.files);
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
|
||
<Heading>Notes</Heading>
|
||
<textarea
|
||
value={d.notes}
|
||
onChange={(e) => setDraft({ notes: e.target.value })}
|
||
rows={3}
|
||
placeholder="Boom in frame at end, actor crosses marker, wind on screen…"
|
||
className="w-full rounded-xl bg-white/5 px-3 py-3 text-base outline-none placeholder:text-white/20 focus:bg-white/10"
|
||
/>
|
||
|
||
{/* Save bar */}
|
||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-ink/95 backdrop-blur">
|
||
<div className="mx-auto flex max-w-2xl gap-2 px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||
{editing ? (
|
||
<>
|
||
<button onClick={cancelEdit} className="min-h-14 flex-1 rounded-xl bg-white/5 text-muted">
|
||
Cancel
|
||
</button>
|
||
<button onClick={() => save()} className="min-h-14 flex-[2] rounded-xl bg-accent font-medium text-ink">
|
||
Update take
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button onClick={() => save(true)} className="min-h-14 flex-1 rounded-xl bg-white/10 text-sm leading-tight">
|
||
Save ·<br />new shot
|
||
</button>
|
||
<button onClick={() => save()} className="min-h-14 flex-[2] rounded-xl bg-accent font-medium text-ink">
|
||
Save · next take
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{view === "log" && <Log takes={store.takes} onEdit={edit} onDelete={remove} onExport={exportReport} />}
|
||
|
||
{view === "day" && (
|
||
<section>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{(["production", "day", "unit", "supervisor"] as const).map((k) => (
|
||
<Field
|
||
key={k}
|
||
label={k === "day" ? "Shoot day" : k[0].toUpperCase() + k.slice(1)}
|
||
value={store.meta[k]}
|
||
inputMode={k === "day" ? "decimal" : "text"}
|
||
onChange={(v) => setStore({ ...store, meta: { ...store.meta, [k]: v } })}
|
||
/>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={shareLink}
|
||
disabled={!store.takes.length}
|
||
className="mt-6 min-h-14 w-full rounded-xl bg-accent font-medium text-ink disabled:opacity-30"
|
||
>
|
||
Send report link to post
|
||
</button>
|
||
<Link
|
||
href="/set/report/view/"
|
||
className="mt-2 flex min-h-12 w-full items-center justify-center rounded-xl bg-white/10 text-sm"
|
||
>
|
||
Preview report · save PDF with photos
|
||
</Link>
|
||
<button
|
||
onClick={exportReport}
|
||
disabled={!store.takes.length}
|
||
className="mt-2 min-h-12 w-full rounded-xl bg-white/10 text-sm disabled:opacity-30"
|
||
>
|
||
Share CSV + photos
|
||
</button>
|
||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||
<button onClick={backup} className="min-h-12 rounded-xl bg-white/10 text-sm">
|
||
Backup (.json)
|
||
</button>
|
||
<button onClick={() => restoreInput.current?.click()} className="min-h-12 rounded-xl bg-white/10 text-sm">
|
||
Restore backup
|
||
</button>
|
||
</div>
|
||
<input
|
||
ref={restoreInput}
|
||
type="file"
|
||
accept="application/json,.json"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) restore(f);
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
<button onClick={newDay} className="mt-6 min-h-12 w-full rounded-xl bg-white/5 text-sm text-muted">
|
||
Start a new day (clears the log)
|
||
</button>
|
||
<p className="mt-6 text-xs leading-relaxed text-muted">
|
||
Everything saves on this device as you type and works without signal. Nothing is uploaded: the report
|
||
link carries the day's log inside it (photos are listed by name; send them with Share CSV + photos, or
|
||
send the PDF from Preview, which includes them). Keep a backup: the .json holds every take and photo, and
|
||
Restore loads it on another phone or iPad. Clearing Safari's website data erases the log.
|
||
</p>
|
||
</section>
|
||
)}
|
||
|
||
{busy && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 text-sm">{busy}</div>}
|
||
|
||
{flash && (
|
||
<div className="pointer-events-none fixed inset-x-0 bottom-28 z-40 flex justify-center px-4">
|
||
<span className="rounded-full bg-fg px-4 py-2 text-sm text-ink shadow-lg">{flash}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Log({ takes, onEdit, onDelete, onExport }: { takes: Take[]; onEdit: (t: Take) => void; onDelete: (t: Take) => void; onExport: () => void }) {
|
||
if (!takes.length) return <p className="py-16 text-center text-sm text-muted">No takes logged yet.</p>;
|
||
// Group by scene/shot, newest setup first
|
||
const groups: { key: string; takes: Take[] }[] = [];
|
||
for (const t of takes) {
|
||
const key = `${t.scene} / ${t.shot}`;
|
||
const g = groups.find((x) => x.key === key);
|
||
if (g) g.takes.push(t);
|
||
else groups.push({ key, takes: [t] });
|
||
}
|
||
groups.reverse();
|
||
return (
|
||
<section>
|
||
<button onClick={onExport} className="mb-4 min-h-12 w-full rounded-xl bg-white/10 text-sm">
|
||
Share report · CSV + photos
|
||
</button>
|
||
{groups.map((g) => (
|
||
<div key={g.key} className="mb-5">
|
||
<h2 className="mb-1.5 font-mono text-sm text-fg">{g.key}</h2>
|
||
<ul className="divide-y divide-line overflow-hidden rounded-xl bg-white/[0.04]">
|
||
{g.takes.map((t) => (
|
||
<li key={t.id} className="flex items-stretch">
|
||
<button onClick={() => onEdit(t)} className="flex min-h-14 flex-1 items-center gap-3 px-3 py-2 text-left">
|
||
<span className={`w-10 font-mono text-lg ${t.rating === "ng" ? "text-muted line-through" : ""}`}>
|
||
{t.rating === "circle" ? <span className="rounded-full border border-accent px-1.5 text-accent">{t.take}</span> : `T${t.take}`}
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm">
|
||
{t.cams.map((c) => [c.id, c.lens && `${c.lens}mm`, c.stop && `T${c.stop}`, c.clip].filter(Boolean).join(" ")).join(" · ")}
|
||
</span>
|
||
<span className="block truncate text-xs text-muted">
|
||
{clock(t.at)}
|
||
{t.tags.length > 0 && ` · ${t.tags.join(", ")}`}
|
||
{t.photos.length > 0 && ` · ${t.photos.length} photo${t.photos.length > 1 ? "s" : ""}`}
|
||
{t.notes && ` · ${t.notes}`}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
<button onClick={() => onDelete(t)} className="px-4 text-muted" aria-label={`Delete take ${t.take}`}>
|
||
×
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Thumb({ id, onRemove }: { id: string; onRemove: () => void }) {
|
||
const [url, setUrl] = useState("");
|
||
useEffect(() => {
|
||
let u = "";
|
||
getPhoto(id)
|
||
.then((b) => {
|
||
if (b) setUrl((u = URL.createObjectURL(b)));
|
||
})
|
||
.catch(() => {});
|
||
return () => {
|
||
if (u) URL.revokeObjectURL(u);
|
||
};
|
||
}, [id]);
|
||
return (
|
||
<div className="relative h-20 w-20 overflow-hidden rounded-lg bg-white/5">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
{url && <img src={url} alt="" className="h-full w-full object-cover" />}
|
||
<button
|
||
onClick={() => confirm("Remove this photo?") && onRemove()}
|
||
className="absolute top-0 right-0 flex h-8 w-8 items-center justify-center bg-black/60 text-sm"
|
||
aria-label="Remove photo"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Heading({ children }: { children: React.ReactNode }) {
|
||
return <h2 className="mt-6 mb-2 text-[11px] tracking-wider text-muted uppercase">{children}</h2>;
|
||
}
|
||
|
||
function BigField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||
return (
|
||
<label className="block min-w-0 rounded-xl bg-white/5 px-3 pt-2 pb-1 focus-within:bg-white/10">
|
||
<span className="block text-[11px] tracking-wider text-muted uppercase">{label}</span>
|
||
<input
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value.toUpperCase())}
|
||
autoCapitalize="characters"
|
||
autoComplete="off"
|
||
autoCorrect="off"
|
||
className="w-full bg-transparent font-mono text-3xl outline-none"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
inputMode = "text",
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
inputMode?: "decimal" | "text";
|
||
}) {
|
||
return (
|
||
<label className="block min-w-0 rounded-lg bg-white/5 px-3 pt-1.5 pb-1 focus-within:bg-white/10">
|
||
<span className="block truncate text-[10px] tracking-wider text-muted uppercase">{label}</span>
|
||
{/* 16px text so iOS doesn't zoom in on focus */}
|
||
<input
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
inputMode={inputMode}
|
||
autoComplete="off"
|
||
autoCorrect="off"
|
||
className="w-full bg-transparent font-mono text-base outline-none placeholder:text-white/15"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function Chip({ on, onClick, check, children }: { on: boolean; onClick: () => void; check?: boolean; children: React.ReactNode }) {
|
||
return (
|
||
<button onClick={onClick} aria-pressed={on} className={`min-h-10 rounded-full px-3.5 text-sm ${on ? "bg-fg text-ink" : "bg-white/5 text-muted"}`}>
|
||
{check && on && "✓ "}
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function StepBtn({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||
return (
|
||
<button onClick={onClick} className="flex h-11 w-9 items-center justify-center rounded-lg text-2xl text-muted active:bg-white/10">
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|