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
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue