feat(downloads): share UI — user picker + public watch links + watch page
Rework the share dialog into two clear modes and add the public /watch player page: - ShareDialog: (A) 'Share with a user' — autocomplete picker over registered users (was a blind email box that 404'd on non-users); (B) 'Share a link' — create/list/copy/revoke public links with allow-download toggle, optional expiry (1/7/30d), optional password; per-link view count. - WatchPage: standalone login-free player at /watch/<token> (routed in main.tsx like /privacy), self-contained mini-i18n (en/hu/de by browser language); password gate → unlock → play; shows a Download button only when the link allows it. - api: ShareLink/ShareRecipient types + link CRUD + recipients; share i18n (en/hu/de). Verified end-to-end in a real browser: user picker, link create, public playback, stream-only vs downloadable, password gate + unlock, no console errors.
This commit is contained in:
parent
d672583830
commit
391b8fda33
8 changed files with 543 additions and 38 deletions
178
frontend/src/components/WatchPage.tsx
Normal file
178
frontend/src/components/WatchPage.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Download, Lock, PlayCircle } from "lucide-react";
|
||||
|
||||
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
||||
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
||||
// self-contained i18n dict (the public viewer has no app language preference).
|
||||
|
||||
type Meta = {
|
||||
needs_password: boolean;
|
||||
title?: string | null;
|
||||
uploader?: string | null;
|
||||
duration_s?: number | null;
|
||||
allow_download?: boolean;
|
||||
file_url?: string;
|
||||
};
|
||||
|
||||
const STR = {
|
||||
en: {
|
||||
loading: "Loading…",
|
||||
gone: "This link is no longer available.",
|
||||
goneHint: "It may have expired or been revoked by the owner.",
|
||||
passwordTitle: "This video is password-protected",
|
||||
passwordPlaceholder: "Enter password",
|
||||
watch: "Watch",
|
||||
wrong: "Wrong password.",
|
||||
download: "Download",
|
||||
brand: "Shared via Siftlode",
|
||||
},
|
||||
hu: {
|
||||
loading: "Betöltés…",
|
||||
gone: "Ez a link már nem elérhető.",
|
||||
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
|
||||
passwordTitle: "Ez a videó jelszóval védett",
|
||||
passwordPlaceholder: "Add meg a jelszót",
|
||||
watch: "Megnézem",
|
||||
wrong: "Hibás jelszó.",
|
||||
download: "Letöltés",
|
||||
brand: "Megosztva a Siftlode-dal",
|
||||
},
|
||||
de: {
|
||||
loading: "Wird geladen…",
|
||||
gone: "Dieser Link ist nicht mehr verfügbar.",
|
||||
goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.",
|
||||
passwordTitle: "Dieses Video ist passwortgeschützt",
|
||||
passwordPlaceholder: "Passwort eingeben",
|
||||
watch: "Ansehen",
|
||||
wrong: "Falsches Passwort.",
|
||||
download: "Herunterladen",
|
||||
brand: "Geteilt über Siftlode",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const lang = ((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
|
||||
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
|
||||
: "en";
|
||||
const T = STR[lang];
|
||||
|
||||
export default function WatchPage() {
|
||||
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone">("loading");
|
||||
const [meta, setMeta] = useState<Meta | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus("gone");
|
||||
return;
|
||||
}
|
||||
fetch(`/api/public/watch/${encodeURIComponent(token)}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
|
||||
.then((m: Meta) => {
|
||||
if (m.needs_password) setStatus("password");
|
||||
else {
|
||||
setMeta(m);
|
||||
setStatus("ready");
|
||||
}
|
||||
})
|
||||
.catch(() => setStatus("gone"));
|
||||
}, [token]);
|
||||
|
||||
const unlock = async () => {
|
||||
setUnlocking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetch(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (r.status === 403) {
|
||||
setError(T.wrong);
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
setStatus("gone");
|
||||
return;
|
||||
}
|
||||
setMeta(await r.json());
|
||||
setStatus("ready");
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0b0f14] text-slate-100 flex flex-col items-center">
|
||||
<div className="w-full max-w-4xl px-4 py-6 flex-1 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-5 text-sm text-slate-400">
|
||||
<PlayCircle className="w-5 h-5 text-teal-400" />
|
||||
<span className="font-semibold text-slate-200">Siftlode</span>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <div className="flex-1 grid place-items-center text-slate-400">{T.loading}</div>}
|
||||
|
||||
{status === "gone" && (
|
||||
<div className="flex-1 grid place-items-center text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold">{T.gone}</div>
|
||||
<div className="text-sm text-slate-400 mt-1">{T.goneHint}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "password" && (
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-slate-900/60 border border-slate-700 p-6">
|
||||
<div className="flex items-center gap-2 font-medium mb-4">
|
||||
<Lock className="w-4 h-4 text-teal-400" /> {T.passwordTitle}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
autoFocus
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
|
||||
placeholder={T.passwordPlaceholder}
|
||||
className="w-full rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-teal-500/40"
|
||||
/>
|
||||
{error && <div className="text-sm text-red-400 mt-2">{error}</div>}
|
||||
<button
|
||||
onClick={unlock}
|
||||
disabled={!password || unlocking}
|
||||
className="mt-4 w-full rounded-lg bg-teal-600 hover:bg-teal-500 disabled:opacity-50 py-2 text-sm font-medium transition"
|
||||
>
|
||||
{T.watch}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && meta && (
|
||||
<div>
|
||||
<div className="rounded-xl overflow-hidden bg-black border border-slate-800">
|
||||
<video controls playsInline src={meta.file_url} className="w-full max-h-[75vh] mx-auto block" />
|
||||
</div>
|
||||
<div className="mt-3 flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
|
||||
{meta.uploader && <div className="text-sm text-slate-400">{meta.uploader}</div>}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 px-3 py-2 text-sm font-medium transition"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {T.download}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 py-4">{T.brand}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue