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
|
|
@ -19,6 +19,7 @@ import Modal from "./Modal";
|
||||||
import DownloadDialog from "./DownloadDialog";
|
import DownloadDialog from "./DownloadDialog";
|
||||||
import ProfileEditor from "./ProfileEditor";
|
import ProfileEditor from "./ProfileEditor";
|
||||||
import VideoEditor from "./VideoEditor";
|
import VideoEditor from "./VideoEditor";
|
||||||
|
import ShareDialog from "./ShareDialog";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { api, type DownloadJob, type Me } from "../lib/api";
|
import { api, type DownloadJob, type Me } from "../lib/api";
|
||||||
|
|
@ -186,39 +187,6 @@ function RenameModal({ job, onClose }: { job: DownloadJob; onClose: () => void }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShareModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const share = useMutation({
|
|
||||||
mutationFn: () => api.shareDownload(job.id, email.trim()),
|
|
||||||
onSuccess: (r) => {
|
|
||||||
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<Modal title={t("downloads.share.title")} onClose={onClose}>
|
|
||||||
<label className="block text-sm font-medium mb-1">{t("downloads.share.label")}</label>
|
|
||||||
<input
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder={t("downloads.share.placeholder")}
|
|
||||||
className={inputCls}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end mt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => share.mutate()}
|
|
||||||
disabled={!email.trim() || share.isPending}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
|
||||||
>
|
|
||||||
{t("downloads.share.submit")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Usage bar ------------------------------------------------------------------------------
|
// --- Usage bar ------------------------------------------------------------------------------
|
||||||
|
|
||||||
function UsageBar() {
|
function UsageBar() {
|
||||||
|
|
@ -541,7 +509,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
||||||
)}
|
)}
|
||||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
|
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
|
||||||
{shareJob && <ShareModal job={shareJob} onClose={() => setShareJob(null)} />}
|
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
255
frontend/src/components/ShareDialog.tsx
Normal file
255
frontend/src/components/ShareDialog.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import { api, type DownloadJob, type ShareLink } from "../lib/api";
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
|
||||||
|
const btn = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
|
||||||
|
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||||
|
|
||||||
|
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||||
|
|
||||||
|
function UserShare({ job }: { job: DownloadJob }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const recipients = useQuery({ queryKey: ["share-recipients"], queryFn: api.shareRecipients });
|
||||||
|
const list = recipients.data ?? [];
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const s = q.trim().toLowerCase();
|
||||||
|
if (!s) return list.slice(0, 8);
|
||||||
|
return list
|
||||||
|
.filter((u) => u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s))
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [q, list]);
|
||||||
|
|
||||||
|
const share = useMutation({
|
||||||
|
mutationFn: (email: string) => api.shareDownload(job.id, email),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
|
||||||
|
setQ("");
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => notify({ level: "error", message: t("downloads.share.failed") }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium mb-1">
|
||||||
|
<UserPlus className="w-4 h-4 text-muted" /> {t("downloads.share.userSection")}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted mb-2">{t("downloads.share.userHint")}</p>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQ(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
// Close on blur, but after a tick so a click on a dropdown row still registers.
|
||||||
|
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||||
|
placeholder={t("downloads.share.pickPlaceholder")}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
{open && filtered.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-xl overflow-hidden">
|
||||||
|
{filtered.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => share.mutate(u.email)}
|
||||||
|
disabled={share.isPending}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
||||||
|
>
|
||||||
|
<span className="truncate">{u.display_name || u.email}</span>
|
||||||
|
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{recipients.data && list.length === 0 && (
|
||||||
|
<p className="text-xs text-muted mt-1">{t("downloads.share.noUsers")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- share by public link -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const fullUrl = window.location.origin + link.url;
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(fullUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
notify({ level: "error", message: t("downloads.share.copyFailed") });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const toggle = useMutation({
|
||||||
|
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
});
|
||||||
|
const revoke = useMutation({
|
||||||
|
mutationFn: () => api.revokeDownloadLink(link.id),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
const badges: string[] = [];
|
||||||
|
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
|
||||||
|
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
|
||||||
|
if (link.expires_at) badges.push(t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-card/40 p-2.5 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="w-4 h-4 text-muted shrink-0" />
|
||||||
|
<input readOnly value={fullUrl} className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-none" />
|
||||||
|
<button onClick={copy} title={t("downloads.share.copy")} className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition">
|
||||||
|
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (await confirm({ title: t("downloads.share.revoke"), message: t("downloads.share.revokeConfirm"), confirmLabel: t("downloads.share.revoke"), danger: true }))
|
||||||
|
revoke.mutate();
|
||||||
|
}}
|
||||||
|
title={t("downloads.share.revoke")}
|
||||||
|
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-red-400 transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
|
||||||
|
<span className="inline-flex items-center gap-1"><Eye className="w-3 h-3" /> {t("downloads.share.views", { n: link.view_count })}</span>
|
||||||
|
{badges.map((b) => (
|
||||||
|
<span key={b} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-surface">
|
||||||
|
{b === t("downloads.share.hasPassword") && <Lock className="w-3 h-3" />}
|
||||||
|
{b}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<label className="inline-flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={link.allow_download} onChange={() => toggle.mutate()} />
|
||||||
|
{t("downloads.share.allowDownload")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkShare({ job }: { job: DownloadJob }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const links = useQuery({ queryKey: ["download-links", job.id], queryFn: () => api.downloadLinks(job.id) });
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [allowDownload, setAllowDownload] = useState(false);
|
||||||
|
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["download-links", job.id] });
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.createDownloadLink(job.id, {
|
||||||
|
allow_download: allowDownload,
|
||||||
|
expires_days: expiryDays,
|
||||||
|
password: password.trim() || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
setCreating(false);
|
||||||
|
setAllowDownload(false);
|
||||||
|
setExpiryDays(null);
|
||||||
|
setPassword("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium mb-1">
|
||||||
|
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted mb-2">{t("downloads.share.linkHint")}</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(links.data ?? []).map((l) => (
|
||||||
|
<LinkRow key={l.id} link={l} onChanged={invalidate} />
|
||||||
|
))}
|
||||||
|
{links.data && links.data.length === 0 && !creating && (
|
||||||
|
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating ? (
|
||||||
|
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" checked={allowDownload} onChange={(e) => setAllowDownload(e.target.checked)} />
|
||||||
|
{t("downloads.share.allowDownload")}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-muted">{t("downloads.share.expiry")}:</span>
|
||||||
|
{EXPIRY_DAYS.map((d) => (
|
||||||
|
<button
|
||||||
|
key={String(d)}
|
||||||
|
onClick={() => setExpiryDays(d)}
|
||||||
|
className={clsx(
|
||||||
|
"px-2 py-1 rounded-md border text-xs transition",
|
||||||
|
expiryDays === d ? "border-accent bg-accent/10 text-accent" : "border-border text-muted hover:text-fg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{d === null ? t("downloads.share.expiryNever") : t("downloads.share.days", { n: d })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t("downloads.share.password")}
|
||||||
|
className={inputCls}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button onClick={() => setCreating(false)} className={clsx(btn, "text-muted hover:text-fg hover:bg-surface")}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => create.mutate()} disabled={create.isPending} className={clsx(btn, "bg-accent text-accent-fg hover:opacity-90")}>
|
||||||
|
{t("downloads.share.createLink")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className={clsx(btn, "mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5")}
|
||||||
|
>
|
||||||
|
<Link2 className="w-4 h-4" /> {t("downloads.share.newLink")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.share.title")} onClose={onClose}>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<UserShare job={job} />
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<LinkShare job={job} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -81,7 +81,30 @@
|
||||||
"label": "E-Mail des Empfängers",
|
"label": "E-Mail des Empfängers",
|
||||||
"placeholder": "user@example.com",
|
"placeholder": "user@example.com",
|
||||||
"submit": "Teilen",
|
"submit": "Teilen",
|
||||||
"done": "Geteilt mit {{email}}"
|
"done": "Geteilt mit {{email}}",
|
||||||
|
"failed": "Teilen fehlgeschlagen.",
|
||||||
|
"userSection": "Mit einem Nutzer teilen",
|
||||||
|
"userHint": "Erscheint bei ihm unter „Mit mir geteilt”.",
|
||||||
|
"pickPlaceholder": "Nutzer suchen…",
|
||||||
|
"noUsers": "Keine weiteren Nutzer.",
|
||||||
|
"linkSection": "Per Link teilen",
|
||||||
|
"linkHint": "Jeder mit dem Link kann zusehen — ohne Konto.",
|
||||||
|
"noLinks": "Noch keine Links.",
|
||||||
|
"newLink": "Link erstellen",
|
||||||
|
"createLink": "Erstellen",
|
||||||
|
"allowDownload": "Download erlauben",
|
||||||
|
"expiry": "Läuft ab",
|
||||||
|
"expiryNever": "Nie",
|
||||||
|
"days": "{{n}} Tage",
|
||||||
|
"password": "Passwort (optional)",
|
||||||
|
"copy": "Link kopieren",
|
||||||
|
"copyFailed": "Kopieren fehlgeschlagen.",
|
||||||
|
"revoke": "Widerrufen",
|
||||||
|
"revokeConfirm": "Der Link funktioniert sofort nicht mehr.",
|
||||||
|
"views": "{{n}} Aufrufe",
|
||||||
|
"expiresOn": "läuft ab {{date}}",
|
||||||
|
"hasPassword": "Passwort",
|
||||||
|
"downloadable": "herunterladbar"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"cancelTitle": "Download abbrechen?",
|
"cancelTitle": "Download abbrechen?",
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,30 @@
|
||||||
"label": "Recipient email",
|
"label": "Recipient email",
|
||||||
"placeholder": "user@example.com",
|
"placeholder": "user@example.com",
|
||||||
"submit": "Share",
|
"submit": "Share",
|
||||||
"done": "Shared with {{email}}"
|
"done": "Shared with {{email}}",
|
||||||
|
"failed": "Couldn’t share.",
|
||||||
|
"userSection": "Share with a user",
|
||||||
|
"userHint": "They’ll find it under “Shared with me”.",
|
||||||
|
"pickPlaceholder": "Search a user…",
|
||||||
|
"noUsers": "No other users yet.",
|
||||||
|
"linkSection": "Share a link",
|
||||||
|
"linkHint": "Anyone with the link can watch — no account needed.",
|
||||||
|
"noLinks": "No links yet.",
|
||||||
|
"newLink": "Create a link",
|
||||||
|
"createLink": "Create link",
|
||||||
|
"allowDownload": "Allow download",
|
||||||
|
"expiry": "Expires",
|
||||||
|
"expiryNever": "Never",
|
||||||
|
"days": "{{n}} days",
|
||||||
|
"password": "Password (optional)",
|
||||||
|
"copy": "Copy link",
|
||||||
|
"copyFailed": "Couldn’t copy.",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"revokeConfirm": "This link will stop working immediately.",
|
||||||
|
"views": "{{n}} views",
|
||||||
|
"expiresOn": "expires {{date}}",
|
||||||
|
"hasPassword": "password",
|
||||||
|
"downloadable": "downloadable"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"cancelTitle": "Cancel download?",
|
"cancelTitle": "Cancel download?",
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,30 @@
|
||||||
"label": "Címzett e-mail címe",
|
"label": "Címzett e-mail címe",
|
||||||
"placeholder": "user@example.com",
|
"placeholder": "user@example.com",
|
||||||
"submit": "Megosztás",
|
"submit": "Megosztás",
|
||||||
"done": "Megosztva vele: {{email}}"
|
"done": "Megosztva vele: {{email}}",
|
||||||
|
"failed": "A megosztás nem sikerült.",
|
||||||
|
"userSection": "Megosztás felhasználóval",
|
||||||
|
"userHint": "A „Velem megosztott” fülön találja meg.",
|
||||||
|
"pickPlaceholder": "Keress egy felhasználót…",
|
||||||
|
"noUsers": "Nincs más felhasználó.",
|
||||||
|
"linkSection": "Megosztás linkkel",
|
||||||
|
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
|
||||||
|
"noLinks": "Még nincs link.",
|
||||||
|
"newLink": "Link létrehozása",
|
||||||
|
"createLink": "Létrehozás",
|
||||||
|
"allowDownload": "Letöltés engedélyezése",
|
||||||
|
"expiry": "Lejárat",
|
||||||
|
"expiryNever": "Soha",
|
||||||
|
"days": "{{n}} nap",
|
||||||
|
"password": "Jelszó (opcionális)",
|
||||||
|
"copy": "Link másolása",
|
||||||
|
"copyFailed": "Nem sikerült a másolás.",
|
||||||
|
"revoke": "Visszavonás",
|
||||||
|
"revokeConfirm": "A link azonnal használhatatlanná válik.",
|
||||||
|
"views": "{{n}} megtekintés",
|
||||||
|
"expiresOn": "lejár: {{date}}",
|
||||||
|
"hasPassword": "jelszó",
|
||||||
|
"downloadable": "letölthető"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"cancelTitle": "Megszakítod a letöltést?",
|
"cancelTitle": "Megszakítod a letöltést?",
|
||||||
|
|
|
||||||
|
|
@ -710,6 +710,23 @@ export interface DownloadJob {
|
||||||
user_email?: string;
|
user_email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShareRecipient {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
display_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareLink {
|
||||||
|
id: number;
|
||||||
|
token: string;
|
||||||
|
url: string; // path like "/watch/<token>" — prepend window.location.origin to share
|
||||||
|
allow_download: boolean;
|
||||||
|
has_password: boolean;
|
||||||
|
expires_at: string | null;
|
||||||
|
view_count: number;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadUsage {
|
export interface DownloadUsage {
|
||||||
footprint_bytes: number;
|
footprint_bytes: number;
|
||||||
active_jobs: number;
|
active_jobs: number;
|
||||||
|
|
@ -1073,6 +1090,22 @@ export const api = {
|
||||||
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
|
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
|
||||||
unshareDownload: (id: number, email: string) =>
|
unshareDownload: (id: number, email: string) =>
|
||||||
req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }),
|
req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }),
|
||||||
|
// Registered users this user can share with (for the internal picker).
|
||||||
|
shareRecipients: (): Promise<ShareRecipient[]> => req("/api/downloads/recipients"),
|
||||||
|
// Public watch links (share by link).
|
||||||
|
downloadLinks: (jobId: number): Promise<ShareLink[]> => req(`/api/downloads/${jobId}/links`),
|
||||||
|
createDownloadLink: (
|
||||||
|
jobId: number,
|
||||||
|
body: { allow_download?: boolean; expires_days?: number | null; password?: string }
|
||||||
|
): Promise<ShareLink> =>
|
||||||
|
req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
updateDownloadLink: (
|
||||||
|
linkId: number,
|
||||||
|
patch: { allow_download?: boolean; expires_days?: number | null; password?: string }
|
||||||
|
): Promise<ShareLink> =>
|
||||||
|
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
|
||||||
|
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
|
||||||
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
|
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
|
||||||
// account via ?account= (same wallet-gated selection the WebSocket uses) — otherwise the
|
// account via ?account= (same wallet-gated selection the WebSocket uses) — otherwise the
|
||||||
// download resolves to the session-default account and 404s for a per-tab account's file.
|
// download resolves to the session-default account and 404s for a per-tab account's file.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||||
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
||||||
import Terms from "./components/legal/Terms";
|
import Terms from "./components/legal/Terms";
|
||||||
|
import WatchPage from "./components/WatchPage";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
|
@ -19,7 +20,8 @@ const queryClient = new QueryClient({
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const root =
|
const root =
|
||||||
path === "/privacy" ? <PrivacyPolicy /> :
|
path === "/privacy" ? <PrivacyPolicy /> :
|
||||||
path === "/terms" ? <Terms /> : (
|
path === "/terms" ? <Terms /> :
|
||||||
|
path.startsWith("/watch/") ? <WatchPage /> : (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue