7150c3f3d2
Deploy / deploy (push) Successful in 39s
- nginx basic auth on /drops/ (page and clips); login file and clips live on the server only (docker/auth, docker/drops), never in git or the image - Fails closed: missing login file returns 500; no-store, noindex - Page reads manifest.json from the server, shows "taken down" when empty - Player hides download/cast/PiP, blocks right-click, confidential overlay - DEPLOY.md step 8: how to share a drop and take it down Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
3.1 KiB
TypeScript
92 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
// Served by nginx from the server's docker/drops/ folder, behind the same login as the page.
|
|
const base = "/drops/files/";
|
|
|
|
type Drop = {
|
|
title: string;
|
|
note?: string;
|
|
files: { name: string; src: string }[];
|
|
};
|
|
|
|
export default function DropViewer() {
|
|
const [drop, setDrop] = useState<Drop | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetch(`${base}manifest.json`, { cache: "no-store" })
|
|
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
|
|
.then(setDrop)
|
|
.catch(() => setFailed(true));
|
|
}, []);
|
|
|
|
if (failed) {
|
|
return (
|
|
<section className="mx-auto max-w-6xl px-4 pt-16 md:px-8 md:pt-24">
|
|
<p className="font-mono text-xs tracking-[0.3em] text-accent uppercase">Nothing here</p>
|
|
<p className="mt-5 max-w-2xl text-xl leading-relaxed font-light text-muted">
|
|
These previews have been taken down. Ask your Two Tales contact if you still need them.
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (!drop) {
|
|
return <p className="mx-auto max-w-6xl px-4 pt-16 font-mono text-xs text-muted uppercase md:px-8">Loading…</p>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<section className="mx-auto max-w-6xl px-4 pt-16 md:px-8 md:pt-24">
|
|
<p className="font-mono text-xs tracking-[0.3em] text-accent uppercase">Confidential · view only</p>
|
|
<h1 className="mt-5 font-display text-[clamp(2rem,7vw,4.5rem)] leading-[0.9] font-thin uppercase">
|
|
{drop.title}
|
|
</h1>
|
|
{drop.note && <p className="mt-6 max-w-3xl text-lg leading-relaxed font-light text-muted">{drop.note}</p>}
|
|
</section>
|
|
|
|
<ol className="mx-auto mt-12 max-w-6xl space-y-12 px-4 md:mt-16 md:px-8">
|
|
{drop.files.map((f, i) => (
|
|
<li key={f.src}>
|
|
<div className="mb-3 flex items-center gap-4 font-mono text-xs tracking-[0.2em] uppercase">
|
|
<span className="text-accent">{String(i + 1).padStart(2, "0")}</span>
|
|
<span className="h-px w-10 bg-line" />
|
|
<span className="break-all text-fg">{f.name}</span>
|
|
</div>
|
|
<Player src={`${base}${f.src}`} label={f.name} />
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Plays inline with no download, cast or picture-in-picture options and no right-click menu.
|
|
// This only stops casual saving: anyone who can watch can still screen-record.
|
|
function Player({ src, label }: { src: string; label: string }) {
|
|
return (
|
|
<div className="relative overflow-hidden border border-line bg-black" onContextMenu={(e) => e.preventDefault()}>
|
|
<video
|
|
src={src}
|
|
controls
|
|
loop
|
|
playsInline
|
|
preload="metadata"
|
|
controlsList="nodownload noremoteplayback noplaybackrate"
|
|
disablePictureInPicture
|
|
disableRemotePlayback
|
|
className="block aspect-video w-full"
|
|
aria-label={label}
|
|
/>
|
|
<span
|
|
className="pointer-events-none absolute top-3 right-3 font-mono text-[10px] tracking-[0.25em] text-white/40 uppercase select-none md:text-xs"
|
|
aria-hidden="true"
|
|
>
|
|
Two Tales VFX · Confidential
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|