Add a paint-only .btn-primary recipe (solid accent bg + accent-fg text + hover:opacity dim) in index.css @layer components, alongside the selection-paint recipes (.pill/.chip/.is-current). Migrate ~38 primary/action buttons across 24 files off the hand-rolled 'bg-accent text-accent-fg hover:opacity-90', keeping each site's geometry (padding/radius/font/gap/disabled/shadow). Deliberately NOT migrated: accent BADGES/labels (spans), the chat message bubble, selection toggles (tableHeader — R8 popover territory), and the round play FAB (hover:scale, not the opacity-dim primary idiom). Three previously hover-less primary buttons (SettingsPanel/SetupWizard/DraftSaveBar) gain the standard hover dim for consistency. Gate green (tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed a migrated button computes to accent/accent-fg and both recipe rules load.
388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
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 (
|
|
<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-base mt-1 w-full rounded-lg glass-menu 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 [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 (
|
|
<div className="rounded-lg glass-card p-2.5 space-y-2">
|
|
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-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-hidden"
|
|
/>
|
|
<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-success" /> : <Copy className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setNewPass("");
|
|
setEditPass((v) => !v);
|
|
}}
|
|
title={
|
|
link.has_password
|
|
? t("downloads.share.changePassword")
|
|
: t("downloads.share.setPassword")
|
|
}
|
|
className={clsx(
|
|
"p-1.5 rounded-md hover:bg-surface transition",
|
|
link.has_password ? "text-accent" : "text-muted hover:text-fg",
|
|
)}
|
|
>
|
|
<Lock 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-danger 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-sm 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>
|
|
{editPass && (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={newPass}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<button
|
|
onClick={() => savePass.mutate(newPass.trim())}
|
|
disabled={savePass.isPending || !newPass.trim()}
|
|
className="px-2 py-1 rounded-md text-xs font-semibold btn-primary disabled:opacity-40 transition"
|
|
>
|
|
{t("downloads.share.savePassword")}
|
|
</button>
|
|
{link.has_password && (
|
|
<button
|
|
onClick={async () => {
|
|
if (
|
|
await confirm({
|
|
title: t("downloads.share.removePassword"),
|
|
message: t("downloads.share.removePasswordConfirm"),
|
|
confirmLabel: t("downloads.share.removePassword"),
|
|
danger: true,
|
|
})
|
|
)
|
|
savePass.mutate("");
|
|
}}
|
|
disabled={savePass.isPending}
|
|
className="px-2 py-1 rounded-md text-xs text-muted hover:text-danger transition"
|
|
>
|
|
{t("downloads.share.removePassword")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<number | null>(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 (
|
|
<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 && !create.isPending && (
|
|
<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(btnCls, "text-muted hover:text-fg hover:bg-surface")}
|
|
>
|
|
{t("common.cancel")}
|
|
</button>
|
|
<button
|
|
onClick={() => create.mutate()}
|
|
disabled={create.isPending}
|
|
className={clsx(btnCls, "btn-primary")}
|
|
>
|
|
{t("downloads.share.createLink")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setCreating(true)}
|
|
className={clsx(
|
|
btnCls,
|
|
"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>
|
|
);
|
|
}
|