diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 5fe9fc2..d191c5d 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -19,6 +19,7 @@ import Modal from "./Modal"; import DownloadDialog from "./DownloadDialog"; import ProfileEditor from "./ProfileEditor"; import VideoEditor from "./VideoEditor"; +import ShareDialog from "./ShareDialog"; import { useConfirm } from "./ConfirmProvider"; import { useLiveQuery } from "../lib/useLiveQuery"; 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 ( - - - setEmail(e.target.value)} - placeholder={t("downloads.share.placeholder")} - className={inputCls} - autoFocus - /> -
- -
-
- ); -} - // --- Usage bar ------------------------------------------------------------------------------ function UsageBar() { @@ -541,7 +509,7 @@ export default function DownloadCenter({ me }: { me: Me }) { )} {manage && setManage(false)} />} {renameJob && setRenameJob(null)} />} - {shareJob && setShareJob(null)} />} + {shareJob && setShareJob(null)} />} {editJob && setEditJob(null)} />} ); diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx new file mode 100644 index 0000000..6bacfd1 --- /dev/null +++ b/frontend/src/components/ShareDialog.tsx @@ -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 ( +
+
+ {t("downloads.share.userSection")} +
+

{t("downloads.share.userHint")}

+
+ { + 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 && ( +
+ {filtered.map((u) => ( + + ))} +
+ )} +
+ {recipients.data && list.length === 0 && ( +

{t("downloads.share.noUsers")}

+ )} +
+ ); +} + +// --- 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 ( +
+
+ + + + +
+
+ {t("downloads.share.views", { n: link.view_count })} + {badges.map((b) => ( + + {b === t("downloads.share.hasPassword") && } + {b} + + ))} +
+ +
+
+ ); +} + +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(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 ( +
+
+ {t("downloads.share.linkSection")} +
+

{t("downloads.share.linkHint")}

+ +
+ {(links.data ?? []).map((l) => ( + + ))} + {links.data && links.data.length === 0 && !creating && ( +

{t("downloads.share.noLinks")}

+ )} +
+ + {creating ? ( +
+ +
+ {t("downloads.share.expiry")}: + {EXPIRY_DAYS.map((d) => ( + + ))} +
+ setPassword(e.target.value)} + placeholder={t("downloads.share.password")} + className={inputCls} + autoComplete="new-password" + /> +
+ + +
+
+ ) : ( + + )} +
+ ); +} + +export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) { + const { t } = useTranslation(); + return ( + +
+ +
+ +
+ + ); +} diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx new file mode 100644 index 0000000..ecec257 --- /dev/null +++ b/frontend/src/components/WatchPage.tsx @@ -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/ 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(null); + const [password, setPassword] = useState(""); + const [error, setError] = useState(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 ( +
+
+
+ + Siftlode +
+ + {status === "loading" &&
{T.loading}
} + + {status === "gone" && ( +
+
+
{T.gone}
+
{T.goneHint}
+
+
+ )} + + {status === "password" && ( +
+
+
+ {T.passwordTitle} +
+ 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 &&
{error}
} + +
+
+ )} + + {status === "ready" && meta && ( +
+
+
+
+
+

{meta.title || "Video"}

+ {meta.uploader &&
{meta.uploader}
} +
+ {meta.allow_download && ( + + {T.download} + + )} +
+
+ )} +
+
{T.brand}
+
+ ); +} diff --git a/frontend/src/i18n/locales/de/downloads.json b/frontend/src/i18n/locales/de/downloads.json index 85cdffd..6cb36a9 100644 --- a/frontend/src/i18n/locales/de/downloads.json +++ b/frontend/src/i18n/locales/de/downloads.json @@ -81,7 +81,30 @@ "label": "E-Mail des Empfängers", "placeholder": "user@example.com", "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": { "cancelTitle": "Download abbrechen?", diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 1dd3aea..c46fd2d 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -81,7 +81,30 @@ "label": "Recipient email", "placeholder": "user@example.com", "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": { "cancelTitle": "Cancel download?", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index ff5d9d9..7362d83 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -81,7 +81,30 @@ "label": "Címzett e-mail címe", "placeholder": "user@example.com", "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": { "cancelTitle": "Megszakítod a letöltést?", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1840e31..e7ae4bd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -710,6 +710,23 @@ export interface DownloadJob { 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/" — 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 { footprint_bytes: number; active_jobs: number; @@ -1073,6 +1090,22 @@ export const api = { req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }), unshareDownload: (id: number, email: string) => req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }), + // Registered users this user can share with (for the internal picker). + shareRecipients: (): Promise => req("/api/downloads/recipients"), + // Public watch links (share by link). + downloadLinks: (jobId: number): Promise => req(`/api/downloads/${jobId}/links`), + createDownloadLink: ( + jobId: number, + body: { allow_download?: boolean; expires_days?: number | null; password?: string } + ): Promise => + 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 => + 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 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 // download resolves to the session-default account and 404s for a per-tab account's file. diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 88bb2c6..8923e7d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,6 +6,7 @@ import ErrorBoundary from "./components/ErrorBoundary"; import { ConfirmProvider } from "./components/ConfirmProvider"; import PrivacyPolicy from "./components/legal/PrivacyPolicy"; import Terms from "./components/legal/Terms"; +import WatchPage from "./components/WatchPage"; import "./i18n"; import "./index.css"; @@ -19,7 +20,8 @@ const queryClient = new QueryClient({ const path = window.location.pathname; const root = path === "/privacy" ? : - path === "/terms" ? : ( + path === "/terms" ? : + path.startsWith("/watch/") ? : (