The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
182 lines
6.8 KiB
TypeScript
182 lines
6.8 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 "./ui/Modal";
|
|
import { api, type DownloadJob } from "../lib/api";
|
|
import { inputCls } from "./ui/form";
|
|
import { Button } from "./ui/Button";
|
|
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
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => save.mutate()}
|
|
disabled={!name.trim() || save.isPending}
|
|
>
|
|
{t("downloads.edit.save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
|
|
// sign-in/unsupported-host/…), where a retry can't help — the queue row still keeps its own retry
|
|
// control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
|
|
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
|
|
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
|
|
const RETRYABLE_ERRORS = ["postprocess", "network"];
|
|
|
|
// 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
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => {
|
|
onRetry();
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("downloads.actions.retry")}
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
{t("common.close")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|