Files
ttvfx/components/set/MarkerPicker.tsx
T
twotalesanimation e6658eae3d
Deploy / deploy (push) Successful in 29s
Fix /set offline cache: save markers and page assets on first visit
The worker isn't controlling the page on the first visit, so warm-up fetches
bypassed it and no images were cached. The page now writes markers, its own
JS/CSS/fonts and /set/ straight into the cache and only then shows
'Works offline'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:14:33 +02:00

465 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { Kind, MarkerExtra, MarkerScreen } from "@/lib/markers";
type Mode = "full" | "edge";
type Showing = { src: string; label: string; detail: string; screen?: MarkerScreen; extra?: MarkerExtra };
const KIND_LABEL: Record<Kind, string> = { phone: "Phones", tablet: "Tablets", laptop: "Laptops", monitor: "Monitors" };
const MODE_KEY = "ttvfx-set-mode";
// Must match CACHE in public/set/sw.js
const OFFLINE_CACHE = "ttvfx-set-v1";
function detectScreen() {
const dpr = window.devicePixelRatio || 1;
let w = Math.round(window.screen.width * dpr);
let h = Math.round(window.screen.height * dpr);
const landscape = window.matchMedia("(orientation: landscape)").matches;
// iOS reports screen size in portrait regardless of rotation
if (landscape !== w > h) [w, h] = [h, w];
return { w, h, landscape };
}
function bestMatch(screens: MarkerScreen[], w: number, h: number) {
const pool = screens.filter((s) => s.width > s.height === w > h);
const candidates = pool.length ? pool : screens;
let best: { s: MarkerScreen; score: number } | null = null;
for (const s of candidates) {
const aspect = Math.abs(s.width / s.height - w / h);
const size = Math.abs(s.width * s.height - w * h) / (s.width * s.height);
const score = aspect * 10 + size;
if (!best || score < best.score) best = { s, score };
}
if (!best) return null;
const exact = Math.abs(best.s.width - w) <= 2 && Math.abs(best.s.height - h) <= 2;
return { screen: best.s, exact };
}
function srcFor(s: MarkerScreen, mode: Mode) {
return (mode === "edge" ? s.edge : s.full) ?? s.full ?? s.edge ?? "";
}
function Shape({ w, h, active }: { w: number; h: number; active?: boolean }) {
const scale = 34 / Math.max(w, h);
return (
<span className="flex h-10 w-10 shrink-0 items-center justify-center">
<span
className={`block rounded-[3px] border-2 ${active ? "border-accent bg-accent/20" : "border-white/60"}`}
style={{ width: Math.max(10, w * scale), height: Math.max(10, h * scale) }}
/>
</span>
);
}
// --- Browser-owned state, read via useSyncExternalStore -------------------
// The URL hash says which marker is open (#<screen-id>/<full|edge> or #<extra-id>),
// so share links, refresh and the back button all behave the same way.
const HASH_EVENT = "ttvfx-set-hash";
const MODE_EVENT = "ttvfx-set-mode";
function subscribe(events: string[]) {
return (cb: () => void) => {
events.forEach((e) => window.addEventListener(e, cb));
return () => events.forEach((e) => window.removeEventListener(e, cb));
};
}
const subscribeHash = subscribe(["popstate", "hashchange", HASH_EVENT]);
const subscribeMode = subscribe(["storage", MODE_EVENT]);
const subscribeScreen = subscribe(["resize", "orientationchange"]);
function setHash(hash: string, push: boolean) {
history[push ? "pushState" : "replaceState"]({ marker: Boolean(hash) }, "", hash || location.pathname);
window.dispatchEvent(new Event(HASH_EVENT));
}
function readMode(): Mode {
try {
return localStorage.getItem(MODE_KEY) === "edge" ? "edge" : "full";
} catch {
return "full";
}
}
function writeMode(m: Mode) {
try {
localStorage.setItem(MODE_KEY, m);
} catch {}
window.dispatchEvent(new Event(MODE_EVENT));
}
function screenKey() {
const { w, h } = detectScreen();
return w > 0 && h > 0 ? `${w}x${h}` : ""; // some browsers report 0×0 while hidden
}
function iosNeedsTip() {
const ios = /iPhone|iPod/.test(navigator.userAgent);
const standalone = window.matchMedia("(display-mode: standalone), (display-mode: fullscreen)").matches;
return ios && !standalone;
}
function requestFullscreen() {
if (document.fullscreenEnabled && !document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
}
}
export default function MarkerPicker({ screens, extras }: { screens: MarkerScreen[]; extras: MarkerExtra[] }) {
const hash = useSyncExternalStore(subscribeHash, () => location.hash, () => "");
const mode = useSyncExternalStore(subscribeMode, readMode, () => "full" as Mode);
const key = useSyncExternalStore(subscribeScreen, screenKey, () => "");
const iosTip = useSyncExternalStore(() => () => {}, iosNeedsTip, () => false);
const [offline, setOffline] = useState<"idle" | "saving" | "ready">("idle");
const device = useMemo(() => {
if (!key) return null;
const [w, h] = key.split("x").map(Number);
return { w, h };
}, [key]);
const match = useMemo(() => (device ? bestMatch(screens, device.w, device.h) : null), [device, screens]);
// What the viewer shows, derived from the hash
const showing = useMemo<(Showing & { mode: Mode }) | null>(() => {
const [id, m] = hash.slice(1).split("/");
if (!id) return null;
const viewMode: Mode = m === "edge" ? "edge" : "full";
const s = screens.find((x) => x.id === id);
if (s)
return {
src: srcFor(s, viewMode),
label: s.name,
detail: `${s.width}×${s.height}${s.landscape ? " · landscape" : ""}`,
screen: s,
mode: viewMode,
};
const e = extras.find((x) => x.id === id);
return e ? { src: e.src, label: e.name, detail: "", extra: e, mode: viewMode } : null;
}, [hash, screens, extras]);
// Leave browser full screen whenever the viewer closes (incl. back button)
useEffect(() => {
if (!showing && document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
}, [showing]);
// Offline: register the service worker, then put every marker plus this page's own
// scripts/styles/fonts straight into its cache. Writing from the page (rather than
// relying on the worker intercepting fetches) also covers the very first visit,
// before the worker controls the page.
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;
if (cancelled) return;
setOffline("saving");
const markers = [...screens.flatMap((s) => [s.full, s.edge]), ...extras.map((e) => e.src)].filter(Boolean) as string[];
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/", ...new Set([...markers, ...assets])]);
if (!cancelled) setOffline("ready");
} catch {
if (!cancelled) setOffline("idle");
}
})();
return () => {
cancelled = true;
};
}, [screens, extras]);
const showScreen = (s: MarkerScreen) => {
setHash(`#${s.id}/${mode}`, true);
requestFullscreen();
};
const showExtra = (e: MarkerExtra) => {
setHash(`#${e.id}`, true);
requestFullscreen();
};
const close = useCallback(() => {
if (history.state?.marker) history.back();
else setHash("", false);
}, []);
const switchMode = useCallback(
(m: Mode) => {
writeMode(m);
if (showing?.screen) setHash(`#${showing.screen.id}/${m}`, false);
},
[showing],
);
const chooseMode = writeMode;
const grouped = useMemo(() => {
const out = new Map<Kind, MarkerScreen[]>();
for (const s of screens) out.set(s.kind, [...(out.get(s.kind) ?? []), s]);
return [...out.entries()];
}, [screens]);
return (
<div className="mx-auto max-w-2xl px-4 pt-6 pb-24">
<header className="flex items-start justify-between gap-4">
<div>
<p className="font-mono text-[11px] tracking-[0.25em] text-accent uppercase">Two Tales VFX · On set</p>
<h1 className="mt-1 font-display text-3xl font-light tracking-wide uppercase">Screen markers</h1>
</div>
<p className="pt-1 text-right font-mono text-[11px] leading-relaxed text-muted">
{offline === "ready" ? "✓ Works offline" : offline === "saving" ? "Saving for offline…" : ""}
</p>
</header>
{/* Marker style */}
<div className="mt-6 grid grid-cols-2 gap-2 rounded-xl bg-white/5 p-1.5" role="radiogroup" aria-label="Marker style">
{(
[
["full", "Full grid", "Dots across the whole screen"],
["edge", "Edges only", "Clear centre for actors' hands"],
] as const
).map(([m, label, hint]) => (
<button
key={m}
type="button"
role="radio"
aria-checked={mode === m}
onClick={() => chooseMode(m)}
className={`min-h-16 rounded-lg px-3 py-2.5 text-left transition-colors ${
mode === m ? "bg-fg text-ink" : "text-fg hover:bg-white/5"
}`}
>
<span className="block text-base font-medium">{label}</span>
<span className={`block text-xs ${mode === m ? "text-ink/70" : "text-muted"}`}>{hint}</span>
</button>
))}
</div>
{/* This screen */}
{match && device && (
<section className="mt-6">
<h2 className="mb-2 font-mono text-[11px] tracking-[0.25em] text-muted uppercase">This screen</h2>
<button
type="button"
onClick={() => showScreen(match.screen)}
className="flex min-h-24 w-full items-center gap-4 rounded-xl border-2 border-accent bg-accent/10 px-4 py-4 text-left active:bg-accent/25"
>
<Shape w={match.screen.width} h={match.screen.height} active />
<span className="min-w-0 flex-1">
<span className="block text-xl font-medium">{match.screen.name}</span>
<span className="block font-mono text-xs text-muted">
{match.screen.width}×{match.screen.height}
{match.screen.landscape ? " · landscape" : ""} ·{" "}
{match.exact ? "exact match" : `closest to ${device.w}×${device.h}`}
</span>
</span>
<span className="shrink-0 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-ink">Show</span>
</button>
</section>
)}
{iosTip && (
<p className="mt-4 rounded-lg bg-white/5 px-4 py-3 text-sm leading-relaxed text-muted">
<span className="text-fg">iPhone tip:</span> Safari can&apos;t hide its bars. For true full screen tap{" "}
<span className="text-fg">Share → Add to Home Screen</span> once, then open “Markers” from the home screen.
</p>
)}
{/* All screens */}
{grouped.map(([kind, list]) => (
<section key={kind} className="mt-8">
<h2 className="mb-2 font-mono text-[11px] tracking-[0.25em] text-muted uppercase">{KIND_LABEL[kind]}</h2>
<ul className="divide-y divide-white/10 overflow-hidden rounded-xl bg-white/[0.04]">
{list.map((s) => {
const isMatch = match?.screen.id === s.id;
return (
<li key={s.id}>
<button
type="button"
onClick={() => showScreen(s)}
className="flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left active:bg-white/10"
>
<Shape w={s.width} h={s.height} active={isMatch} />
<span className="min-w-0 flex-1">
<span className="block text-base">{s.name}</span>
<span className="block font-mono text-xs text-muted">
{s.width}×{s.height}
{s.landscape ? " · landscape" : " · portrait"}
</span>
</span>
{isMatch && <span className="font-mono text-[10px] tracking-widest text-accent uppercase">This</span>}
<span className="text-xl text-muted" aria-hidden="true">
›
</span>
</button>
</li>
);
})}
</ul>
</section>
))}
{extras.length > 0 && (
<section className="mt-8">
<h2 className="mb-2 font-mono text-[11px] tracking-[0.25em] text-muted uppercase">Other</h2>
<ul className="divide-y divide-white/10 overflow-hidden rounded-xl bg-white/[0.04]">
{extras.map((e) => (
<li key={e.id}>
<button
type="button"
onClick={() => showExtra(e)}
className="flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left active:bg-white/10"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center">
<span className="block h-7 w-7 rounded-[3px] bg-[#808080]" />
</span>
<span className="flex-1 text-base">{e.name}</span>
<span className="text-xl text-muted" aria-hidden="true">
›
</span>
</button>
</li>
))}
</ul>
</section>
)}
<p className="mt-10 text-center text-xs leading-relaxed text-muted">
Tap the image for controls · Back or Esc to close · Screen stays awake while showing
</p>
{showing && (
<Viewer showing={showing} mode={showing.mode} onMode={switchMode} onClose={close} />
)}
</div>
);
}
function Viewer({
showing,
mode,
onMode,
onClose,
}: {
showing: Showing;
mode: Mode;
onMode: (m: Mode) => void;
onClose: () => void;
}) {
const [controls, setControls] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const hideTimer = useRef<number | undefined>(undefined);
// Stretch to fill when the image's shape is close to the visible area (markers sit on
// the real screen edges); otherwise fit it whole so the dots stay round.
const viewport = useSyncExternalStore(subscribeScreen, () => `${innerWidth}x${innerHeight}`, () => "1x1");
const fit = useMemo(() => {
if (!showing.screen) return "object-cover";
const [vw, vh] = viewport.split("x").map(Number);
const ratio = showing.screen.width / showing.screen.height / (vw / vh);
return Math.abs(ratio - 1) <= 0.15 ? "object-fill" : "object-contain";
}, [showing.screen, viewport]);
const canFullscreen = typeof document !== "undefined" && document.fullscreenEnabled;
// Keep the screen awake while a marker is up
useEffect(() => {
let lock: WakeLockSentinel | null = null;
const acquire = async () => {
try {
lock = (await navigator.wakeLock?.request("screen")) ?? null;
} catch {}
};
const onVisible = () => document.visibilityState === "visible" && acquire();
acquire();
document.addEventListener("visibilitychange", onVisible);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("visibilitychange", onVisible);
document.body.style.overflow = "";
lock?.release().catch(() => {});
};
}, []);
useEffect(() => {
const onFs = () => setFullscreen(Boolean(document.fullscreenElement));
onFs();
document.addEventListener("fullscreenchange", onFs);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !document.fullscreenElement) onClose();
if (e.key === "f") onMode("full");
if (e.key === "e") onMode("edge");
if (e.key === " ") setControls((v) => !v);
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("fullscreenchange", onFs);
document.removeEventListener("keydown", onKey);
};
}, [onClose, onMode]);
const poke = () => {
window.clearTimeout(hideTimer.current);
setControls((v) => {
if (!v) hideTimer.current = window.setTimeout(() => setControls(false), 4000);
return !v;
});
};
return (
<div
className={`fixed inset-0 z-[100] bg-black ${controls ? "" : "cursor-none"}`}
onClick={() => {
if (canFullscreen && !document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(() => {});
poke();
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={showing.src} alt={showing.label} className={`h-full w-full select-none ${fit}`} draggable={false} />
{canFullscreen && !fullscreen && !controls && (
<p className="pointer-events-none absolute inset-x-0 bottom-6 text-center font-mono text-xs text-white/70">
Tap for full screen
</p>
)}
{controls && (
<div
className="absolute inset-x-0 top-0 flex flex-wrap items-center gap-2 bg-black/85 px-3 pt-[max(0.75rem,env(safe-area-inset-top))] pb-3"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="min-h-12 rounded-lg bg-white/10 px-4 text-sm font-medium text-fg active:bg-white/20"
>
✕ Close
</button>
{showing.screen && (
<div className="flex rounded-lg bg-white/10 p-1">
{(["full", "edge"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => onMode(m)}
className={`min-h-10 rounded-md px-3 text-sm ${mode === m ? "bg-fg text-ink" : "text-fg"}`}
>
{m === "full" ? "Full grid" : "Edges"}
</button>
))}
</div>
)}
<span className="min-w-0 flex-1 truncate px-1 text-right font-mono text-xs text-white/70">
{showing.label} {showing.detail && `· ${showing.detail}`}
</span>
<a
href={showing.src}
download
className="flex min-h-12 items-center rounded-lg bg-white/10 px-4 text-sm text-fg active:bg-white/20"
>
Save
</a>
</div>
)}
</div>
);
}