feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place

- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
  history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
  their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
  open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
  job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
  your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
  back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
This commit is contained in:
2026-10-11 19:37:13 +02:00
parent 1c4f0042d2
commit 078194731b
69 changed files with 1553 additions and 1147 deletions
+171 -45
View File
@@ -1,8 +1,9 @@
import { 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 { Lock, Pencil, Plus, Trash2 } from "lucide-react";
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";
@@ -10,11 +11,15 @@ import { profileName } from "../lib/vocab";
import { Button } from "./ui/Button";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect";
import { IconButton } from "./ui/IconButton";
import { CheckRow } from "./ui/NativeControls";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
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];
@@ -46,14 +51,43 @@ function Check({
);
}
export default function ProfileEditor({ onClose }: { onClose: () => void }) {
// 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 });
const profiles = profilesQ.data ?? [];
const builtins = profiles.filter((p) => p.is_builtin);
const mine = profiles.filter((p) => !p.is_builtin);
// 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("");
@@ -73,6 +107,13 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
setSpec({ ...BLANK, ...p.spec });
setFormOpen(true);
};
// 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);
};
const save = useMutation({
mutationFn: () =>
@@ -106,43 +147,112 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
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 (
<Modal
initialFocus="close"
title={t("downloads.profiles.title")}
onClose={onClose}
maxWidth="max-w-xl"
>
{/* Existing formats */}
<div className="space-y-1.5 mb-4">
<SectionLabel level={3}>{t("downloads.profiles.builtin")}</SectionLabel>
{builtins.map((p) => (
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
<span className="flex-1 truncate">{profileName(t, p)}</span>
</div>
))}
{mine.length > 0 && (
<SectionLabel level={3} className="pt-2">
{t("downloads.profiles.yours")}
</SectionLabel>
)}
{mine.map((p) => (
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
<span className="flex-1 truncate">{p.name}</span>
<IconButton onClick={() => startEdit(p)} label={t("downloads.actions.rename")}>
<Pencil />
</IconButton>
<IconButton
variant="danger"
onClick={() => onDelete(p)}
label={t("downloads.profiles.delete")}
>
<Trash2 />
</IconButton>
</div>
))}
</div>
<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}>
@@ -218,7 +328,9 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
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>
<div className="space-y-1.5 pt-1">
{/* 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")}
@@ -257,6 +369,20 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
</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>
);
}