import { useEffect, useMemo, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; 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 { copyText } from "../lib/clipboard"; import { api, type DownloadJob, type ShareLink } from "../lib/api"; import { inputCls, btnCls } from "./ui/form"; 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: qk.shareRecipients(), 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 [editPass, setEditPass] = useState(false); const [newPass, setNewPass] = useState(""); const fullUrl = window.location.origin + link.url; const copy = async () => { if (await copyText(fullUrl)) { setCopied(true); setTimeout(() => setCopied(false), 1500); } else { 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, }); // Set / change / clear the link password. Rotating (or clearing) it bumps the link's // password_version server-side, which invalidates any watch grant issued under the old password. const savePass = useMutation({ mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }), onSuccess: () => { setEditPass(false); setNewPass(""); 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} ))}
{editPass && (
setNewPass(e.target.value)} onKeyDown={(e) => e.key === "Enter" && newPass.trim() && savePass.mutate(newPass.trim()) } placeholder={t( link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword", )} className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> {link.has_password && ( )}
)}
); } function LinkShare({ job }: { job: DownloadJob }) { const { t } = useTranslation(); const qc = useQueryClient(); const links = useQuery({ queryKey: qk.downloadLinks(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: qk.downloadLinks(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(""); }, }); // Open with a link already generated: sharing is almost always the goal, so mint a default // (no password, no expiry, view-only) link the moment the dialog finds none — as if the user // had pressed "New link" → "Create". All further controls (password, download toggle, expiry, // extra links, revoke) stay available on the created link and via the "New link" button. const autoTried = useRef(false); useEffect(() => { if (links.data && links.data.length === 0 && !autoTried.current && !create.isPending) { autoTried.current = true; create.mutate(); } // Only links.data (empty → auto-create once) should drive this; `create` is a fresh object every // render and would re-run the effect needlessly. The autoTried ref makes it fire exactly once. // eslint-disable-next-line react-hooks/exhaustive-deps }, [links.data]); return (
{t("downloads.share.linkSection")}

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

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

{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 (
); }