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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user