import { useEffect, 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 Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; import { copyText } from "../lib/clipboard"; import { formatDate } from "../lib/format"; import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { Input, InnerInput } from "./ui/Input"; import { CheckRow } from "./ui/NativeControls"; import { SURFACE } from "./ui/surfaces"; import UserPicker from "./UserPicker"; const EXPIRY_DAYS = [null, 1, 7, 30] as const; // --- share with a registered user (ACL) ----------------------------------------------------- // One shared empty list while the recipients load: a fresh `[]` per render made `filtered` below // recompute on every render. const NO_RECIPIENTS: ShareRecipient[] = []; function UserShare({ job }: { job: DownloadJob }) { const { t } = useTranslation(); const recipients = useQuery({ queryKey: qk.shareRecipients(), queryFn: api.shareRecipients }); const list = recipients.data ?? NO_RECIPIENTS; 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 }) }); }, onError: (e) => notifyActionError(e, t("downloads.share.failed")), }); return (
{t("downloads.share.userSection")}

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

void recipients.refetch()} disabled={share.isPending} onPick={(u) => { if (!share.isPending) share.mutate(u.email); }} label={t("downloads.share.userSection")} placeholder={t("downloads.share.pickPlaceholder")} /> {recipients.data && list.length === 0 && (

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

)}
); } // --- share by public link ------------------------------------------------------------------- function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) { const { t, i18n } = 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, meta: { failTitle: "errors.writeFailed.shareLinkDownload" }, }); const revoke = useMutation({ mutationFn: () => api.revokeDownloadLink(link.id), onSuccess: onChanged, meta: { failTitle: "errors.writeFailed.shareLinkRevoke" }, }); // 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 }), meta: { failTitle: "errors.writeFailed.shareLinkPassword" }, 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: formatDate(link.expires_at, i18n.language) }), ); return (
{copied ? : } {/* All three on the same rung. Only Copy was migrated at first, which left one 2rem button between two 1.75rem ones with a different radius and hover fill — and, on a coarse pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive Revoke stayed small. */} { setNewPass(""); setEditPass((v) => !v); }} // On the ICON, not the button: `clsx` does not merge Tailwind, so a colour className loses // to the variant's own `text-muted`/`hover:text-fg` by stylesheet order and the state was // simply invisible. The Copy button above already does it this way. > { if ( await confirm({ title: t("downloads.share.revoke"), message: t("downloads.share.revokeConfirm"), confirmLabel: t("downloads.share.revoke"), danger: true, }) ) revoke.mutate(); }} >
{t("downloads.share.views", { count: link.view_count })} {badges.map((b) => ( {b === t("downloads.share.hasPassword") && } {b} ))}
toggle.mutate()}> {t("downloads.share.allowDownload")}
{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", )} /> {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); // Downloadable by default — matches the one-click Share (which relies on the backend // default) and the common "let them have the file" intent. The owner can uncheck it here. const [allowDownload, setAllowDownload] = useState(true); const [expiryDays, setExpiryDays] = useState(null); const [password, setPassword] = useState(""); const invalidate = () => void 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(""); }, meta: { failTitle: "downloads.share.linkFailed" }, }); // Open with a link already generated: sharing is almost always the goal, so mint a default // (no password, no expiry, downloadable) 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); // The mutation OBJECT is rebuilt every render; `mutate` is not (react-query memoises it), so the // two halves the effect actually uses can be named while `links.data` stays the only real trigger. // The autoTried ref is what makes it fire exactly once. const { mutate: createLink, isPending: createPending } = create; useEffect(() => { if (links.data && links.data.length === 0 && !autoTried.current && !createPending) { autoTried.current = true; createLink(); } }, [links.data, createLink, createPending]); 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.allowDownload")}
{t("downloads.share.expiry")}: {EXPIRY_DAYS.map((d) => ( ))}
setPassword(e.target.value)} placeholder={t("downloads.share.password")} autoComplete="new-password" />
) : ( )}
); } export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) { const { t } = useTranslation(); return (
); }