Pure code-motion, DownloadCenter 1088->359 L. Group by kind, mirroring the S4 plexCards/plexViews split: - components/downloadRow.tsx (309 L): the user-list presentation + shared row primitives — StatusPill, Thumb, IconBtn, SourceRef, ChannelLine, DownloadRow, UsageBar (+ jobTitle/jobChannel/displayUrl helpers, status/phase constants). - components/downloadModals.tsx (178 L): the per-item user modals — MetaEditModal (rename/metadata) and ErrorModal (failure detail + retry). - components/downloadAdmin.tsx (267 L): the admin "System" tab — AdminSystem plus QuotaModal and QuotaUserPicker. Every extracted piece is prop-driven with no shell-state reach-back, so the shell just imports them back; the tab logic, mutations and modal state stay in DownloadCenter. Behaviour-preserving. Gate green (tsc first try, knip, lint 0-err, 247 vitest). Self-E2E (localdev): Queue/Library/Shared/System tabs render, UsageBar, admin storage/footprint/job list, MetaEditModal open/close, 0 console errors.
179 lines
6.6 KiB
TypeScript
179 lines
6.6 KiB
TypeScript
import { useState } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import Modal from "./Modal";
|
|
import { api, type DownloadJob } from "../lib/api";
|
|
import { inputCls } from "./ui/form";
|
|
import { IconBtn } from "./downloadRow";
|
|
|
|
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
|
// editing and the failure-detail window.
|
|
|
|
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
|
|
// All are display-only overrides — the on-disk file is never renamed.
|
|
export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const [name, setName] = useState(job.display_name || job.asset?.title || "");
|
|
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
|
|
const [channelUrl, setChannelUrl] = useState(
|
|
job.display_uploader_url || job.asset?.uploader_url || "",
|
|
);
|
|
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
|
|
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
|
|
|
|
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
|
|
const addLink = () => setLinks((ls) => [...ls, ""]);
|
|
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
|
|
|
|
const save = useMutation({
|
|
mutationFn: () =>
|
|
api.updateDownloadMeta(job.id, {
|
|
display_name: name.trim(),
|
|
display_uploader: channel.trim(),
|
|
display_uploader_url: channelUrl.trim(),
|
|
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
|
onClose();
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Modal title={t("downloads.edit.title")} onClose={onClose}>
|
|
<div className="space-y-3">
|
|
<label className="block">
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className={inputCls}
|
|
autoFocus
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
|
|
<input
|
|
value={channel}
|
|
onChange={(e) => setChannel(e.target.value)}
|
|
className={inputCls}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
|
|
<input
|
|
value={channelUrl}
|
|
onChange={(e) => setChannelUrl(e.target.value)}
|
|
placeholder="https://…"
|
|
className={inputCls}
|
|
/>
|
|
</label>
|
|
<div>
|
|
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
|
|
<div className="space-y-2">
|
|
{links.map((l, i) => (
|
|
<div key={i} className="flex gap-2">
|
|
<input
|
|
value={l}
|
|
onChange={(e) => setLink(i, e.target.value)}
|
|
placeholder="https://…"
|
|
className={inputCls}
|
|
/>
|
|
<IconBtn
|
|
onClick={() => removeLink(i)}
|
|
title={t("downloads.edit.removeLink")}
|
|
danger
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</IconBtn>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={addLink}
|
|
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
|
|
>
|
|
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={() => save.mutate()}
|
|
disabled={!name.trim() || save.isPending}
|
|
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
>
|
|
{t("downloads.edit.save")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
|
|
// where a retry can't help — the queue row still keeps its own retry control regardless.
|
|
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
|
|
|
|
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
|
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
|
export function ErrorModal({
|
|
job,
|
|
onRetry,
|
|
onClose,
|
|
}: {
|
|
job: DownloadJob;
|
|
onRetry: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const code = job.error_code || "unknown";
|
|
const [showRaw, setShowRaw] = useState(false);
|
|
const retryable = RETRYABLE_ERRORS.includes(code);
|
|
return (
|
|
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
|
|
<div className="space-y-3">
|
|
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
|
|
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
|
|
{job.error && (
|
|
<div>
|
|
<button
|
|
onClick={() => setShowRaw((v) => !v)}
|
|
className="text-xs text-muted hover:text-fg transition"
|
|
>
|
|
{t("downloads.errors.fail.detailsToggle")}
|
|
</button>
|
|
{showRaw && (
|
|
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
|
|
{job.error}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end gap-2 pt-1">
|
|
{retryable && (
|
|
<button
|
|
onClick={() => {
|
|
onRetry();
|
|
onClose();
|
|
}}
|
|
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
|
>
|
|
{t("downloads.actions.retry")}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
>
|
|
{t("common.close")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|