Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical accent text links, three ghost icon buttons, two ghost text actions), Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread, ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd, PlexCollectionEditor and plexViews. Moves the font-weight out of Button's BASE and into each variant, so the `link` variant can be semibold (which is what every text link in this app looks like) while the action variants stay medium, and no element can end up with two font-* utilities. Welcome is the one signed-out surface this session cannot see, so only its byte-identical link swaps were taken; its primary submit and the bg-card button family are left for a pass that can UAT the screen. 3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications, Messages and the nav rail, clean console in a fresh tab.
385 lines
14 KiB
TypeScript
385 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 "./ui/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 } from "./ui/form";
|
|
import { Button } from "./ui/Button";
|
|
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 variant="ghost" size="icon-sm" onClick={copy} title={t("downloads.share.copy")}>
|
|
{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);
|
|
// D1 (E7 S3): 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<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, downloadable per D1) 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 variant="ghost" size="sm" onClick={() => setCreating(false)}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => create.mutate()}
|
|
disabled={create.isPending}
|
|
>
|
|
{t("downloads.share.createLink")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCreating(true)}
|
|
icon={<Link2 className="w-4 h-4" />}
|
|
className="mt-2"
|
|
>
|
|
{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>
|
|
);
|
|
}
|