- A library past the list's cap is searched and sorted on the server; the count says "the newest". - Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope). - A file dropped where the page takes none never reaches the browser (which would unload the app). - The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line; a text shows its first 5000 lines and says where the rest is. - Confirms name the file (no more "your downloads"); the asking menu rows end in "…". - The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly while only paused work waits, and forgets a finished file that was deleted. - An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top; the link box never replaces what was typed. - Honest states: folders that failed to load, a file no longer there, the storage figure; the rail says "nothing downloaded" only when there is nothing. - Focus after deleting the open folder and when the format form opens; the format name's label. - ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer. - Copy fixes, stale comments and the speculative hub key; a contract test for the list's parameters; e2e: the Files page and the paste intake.
404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
import { useEffect, useId, useRef, useState } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { TFunction } from "i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { CopyPlus, Lock, Pencil, Plus, Trash2 } from "lucide-react";
|
|
import Modal from "./ui/Modal";
|
|
import { useConfirm } from "./confirmContext";
|
|
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
|
import { profileName } from "../lib/vocab";
|
|
import { Button } from "./ui/Button";
|
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
|
import FilterSelect from "./ui/FilterSelect";
|
|
import { CheckRow } from "./ui/NativeControls";
|
|
import { Input } from "./ui/Input";
|
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
|
import { ContextMenu } from "./ui/ContextMenu";
|
|
import DataTable, { type Column } from "./ui/DataTable";
|
|
import { menuGroups } from "./ui/menuGroups";
|
|
import { MoreButton } from "./ui/MoreButton";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
|
|
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
|
|
|
const BLANK: DownloadSpec = {
|
|
mode: "av",
|
|
max_height: 720,
|
|
container: "mp4",
|
|
audio_format: "m4a",
|
|
vcodec: null,
|
|
embed_subs: false,
|
|
embed_chapters: true,
|
|
embed_thumbnail: true,
|
|
sponsorblock: false,
|
|
};
|
|
|
|
function Check({
|
|
label,
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<CheckRow checked={checked} onChange={onChange} className="text-sm">
|
|
{label}
|
|
</CheckRow>
|
|
);
|
|
}
|
|
|
|
// The download formats: the built-in ones (read-only, a base to start from) and the user's own, in one
|
|
// aligned list whose rows each have ONE menu (its "…", a right-click, a long-press, Shift+F10) —
|
|
// Edit · Duplicate · Delete, a built-in's only Duplicate. The new/edit form opens under the list.
|
|
// Settings → Downloads shows it in place; the download dialog's "Manage formats" in a modal.
|
|
|
|
const MODE_KEY = {
|
|
av: "downloads.profiles.modeAv",
|
|
v: "downloads.profiles.modeV",
|
|
a: "downloads.profiles.modeA",
|
|
} as const;
|
|
|
|
/** What a format saves, in a line: the content, the quality (or the audio format), the extras. */
|
|
function specSummary(t: TFunction, spec: Partial<DownloadSpec>): string {
|
|
const mode = spec.mode ?? "av";
|
|
const parts = [
|
|
t(MODE_KEY[mode]),
|
|
mode === "a"
|
|
? (spec.audio_format ?? "m4a").toUpperCase()
|
|
: spec.max_height
|
|
? `≤${spec.max_height}p`
|
|
: t("downloads.profiles.best"),
|
|
];
|
|
if (mode !== "a" && spec.embed_subs) parts.push(t("downloads.profiles.short.subs"));
|
|
if (spec.embed_chapters) parts.push(t("downloads.profiles.short.chapters"));
|
|
if (spec.embed_thumbnail) parts.push(t("downloads.profiles.short.thumbnail"));
|
|
if (spec.sponsorblock) parts.push(t("downloads.profiles.short.sponsorblock"));
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
export function DownloadFormats() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles });
|
|
// Built-ins first, then the user's own — the order the dialog's menu lists them in.
|
|
const all = profilesQ.data ?? [];
|
|
const profiles = [...all.filter((p) => p.is_builtin), ...all.filter((p) => !p.is_builtin)];
|
|
|
|
const [editing, setEditing] = useState<DownloadProfile | null>(null);
|
|
const [name, setName] = useState("");
|
|
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
// Opening the form (New, Edit, Duplicate) puts the keyboard in its name: the form opens away from
|
|
// the control that asked for it, and "New format" is replaced by it.
|
|
const [opened, setOpened] = useState(0);
|
|
const nameField = useRef<HTMLInputElement>(null);
|
|
const nameId = useId();
|
|
useEffect(() => {
|
|
if (opened) nameField.current?.focus();
|
|
}, [opened]);
|
|
|
|
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
|
|
const startNew = () => {
|
|
setEditing(null);
|
|
setName("");
|
|
setSpec(BLANK);
|
|
setFormOpen(true);
|
|
setOpened((n) => n + 1);
|
|
};
|
|
const startEdit = (p: DownloadProfile) => {
|
|
setEditing(p);
|
|
setName(p.name);
|
|
setSpec({ ...BLANK, ...p.spec });
|
|
setFormOpen(true);
|
|
setOpened((n) => n + 1);
|
|
};
|
|
// A new format from an existing one: its settings, and a name of its own to give.
|
|
const startCopy = (p: DownloadProfile) => {
|
|
setEditing(null);
|
|
setName("");
|
|
setSpec({ ...BLANK, ...p.spec });
|
|
setFormOpen(true);
|
|
setOpened((n) => n + 1);
|
|
};
|
|
|
|
const save = useMutation({
|
|
mutationFn: () =>
|
|
editing
|
|
? api.updateDownloadProfile(editing.id, { name, spec })
|
|
: api.createDownloadProfile({ name, spec }),
|
|
onSuccess: () => {
|
|
invalidate();
|
|
setFormOpen(false);
|
|
},
|
|
meta: { failTitle: "errors.writeFailed.downloadProfileSave" },
|
|
});
|
|
const del = useMutation({
|
|
mutationFn: (id: number) => api.deleteDownloadProfile(id),
|
|
onSuccess: invalidate,
|
|
meta: { failTitle: "errors.writeFailed.downloadProfileDelete" },
|
|
});
|
|
|
|
const onDelete = async (p: DownloadProfile) => {
|
|
if (
|
|
await confirm({
|
|
title: t("downloads.profiles.delete"),
|
|
message: t("downloads.profiles.deleteConfirm", { name: p.name }),
|
|
confirmLabel: t("downloads.profiles.delete"),
|
|
danger: true,
|
|
})
|
|
) {
|
|
del.mutate(p.id);
|
|
}
|
|
};
|
|
|
|
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
|
|
|
|
const menu = useContextMenu<DownloadProfile>({ keyOf: (p) => p.id });
|
|
const items = (p: DownloadProfile): ActionMenuItem[] =>
|
|
menuGroups(
|
|
p.is_builtin
|
|
? []
|
|
: [
|
|
{
|
|
id: "edit",
|
|
label: t("downloads.profiles.edit"),
|
|
icon: <Pencil className="w-4 h-4" />,
|
|
onSelect: () => startEdit(p),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
id: "copy",
|
|
label: t("downloads.profiles.duplicate"),
|
|
icon: <CopyPlus className="w-4 h-4" />,
|
|
onSelect: () => startCopy(p),
|
|
},
|
|
],
|
|
p.is_builtin
|
|
? []
|
|
: [
|
|
{
|
|
id: "delete",
|
|
label: t("downloads.profiles.delete"),
|
|
icon: <Trash2 className="w-4 h-4" />,
|
|
danger: true,
|
|
onSelect: () => void onDelete(p),
|
|
},
|
|
],
|
|
);
|
|
const columns: Column<DownloadProfile>[] = [
|
|
{
|
|
key: "name",
|
|
header: t("downloads.profiles.name"),
|
|
cardPrimary: true,
|
|
render: (p) => (
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
<span className="truncate">{profileName(t, p)}</span>
|
|
{p.is_builtin && (
|
|
<Lock
|
|
className="w-3.5 h-3.5 shrink-0 text-muted"
|
|
aria-label={t("downloads.profiles.builtin")}
|
|
/>
|
|
)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "spec",
|
|
header: t("downloads.profiles.saves"),
|
|
render: (p) => {
|
|
const summary = specSummary(t, p.spec);
|
|
return (
|
|
<span className="block truncate text-muted" title={summary}>
|
|
{summary}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: "menu",
|
|
header: "",
|
|
width: "3rem",
|
|
align: "right",
|
|
cardLabel: false,
|
|
cardRow: true,
|
|
render: (p) => (
|
|
<MoreButton
|
|
label={t("downloads.profiles.rowMenu")}
|
|
size="md"
|
|
expanded={menu.open && menu.target?.id === p.id}
|
|
menuProps={menu.buttonProps(p)}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
{profilesQ.isError && !profilesQ.data ? (
|
|
<StateMessage tone="error" onRetry={() => void profilesQ.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : !profilesQ.data ? (
|
|
<LoadingState shape="list" />
|
|
) : (
|
|
<div className="mb-4">
|
|
<DataTable
|
|
rows={profiles}
|
|
columns={columns}
|
|
rowKey={(p) => String(p.id)}
|
|
pager={false}
|
|
keyboardNav
|
|
fixedLayout
|
|
rowProps={(p) => menu.triggerProps(p)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<ContextMenu
|
|
menu={menu}
|
|
ariaLabel={t("downloads.profiles.rowMenu")}
|
|
items={menu.target ? items(menu.target) : []}
|
|
/>
|
|
|
|
{!formOpen ? (
|
|
<Button variant="primary" size="sm" icon={<Plus className="w-4 h-4" />} onClick={startNew}>
|
|
{t("downloads.profiles.new")}
|
|
</Button>
|
|
) : (
|
|
<div className="rounded-xl border border-border p-4 space-y-3">
|
|
<div>
|
|
<label htmlFor={nameId} className="block text-sm font-medium mb-1">
|
|
{t("downloads.profiles.name")}
|
|
</label>
|
|
<Input
|
|
id={nameId}
|
|
ref={nameField}
|
|
className="w-full"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t("downloads.profiles.namePlaceholder")}
|
|
/>
|
|
</div>
|
|
{/* Mode on its own row: three worded choices need the width a half column lacks on a
|
|
phone. Segmented for the short fixed sets, a menu for the seven heights (owner's pick). */}
|
|
<div>
|
|
<span className="block text-sm font-medium mb-1" aria-hidden="true">
|
|
{t("downloads.profiles.mode")}
|
|
</span>
|
|
<SegmentedToggle
|
|
ariaLabel={t("downloads.profiles.mode")}
|
|
size="md"
|
|
fullWidth
|
|
value={spec.mode}
|
|
onChange={(mode) => patch({ mode })}
|
|
options={[
|
|
{ value: "av", label: t("downloads.profiles.modeAv") },
|
|
{ value: "v", label: t("downloads.profiles.modeV") },
|
|
{ value: "a", label: t("downloads.profiles.modeA") },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
{spec.mode !== "a" ? (
|
|
<div>
|
|
<span className="block text-sm font-medium mb-1" aria-hidden="true">
|
|
{t("downloads.profiles.quality")}
|
|
</span>
|
|
<FilterSelect
|
|
label={t("downloads.profiles.quality")}
|
|
hideLabel
|
|
selected={[String(spec.max_height ?? "")]}
|
|
onChange={([v]) => v !== undefined && patch({ max_height: v ? Number(v) : null })}
|
|
options={HEIGHTS.map((h) => ({
|
|
value: String(h ?? ""),
|
|
label: h ? `${h}p` : t("downloads.profiles.best"),
|
|
}))}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<span className="block text-sm font-medium mb-1" aria-hidden="true">
|
|
{t("downloads.profiles.audioFormat")}
|
|
</span>
|
|
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it
|
|
unreliably as a bare file, and the module's purpose is in-browser playback. */}
|
|
<SegmentedToggle
|
|
ariaLabel={t("downloads.profiles.audioFormat")}
|
|
size="md"
|
|
value={spec.audio_format ?? "m4a"}
|
|
onChange={(audio_format) => patch({ audio_format })}
|
|
options={[
|
|
{ value: "m4a", label: "M4A" },
|
|
{ value: "mp3", label: "MP3" },
|
|
]}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* Container/codec are no longer user-selectable: every download is clamped to the
|
|
mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a
|
|
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
|
|
</div>
|
|
{/* One option a line: a CheckRow is inline, and in the wide Settings column four of them
|
|
ran together on one line. */}
|
|
<div className="flex flex-col items-start gap-1.5 pt-1">
|
|
{spec.mode !== "a" && (
|
|
<Check
|
|
label={t("downloads.profiles.embedSubs")}
|
|
checked={spec.embed_subs}
|
|
onChange={(v) => patch({ embed_subs: v })}
|
|
/>
|
|
)}
|
|
<Check
|
|
label={t("downloads.profiles.embedChapters")}
|
|
checked={spec.embed_chapters}
|
|
onChange={(v) => patch({ embed_chapters: v })}
|
|
/>
|
|
<Check
|
|
label={t("downloads.profiles.embedThumbnail")}
|
|
checked={spec.embed_thumbnail}
|
|
onChange={(v) => patch({ embed_thumbnail: v })}
|
|
/>
|
|
<Check
|
|
label={t("downloads.profiles.sponsorblock")}
|
|
checked={spec.sponsorblock}
|
|
onChange={(v) => patch({ sponsorblock: v })}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-1">
|
|
<Button variant="ghost" size="sm" onClick={() => setFormOpen(false)}>
|
|
{t("downloads.actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => save.mutate()}
|
|
disabled={!name.trim() || save.isPending}
|
|
>
|
|
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Modal
|
|
initialFocus="close"
|
|
title={t("downloads.profiles.title")}
|
|
onClose={onClose}
|
|
maxWidth="max-w-xl"
|
|
>
|
|
<DownloadFormats />
|
|
</Modal>
|
|
);
|
|
}
|