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:
@@ -5,7 +5,7 @@ be developed that way. This file is the rules of the road: read it before you to
|
|||||||
loaded into every agent session, so keep it **short and true**. A rule here that has gone stale is
|
loaded into every agent session, so keep it **short and true**. A rule here that has gone stale is
|
||||||
worse than no rule — if you change how the code works, change this file in the same commit.
|
worse than no rule — if you change how the code works, change this file in the same commit.
|
||||||
|
|
||||||
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
|
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a files library with downloads). It is a
|
||||||
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
||||||
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
|
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
|
||||||
router-free navigation provider.
|
router-free navigation provider.
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ blocker and SponsorBlock keep working.
|
|||||||
tags to slice the feed by.
|
tags to slice the feed by.
|
||||||
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
|
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
|
||||||
- **In-app player** with resume, plus keyboard/scroll controls.
|
- **In-app player** with resume, plus keyboard/scroll controls.
|
||||||
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
|
- **Files** — one library for everything you keep: paste a link anywhere in the app to download it
|
||||||
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
|
with yt-dlp (format presets, per-user storage quota, the running downloads in a tray), upload your
|
||||||
|
own files, sort them into folders, trim / crop / split & join videos in a built-in editor, play them
|
||||||
where you left off, then save to your device, share with another user, or hand out a public watch
|
where you left off, then save to your device, share with another user, or hand out a public watch
|
||||||
link.
|
link that plays on any phone.
|
||||||
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
|
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
|
||||||
movies and shows alongside your feed, with watch state kept in sync.
|
movies and shows alongside your feed, with watch state kept in sync.
|
||||||
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
||||||
|
|||||||
@@ -842,6 +842,7 @@ def admin_list_downloads(
|
|||||||
out = []
|
out = []
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
data = serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None)
|
data = serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None)
|
||||||
|
data["user_id"] = j.user_id
|
||||||
data["user_email"] = emails.get(j.user_id)
|
data["user_email"] = emails.get(j.user_id)
|
||||||
out.append(data)
|
out.append(data)
|
||||||
return out
|
return out
|
||||||
@@ -860,19 +861,35 @@ def admin_storage(admin: User = Depends(admin_user), db: Session = Depends(get_d
|
|||||||
.where(DownloadJob.asset_id.is_not(None))
|
.where(DownloadJob.asset_id.is_not(None))
|
||||||
.group_by(DownloadJob.user_id)
|
.group_by(DownloadJob.user_id)
|
||||||
).all()
|
).all()
|
||||||
emails = {
|
uids = [uid for uid, _ in per_user]
|
||||||
u.id: u.email
|
emails = {u.id: u.email for u in db.execute(select(User).where(User.id.in_(uids))).scalars()}
|
||||||
for u in db.execute(select(User).where(User.id.in_([uid for uid, _ in per_user]))).scalars()
|
# What each one's Files lists: their finished files.
|
||||||
|
files: dict[int, int] = {
|
||||||
|
uid: n
|
||||||
|
for uid, n in db.execute(
|
||||||
|
select(DownloadJob.user_id, func.count())
|
||||||
|
.where(DownloadJob.user_id.in_(uids), DownloadJob.status == JobStatus.DONE)
|
||||||
|
.group_by(DownloadJob.user_id)
|
||||||
|
).tuples()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def row(uid: int) -> dict:
|
||||||
|
lim = quota.resolve(db, uid)
|
||||||
|
return {
|
||||||
|
"user_id": uid,
|
||||||
|
"email": emails.get(uid),
|
||||||
|
"footprint_bytes": quota.footprint(db, uid),
|
||||||
|
"files": files.get(uid, 0),
|
||||||
|
"max_bytes": lim.max_bytes,
|
||||||
|
"unlimited": lim.unlimited,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"files_on_disk": on_disk[0],
|
"files_on_disk": on_disk[0],
|
||||||
"total_bytes": int(on_disk[1]),
|
"total_bytes": int(on_disk[1]),
|
||||||
"total_assets": total_assets,
|
"total_assets": total_assets,
|
||||||
"total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
|
"total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
|
||||||
"per_user": [
|
"per_user": [row(uid) for uid in uids],
|
||||||
{"user_id": uid, "email": emails.get(uid), "footprint_bytes": quota.footprint(db, uid)}
|
|
||||||
for uid, _ in per_user
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -598,6 +598,29 @@ def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user,
|
|||||||
assert {p["user_id"]: p["footprint_bytes"] for p in r["per_user"]} == {u.id: 10}
|
assert {p["user_id"]: p["footprint_bytes"] for p in r["per_user"]} == {u.id: 10}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_admin_storage_view_names_each_users_quota_and_files(
|
||||||
|
db, as_user, make_user, _root, monkeypatch
|
||||||
|
):
|
||||||
|
# A row says how full the user is against THEIR limit (a custom one, here), and how many files
|
||||||
|
# their Files lists — the finished ones, not one still waiting for its conversion.
|
||||||
|
_probe(monkeypatch)
|
||||||
|
u = make_user()
|
||||||
|
done, _ = _ingest(db, _root, u.id, size=10)
|
||||||
|
waiting, _ = _ingest(db, _root, u.id, name="b.mov", size=5)
|
||||||
|
done.status, waiting.status = "done", "queued"
|
||||||
|
db.commit()
|
||||||
|
admin = as_user(make_user(role="admin"))
|
||||||
|
put = admin.put(f"/api/admin/downloads/quota/{u.id}", json={"max_bytes": 1000})
|
||||||
|
assert put.status_code == 200
|
||||||
|
row = admin.get("/api/admin/downloads/storage").json()["per_user"]
|
||||||
|
assert [(p["user_id"], p["files"], p["max_bytes"], p["unlimited"]) for p in row] == [
|
||||||
|
(u.id, 1, 1000, False)
|
||||||
|
]
|
||||||
|
# Every download names its owner, so its row can open THEIR quota.
|
||||||
|
jobs = admin.get("/api/admin/downloads").json()
|
||||||
|
assert {(j["id"], j["user_id"]) for j in jobs} == {(done.id, u.id), (waiting.id, u.id)}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("job_status", "asset_status", "ref_count"),
|
("job_status", "asset_status", "ref_count"),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ configured in a web wizard on first start. There's no editing of config files by
|
|||||||
|
|
||||||
- A machine with **Docker** and the **Docker Compose plugin** (Docker Desktop on Windows/macOS,
|
- A machine with **Docker** and the **Docker Compose plugin** (Docker Desktop on Windows/macOS,
|
||||||
or Docker Engine on Linux).
|
or Docker Engine on Linux).
|
||||||
- A few hundred MB of disk and ~1 GB RAM free for the app itself. The optional Download Center
|
- A few hundred MB of disk and ~1 GB RAM free for the app itself. The Files library
|
||||||
stores media too — budget disk for whatever you download (it's bounded by per-user quotas you
|
stores media too — budget disk for whatever you download (it's bounded by per-user quotas you
|
||||||
set as admin).
|
set as admin).
|
||||||
- Optional: a domain name + reverse proxy if you want HTTPS / public access (see below).
|
- Optional: a domain name + reverse proxy if you want HTTPS / public access (see below).
|
||||||
@@ -97,13 +97,13 @@ Only that peer is trusted, so a request sent straight to port `8080` (bypassing
|
|||||||
app is exposed directly with no proxy. Make sure your proxy actually sets `X-Forwarded-For` (Caddy's
|
app is exposed directly with no proxy. Make sure your proxy actually sets `X-Forwarded-For` (Caddy's
|
||||||
`reverse_proxy` and Nginx's `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` both do).
|
`reverse_proxy` and Nginx's `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` both do).
|
||||||
|
|
||||||
## Download Center (media storage)
|
## Files (media storage)
|
||||||
|
|
||||||
The stack includes a **Download Center**: an admin-enabled feature that saves videos to the server
|
The stack includes **Files**: one library that saves videos to the server
|
||||||
with yt-dlp (Plex-friendly folders + `.nfo`/poster), lets users trim/crop/join clips, and shares
|
with yt-dlp (Plex-friendly folders + `.nfo`/poster), lets users trim/crop/join clips, and shares
|
||||||
them. It runs two extra containers that come up automatically — a `worker` (the download/edit job
|
them. It runs two extra containers that come up automatically — a `worker` (the download/edit job
|
||||||
loop) and a small `bgutil-pot` sidecar (mints YouTube tokens so downloads aren't bot-blocked). No
|
loop) and a small `bgutil-pot` sidecar (mints YouTube tokens so downloads aren't bot-blocked). No
|
||||||
configuration is required; per-user storage quotas are set on the admin **Downloads → System** page.
|
configuration is required; per-user storage quotas are set on the admin **Users → Storage** tab.
|
||||||
|
|
||||||
By default the media lives in a Docker-managed volume (`siftlode_files`). To keep it somewhere
|
By default the media lives in a Docker-managed volume (`siftlode_files`). To keep it somewhere
|
||||||
you can reach from other apps — e.g. a folder your **Plex** server indexes — point it at a host
|
you can reach from other apps — e.g. a folder your **Plex** server indexes — point it at a host
|
||||||
@@ -164,7 +164,7 @@ then cannot read its own secrets.
|
|||||||
token and every stored refresh token are encrypted with it *inside* the database. Restore a dump
|
token and every stored refresh token are encrypted with it *inside* the database. Restore a dump
|
||||||
against a different key and those values are lost — you would have to clear the affected rows from
|
against a different key and those values are lost — you would have to clear the affected rows from
|
||||||
`system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private.
|
`system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private.
|
||||||
3. **Downloaded media** *(only if you use the Download Center)* — the `siftlode_files` volume, or
|
3. **Downloaded media** *(only if you use Files)* — the `siftlode_files` volume, or
|
||||||
whatever `FILES_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their
|
whatever `FILES_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their
|
||||||
metadata are in the database, the files themselves are not.
|
metadata are in the database, the files themselves are not.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
@@ -52,8 +52,10 @@ import { MoreButton } from "./ui/MoreButton";
|
|||||||
import type { ActionMenuItem } from "./ui/ActionMenu";
|
import type { ActionMenuItem } from "./ui/ActionMenu";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
import { StorageTab } from "./downloadAdmin";
|
||||||
|
|
||||||
// Admin-only user & access management: roles and access requests (the Invite whitelist). Each
|
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||||
|
// storage every user's downloads take (with their quotas). Each
|
||||||
// section is its own tab (persisted across reloads); the Access tab carries a badge with the count
|
// section is its own tab (persisted across reloads); the Access tab carries a badge with the count
|
||||||
// of pending requests so it's visible without switching to it.
|
// of pending requests so it's visible without switching to it.
|
||||||
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
|
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
|
||||||
@@ -81,6 +83,8 @@ export default function AdminUsers() {
|
|||||||
label: t("users.tabs.access"),
|
label: t("users.tabs.access"),
|
||||||
badge: me.pending_invites + me.pending_activations,
|
badge: me.pending_invites + me.pending_activations,
|
||||||
},
|
},
|
||||||
|
// What every user's downloads take on the disk, against their own limit (the quota editor).
|
||||||
|
{ id: "storage", label: t("users.tabs.storage") },
|
||||||
];
|
];
|
||||||
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
|
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
|
||||||
return (
|
return (
|
||||||
@@ -99,6 +103,7 @@ export default function AdminUsers() {
|
|||||||
<PendingAccess />
|
<PendingAccess />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{active === "storage" && <StorageTab />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ afterEach(() => {
|
|||||||
describe("BootGate", () => {
|
describe("BootGate", () => {
|
||||||
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
|
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
|
||||||
localStorage.setItem("siftlode.startOnHome.5", "0");
|
localStorage.setItem("siftlode.startOnHome.5", "0");
|
||||||
localStorage.setItem("siftlode.page.5", "downloads");
|
localStorage.setItem("siftlode.page.5", "plex");
|
||||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
|
expect((await screen.findByTestId("app")).textContent).toBe("plex");
|
||||||
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
|
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
|
||||||
expect(new URLSearchParams(window.location.search).get("page")).toBe("downloads");
|
expect(new URLSearchParams(window.location.search).get("page")).toBe("plex");
|
||||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned
|
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,12 +176,12 @@ describe("BootGate", () => {
|
|||||||
// Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5.
|
// Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5.
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
|
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
|
||||||
localStorage.setItem("siftlode.startOnHome.5", "0");
|
localStorage.setItem("siftlode.startOnHome.5", "0");
|
||||||
localStorage.setItem("siftlode.page.5", "downloads");
|
localStorage.setItem("siftlode.page.5", "plex");
|
||||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
|
expect((await screen.findByTestId("app")).textContent).toBe("plex");
|
||||||
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
|
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
|
||||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ vi.mock("../lib/notifications", async (importOriginal) => {
|
|||||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||||
return { ...actual, notify: (n: unknown) => notify(n) };
|
return { ...actual, notify: (n: unknown) => notify(n) };
|
||||||
});
|
});
|
||||||
|
const showInTray = vi.fn();
|
||||||
|
vi.mock("../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTray(id) }));
|
||||||
const notifyActionError = vi.fn();
|
const notifyActionError = vi.fn();
|
||||||
vi.mock("../lib/actionErrors", async (importOriginal) => {
|
vi.mock("../lib/actionErrors", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../lib/actionErrors")>();
|
const actual = await importOriginal<typeof import("../lib/actionErrors")>();
|
||||||
@@ -188,6 +190,11 @@ describe("ClipPanel", () => {
|
|||||||
expect(notify).toHaveBeenCalledWith(
|
expect(notify).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ level: "success", message: "clip.queued" }),
|
expect.objectContaining({ level: "success", message: "clip.queued" }),
|
||||||
);
|
);
|
||||||
|
// Its View opens the tray on the new clip.
|
||||||
|
(
|
||||||
|
notify.mock.calls[notify.mock.calls.length - 1]![0] as { action: { onClick: () => void } }
|
||||||
|
).action.onClick();
|
||||||
|
expect(showInTray).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends no audio track for a YouTube clip", async () => {
|
it("sends no audio track for a YouTube clip", async () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
|||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { navigateTo } from "../lib/nav";
|
import { showInTray } from "../lib/downloadQueue";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../lib/log";
|
||||||
import { formatTimecode } from "../lib/timecode";
|
import { formatTimecode } from "../lib/timecode";
|
||||||
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
|
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
|
||||||
@@ -192,13 +192,13 @@ export default function ClipPanel({
|
|||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.enqueueClip(body);
|
const job = await api.enqueueClip(body);
|
||||||
invalidateDownloads(qc);
|
invalidateDownloads(qc);
|
||||||
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
|
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: t("clip.queued"),
|
message: t("clip.queued"),
|
||||||
action: { label: t("clip.view"), onClick: () => navigateTo("downloads") },
|
action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
//
|
|
||||||
// The Download Center's confirm dialogs name their ACTION on the confirm button: the shared
|
|
||||||
// `confirmThen` once labelled it with the dialog's question — the cancel dialog's own "Cancel" beside
|
|
||||||
// "Cancel". A finished file's confirms are the Files page's (Files.menu.dom.test.tsx). i18n is
|
|
||||||
// key-passthrough, so labels read as keys.
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import type { DownloadJob } from "../lib/api";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
jobs: [] as unknown[],
|
|
||||||
confirm: vi.fn(
|
|
||||||
async (_opts: { title: string; message?: string; confirmLabel?: string }) => false,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
|
||||||
vi.mock("../lib/useLiveQuery", () => ({
|
|
||||||
useLiveQuery: (key: readonly unknown[]) => ({
|
|
||||||
data: key[0] === "downloads" ? mocks.jobs : undefined,
|
|
||||||
isError: false,
|
|
||||||
isLoading: false,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) }));
|
|
||||||
// The page toolbar (title + tabs) renders into the app shell's slot, which a bare render has none of.
|
|
||||||
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
|
||||||
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import DownloadCenter from "./DownloadCenter";
|
|
||||||
|
|
||||||
const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({
|
|
||||||
id,
|
|
||||||
status,
|
|
||||||
progress: 0,
|
|
||||||
speed_bps: null,
|
|
||||||
eta_s: null,
|
|
||||||
phase: null,
|
|
||||||
error: null,
|
|
||||||
error_code: null,
|
|
||||||
error_detail: null,
|
|
||||||
queue_pos: 0,
|
|
||||||
created_at: null,
|
|
||||||
source_kind: "youtube",
|
|
||||||
source_ref: `v${id}`,
|
|
||||||
source_url: null,
|
|
||||||
poster_url: null,
|
|
||||||
profile_id: null,
|
|
||||||
spec: {
|
|
||||||
mode: "av",
|
|
||||||
max_height: null,
|
|
||||||
container: null,
|
|
||||||
audio_format: null,
|
|
||||||
vcodec: null,
|
|
||||||
embed_subs: false,
|
|
||||||
embed_chapters: false,
|
|
||||||
embed_thumbnail: false,
|
|
||||||
sponsorblock: false,
|
|
||||||
},
|
|
||||||
display_name: `Clip ${id}`,
|
|
||||||
display_uploader: null,
|
|
||||||
display_uploader_url: null,
|
|
||||||
extra_links: [],
|
|
||||||
can_download: status === "done",
|
|
||||||
folder_id: null,
|
|
||||||
kept: false,
|
|
||||||
expires_at: null,
|
|
||||||
asset: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
mocks.confirm.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Pick `label` from the row's menu, opened by its "…". The surface is visibility:hidden in jsdom
|
|
||||||
* (floating-ui never positions it), so the row is matched on its text. */
|
|
||||||
async function pick(row: string, label: string) {
|
|
||||||
const more = screen
|
|
||||||
.getByText(row)
|
|
||||||
.closest<HTMLElement>("[data-nav-index]")!
|
|
||||||
.querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
|
|
||||||
fireEvent.click(more);
|
|
||||||
const item = await waitFor(() => {
|
|
||||||
const found = screen
|
|
||||||
.getAllByRole("menuitem", { hidden: true })
|
|
||||||
.find((b) => b.textContent === label);
|
|
||||||
expect(found).toBeTruthy();
|
|
||||||
return found!;
|
|
||||||
});
|
|
||||||
fireEvent.click(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("DownloadCenter confirm dialogs", () => {
|
|
||||||
it("label the confirm button with the action, not the dialog's question", async () => {
|
|
||||||
mocks.jobs = [job(1, "running")];
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
<DownloadCenter />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The destructive and rare row buttons live in the row's "…".
|
|
||||||
await pick("Clip 1", "downloads.actions.cancel");
|
|
||||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
||||||
|
|
||||||
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
|
|
||||||
["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancelling an upload says the uploaded file goes with it", async () => {
|
|
||||||
// Nothing to fetch again: the cancel deletes the original from the server.
|
|
||||||
mocks.jobs = [{ ...job(3, "queued"), job_kind: "upload" }];
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
<DownloadCenter />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
await pick("Clip 3", "downloads.actions.cancel");
|
|
||||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
||||||
const [opts] = mocks.confirm.mock.calls[0]!;
|
|
||||||
expect([opts.title, opts.confirmLabel]).toEqual([
|
|
||||||
"downloads.confirm.cancelUploadTitle",
|
|
||||||
"downloads.confirm.cancelUploadAction",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancelling a clip speaks of the clip", async () => {
|
|
||||||
mocks.jobs = [{ ...job(4, "running"), job_kind: "edit" }];
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
<DownloadCenter />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
await pick("Clip 4", "downloads.actions.cancel");
|
|
||||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
||||||
const [opts] = mocks.confirm.mock.calls[0]!;
|
|
||||||
expect([opts.title, opts.message, opts.confirmLabel]).toEqual([
|
|
||||||
"downloads.confirm.cancelEditTitle",
|
|
||||||
"downloads.confirm.cancelEditBody",
|
|
||||||
"downloads.confirm.cancelEditAction",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
|
||||||
import { qk } from "../lib/queryKeys";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Pause, Play, RefreshCw, Settings2, UploadCloud } from "lucide-react";
|
|
||||||
import Tabs from "./ui/Tabs";
|
|
||||||
import { useTabPanel } from "./ui/tabPanel";
|
|
||||||
import { PageToolbar } from "./ui/PageShell";
|
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
||||||
// Lazy: these dialogs open on demand, so they stay out of the Downloads page chunk until used.
|
|
||||||
const UploadDialog = lazy(() => import("./UploadDialog"));
|
|
||||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
|
||||||
import { useMe } from "../lib/useMe";
|
|
||||||
import { type DownloadJob } from "../lib/api";
|
|
||||||
import { fetchQueue } from "../lib/downloadQueue";
|
|
||||||
import { Button } from "./ui/Button";
|
|
||||||
import { DownloadRow, IconBtn } from "./downloadRow";
|
|
||||||
import { ErrorModal } from "./downloadModals";
|
|
||||||
import { AdminSystem } from "./downloadAdmin";
|
|
||||||
import NavGrid from "./ui/NavGrid";
|
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
|
||||||
import { ContextMenu } from "./ui/ContextMenu";
|
|
||||||
import { MoreButton } from "./ui/MoreButton";
|
|
||||||
import { useDownloadActions } from "./useDownloadActions";
|
|
||||||
import { LinkBox } from "./LinkBox";
|
|
||||||
|
|
||||||
const jobKey = (job: DownloadJob) => job.id;
|
|
||||||
|
|
||||||
// The download queue: what is downloading, waiting or failed, the link box and the upload, the
|
|
||||||
// download profiles, and (admin) the instance's downloads. A finished download is a library file and
|
|
||||||
// lives in Files.
|
|
||||||
export default function DownloadCenter() {
|
|
||||||
const me = useMe();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// Always land on the queue tab (not a persisted last-tab) — the queue is where new work and
|
|
||||||
// any failures are, so it's the useful default every visit.
|
|
||||||
const [tab, setTab] = useState("queue");
|
|
||||||
const panel = useTabPanel();
|
|
||||||
const [upload, setUpload] = useState(false);
|
|
||||||
const [manage, setManage] = useState(false);
|
|
||||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
|
||||||
|
|
||||||
// Poll fast (2s) while work is actually in flight; back off to 15s once the queue is settled
|
|
||||||
// (only done/error/paused rows left, which don't advance on their own) so an idle Download Center
|
|
||||||
// isn't hitting the API — and adding to the shared box's load — every 2s for nothing.
|
|
||||||
const jobsQ = useLiveQuery(qk.downloads(), fetchQueue, {
|
|
||||||
intervalMs: (data) =>
|
|
||||||
data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000,
|
|
||||||
});
|
|
||||||
const jobs = jobsQ.data ?? [];
|
|
||||||
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
|
|
||||||
|
|
||||||
// --- The rows' ONE menu -------------------------------------------------------------
|
|
||||||
// Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an
|
|
||||||
// open menu, and its "…" must still close it.
|
|
||||||
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
|
|
||||||
const actions = useDownloadActions();
|
|
||||||
const menuItems = rowMenu.target && tab === "queue" ? actions.items(rowMenu.target) : [];
|
|
||||||
// The row's menu triggers and its "…".
|
|
||||||
const rowMenuProps = (job: DownloadJob) => {
|
|
||||||
const mine = rowMenu.open && rowMenu.target?.id === job.id;
|
|
||||||
return {
|
|
||||||
rowProps: rowMenu.triggerProps(job),
|
|
||||||
more: (
|
|
||||||
<MoreButton
|
|
||||||
label={t("downloads.rowMenu")}
|
|
||||||
size="md"
|
|
||||||
expanded={mine}
|
|
||||||
menuProps={rowMenu.buttonProps(job)}
|
|
||||||
className={mine ? undefined : "reveal"}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
|
|
||||||
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []),
|
|
||||||
];
|
|
||||||
// Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session)
|
|
||||||
// — that would render a blank page with no active tab. Fall back to the default.
|
|
||||||
// Adjusted during render rather than from an effect: as an effect the blank page it exists to
|
|
||||||
// prevent was committed and painted first. `setTab` is this component's own state (persisted per
|
|
||||||
// account), so a render-phase write is allowed, and it converges — "queue" is always in the list.
|
|
||||||
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* The tabs are the page's band (B4); the tab content scrolls under them. No visible title: the
|
|
||||||
nav rail and the tab title name the page, the shell's hidden h1 names it to a screen reader. */}
|
|
||||||
<PageToolbar>
|
|
||||||
<div className="px-4 pt-2 pb-2 max-w-4xl w-full mx-auto flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<Tabs
|
|
||||||
idBase={panel.idBase}
|
|
||||||
tabs={tabs}
|
|
||||||
active={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
className="flex flex-wrap gap-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
icon={<Settings2 />}
|
|
||||||
onClick={() => setManage(true)}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
{t("downloads.page.manage")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PageToolbar>
|
|
||||||
|
|
||||||
<div {...panel.panelProps(tab)} className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
|
|
||||||
{tab === "queue" && (
|
|
||||||
<>
|
|
||||||
{/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */}
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
|
||||||
<LinkBox className="flex-[1_1_16rem]" />
|
|
||||||
{/* Local upload is a peer to "add a URL": a general file → the same library. */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
icon={<UploadCloud className="w-4 h-4" />}
|
|
||||||
onClick={() => setUpload(true)}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
{t("downloads.page.upload")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<NavGrid className="space-y-2">
|
|
||||||
{queue.map((job, i) => (
|
|
||||||
<DownloadRow
|
|
||||||
key={job.id}
|
|
||||||
job={job}
|
|
||||||
navIndex={i}
|
|
||||||
onShowError={setErrorJob}
|
|
||||||
{...rowMenuProps(job)}
|
|
||||||
>
|
|
||||||
{/* The queue's primary action stays in sight; cancel moved into the "…". */}
|
|
||||||
{job.status === "queued" || job.status === "running" ? (
|
|
||||||
<IconBtn
|
|
||||||
onClick={() => actions.run(job, "pause")}
|
|
||||||
title={t("downloads.actions.pause")}
|
|
||||||
>
|
|
||||||
<Pause className="w-4 h-4" />
|
|
||||||
</IconBtn>
|
|
||||||
) : (
|
|
||||||
<IconBtn
|
|
||||||
onClick={() => actions.run(job, "resume")}
|
|
||||||
title={t(
|
|
||||||
job.status === "error"
|
|
||||||
? "downloads.actions.retry"
|
|
||||||
: "downloads.actions.resume",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Retry is "do it again", not playback: the Play glyph promised a video. */}
|
|
||||||
{job.status === "error" ? (
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<Play className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</IconBtn>
|
|
||||||
)}
|
|
||||||
</DownloadRow>
|
|
||||||
))}
|
|
||||||
{queue.length === 0 &&
|
|
||||||
(jobsQ.isError && !jobsQ.data ? (
|
|
||||||
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
|
||||||
{t("common.loadError")}
|
|
||||||
</StateMessage>
|
|
||||||
) : jobsQ.isLoading ? (
|
|
||||||
<LoadingState shape="list" />
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted py-10 text-center">
|
|
||||||
{t("downloads.empty.queue")}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</NavGrid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
|
||||||
|
|
||||||
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
|
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
{upload && <UploadDialog onClose={() => setUpload(false)} />}
|
|
||||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
|
||||||
{errorJob && (
|
|
||||||
<ErrorModal
|
|
||||||
job={errorJob}
|
|
||||||
onRetry={() => actions.run(errorJob, "resume")}
|
|
||||||
onClose={() => setErrorJob(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The download dialog opens on the format used last (per account), so a pasted link is one Enter
|
||||||
|
// away; a format deleted since falls back to the first. Its "added" notice's View opens the tray on
|
||||||
|
// the new job. The prefs are a map that outlives a mount — a reload, as far as the dialog can tell.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api, type DownloadProfile } from "../lib/api";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
prefs: new Map<string, unknown>(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
showInTray: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
vi.mock("../lib/usePref", () => ({
|
||||||
|
usePref: <T,>(key: string, initial: T) => {
|
||||||
|
const [value, setValue] = useState<T>(() =>
|
||||||
|
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
value,
|
||||||
|
(next: T) => {
|
||||||
|
h.prefs.set(key, next);
|
||||||
|
setValue(next);
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||||
|
notify: h.notify,
|
||||||
|
}));
|
||||||
|
vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
|
||||||
|
|
||||||
|
import DownloadDialog from "./DownloadDialog";
|
||||||
|
|
||||||
|
const profile = (id: number, name: string) =>
|
||||||
|
({ id, name, is_builtin: false, spec: { mode: "av" } }) as unknown as DownloadProfile;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
h.prefs.clear();
|
||||||
|
h.notify.mockClear();
|
||||||
|
h.showInTray.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
vi.spyOn(api, "downloadProfiles").mockResolvedValue([profile(1, "Best"), profile(2, "Phone")]);
|
||||||
|
const enqueue = vi.spyOn(api, "enqueueDownload").mockResolvedValue({ id: 77 } as never);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<DownloadDialog source="https://example.com/v" onClose={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return enqueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked = () =>
|
||||||
|
screen.getByRole("button", { name: /^downloads\.dialog\.profile: / }).getAttribute("aria-label");
|
||||||
|
|
||||||
|
describe("DownloadDialog", () => {
|
||||||
|
it("opens on the format used last; a deleted one falls back to the first", async () => {
|
||||||
|
const enqueue = open();
|
||||||
|
await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Best"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^downloads\.dialog\.profile: / }));
|
||||||
|
const phone = (await screen.findAllByRole("menuitemradio", { hidden: true })).find(
|
||||||
|
(o) => o.textContent === "Phone",
|
||||||
|
)!;
|
||||||
|
fireEvent.click(phone);
|
||||||
|
fireEvent.click(screen.getByText("downloads.dialog.submit"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ profile_id: 2 })),
|
||||||
|
);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
open();
|
||||||
|
await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Phone"));
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
h.prefs.set("downloadProfile", 99);
|
||||||
|
open();
|
||||||
|
await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Best"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("its notice's View opens the tray on the new job", async () => {
|
||||||
|
open();
|
||||||
|
await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Best"));
|
||||||
|
fireEvent.click(screen.getByText("downloads.dialog.submit"));
|
||||||
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||||
|
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
||||||
|
expect(h.showInTray).toHaveBeenCalledWith(77);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,13 +11,18 @@ import { invalidateDownloads } from "../lib/downloads";
|
|||||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { navigateTo } from "../lib/nav";
|
import { showInTray } from "../lib/downloadQueue";
|
||||||
import { profileName } from "../lib/vocab";
|
import { profileName } from "../lib/vocab";
|
||||||
|
import { LS } from "../lib/storage";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
import FilterSelect from "./ui/FilterSelect";
|
import FilterSelect from "./ui/FilterSelect";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
|
|
||||||
// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from
|
// Preset picker shown when the user hits Download on a card / in the player, or brings a link (the
|
||||||
// the Download Center. Enqueues via the API and points the user at the Download Center.
|
// Files link box, a paste anywhere). Enqueues via the API; its notice's "View" opens the tray on it.
|
||||||
|
// It opens on the format used last (per account): a pasted link is then one Enter away.
|
||||||
|
const parseProfile = (v: unknown): number | null => (typeof v === "number" ? v : null);
|
||||||
|
|
||||||
export default function DownloadDialog({
|
export default function DownloadDialog({
|
||||||
source,
|
source,
|
||||||
title,
|
title,
|
||||||
@@ -36,23 +41,30 @@ export default function DownloadDialog({
|
|||||||
const [manage, setManage] = useState(false);
|
const [manage, setManage] = useState(false);
|
||||||
|
|
||||||
const profiles = profilesQ.data ?? [];
|
const profiles = profilesQ.data ?? [];
|
||||||
const chosen = profileId ?? profiles[0]?.id ?? null;
|
const [lastProfile, setLastProfile] = usePref<number | null>("downloadProfile", null, {
|
||||||
|
localKey: LS.downloadProfile,
|
||||||
|
parse: parseProfile,
|
||||||
|
});
|
||||||
|
// The one used last, while it still exists (a deleted format falls back to the first).
|
||||||
|
const remembered = profiles.some((p) => p.id === lastProfile) ? lastProfile : null;
|
||||||
|
const chosen = profileId ?? remembered ?? profiles[0]?.id ?? null;
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!chosen || busy) return;
|
if (!chosen || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const displayName = name.trim() || undefined;
|
const displayName = name.trim() || undefined;
|
||||||
try {
|
try {
|
||||||
await api.enqueueDownload({
|
const job = await api.enqueueDownload({
|
||||||
source,
|
source,
|
||||||
profile_id: chosen,
|
profile_id: chosen,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
});
|
});
|
||||||
invalidateDownloads(qc);
|
invalidateDownloads(qc);
|
||||||
|
setLastProfile(chosen);
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: t("downloads.dialog.added"),
|
message: t("downloads.dialog.added"),
|
||||||
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) },
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { api, type QueueJob } from "../lib/api";
|
|||||||
|
|
||||||
const h = vi.hoisted(() => ({
|
const h = vi.hoisted(() => ({
|
||||||
jobs: [] as unknown[],
|
jobs: [] as unknown[],
|
||||||
|
updatedAt: 0,
|
||||||
openFile: vi.fn(),
|
openFile: vi.fn(),
|
||||||
share: vi.fn(async () => "https://x/watch/t"),
|
share: vi.fn(async () => "https://x/watch/t"),
|
||||||
notify: vi.fn(),
|
notify: vi.fn(),
|
||||||
@@ -27,7 +28,13 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/useLiveQuery", () => ({
|
vi.mock("../lib/useLiveQuery", () => ({
|
||||||
useLiveQuery: () => ({ data: h.jobs, isError: false, isLoading: false, refetch: vi.fn() }),
|
useLiveQuery: () => ({
|
||||||
|
data: h.jobs,
|
||||||
|
dataUpdatedAt: h.updatedAt,
|
||||||
|
isError: false,
|
||||||
|
isLoading: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
// The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How
|
// The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How
|
||||||
// usePref stores them is its own business (usePref.dom.test).
|
// usePref stores them is its own business (usePref.dom.test).
|
||||||
@@ -57,11 +64,13 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import DownloadTray from "./DownloadTray";
|
import DownloadTray from "./DownloadTray";
|
||||||
import { noteQueue, resetQueueWatch } from "../lib/downloadQueue";
|
import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue";
|
||||||
|
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetQueueWatch();
|
resetQueueWatch();
|
||||||
h.prefs.clear();
|
h.prefs.clear();
|
||||||
|
h.updatedAt = 0;
|
||||||
globalThis.ResizeObserver ??= class {
|
globalThis.ResizeObserver ??= class {
|
||||||
observe() {}
|
observe() {}
|
||||||
unobserve() {}
|
unobserve() {}
|
||||||
@@ -265,4 +274,62 @@ describe("DownloadTray", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
|
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
|
||||||
expect(document.activeElement?.id).toBe("content");
|
expect(document.activeElement?.id).toBe("content");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("View on a job just started", () => {
|
||||||
|
it("brings a closed tray back, on the job's row", async () => {
|
||||||
|
poll([job(3, "error")]);
|
||||||
|
const view = mount();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
|
||||||
|
expect(tray()).toBeNull();
|
||||||
|
act(() => showInTray(3));
|
||||||
|
view.rerender();
|
||||||
|
expect(tray()).not.toBeNull();
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(rowOf("Clip 3")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unfolds a folded tray onto the row", async () => {
|
||||||
|
poll([job(1, "running"), job(2, "queued")]);
|
||||||
|
mount();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
|
||||||
|
act(() => showInTray(2));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(rowOf("Clip 2")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asked from inside a player, it opens under it but leaves the keyboard there", async () => {
|
||||||
|
poll([job(1, "running"), job(2, "queued")]);
|
||||||
|
mount();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
|
||||||
|
const player = document.createElement("button");
|
||||||
|
document.body.append(player);
|
||||||
|
player.focus();
|
||||||
|
const layer = pushLayer();
|
||||||
|
act(() => showInTray(2));
|
||||||
|
await waitFor(() => expect(screen.getByText("Clip 2")).toBeTruthy());
|
||||||
|
expect(document.activeElement).toBe(player);
|
||||||
|
removeLayer(layer);
|
||||||
|
player.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a job done before the tray saw it run opens as a file", () => {
|
||||||
|
// The queue's first look only learns what is there: this one never ran in this tab.
|
||||||
|
poll([job(5, "done", { folder_id: 7 })]);
|
||||||
|
mount();
|
||||||
|
act(() => showInTray(5));
|
||||||
|
expect(h.openFile).toHaveBeenCalledWith(5, 7);
|
||||||
|
expect(tray()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a job a later poll does not have is let go — nothing opens, nothing waits for it", () => {
|
||||||
|
poll([job(1, "running")]);
|
||||||
|
const view = mount();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
|
||||||
|
act(() => showInTray(9));
|
||||||
|
h.updatedAt = Date.now() + 1000;
|
||||||
|
poll([job(1, "running")], view);
|
||||||
|
// Had the ask stayed, the job turning up later would pull the tray open on it.
|
||||||
|
poll([job(1, "running"), job(9, "running")], view);
|
||||||
|
expect(screen.queryByText("Clip 9")).toBeNull();
|
||||||
|
expect(h.openFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+75
-30
@@ -1,18 +1,24 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// The Download Center's queue: a row keeps its primary action (pause / resume / retry) in sight, and
|
// The tray's row menu: the copies and cancel of a waiting, running or failed job are its ONE menu,
|
||||||
// the copies and cancel are its ONE menu, opened by the row's "…" (it shows on hover or focus) or a
|
// opened by the row's "…" (it shows on hover or focus) or a right-click on the row; a failed job's
|
||||||
// right-click on the row. A finished download's row is the Files page's (Files.menu.dom.test.tsx).
|
// first row is Retry. The cancel confirm names its ACTION on its button, and speaks of what is
|
||||||
|
// cancelled (a download, an upload, a clip). The tray's own showing and done rows are
|
||||||
|
// DownloadTray.dom.test.tsx's; a finished file's menu is the Files page's (Files.menu.dom.test.tsx).
|
||||||
// i18n is key-passthrough, so labels read as keys.
|
// i18n is key-passthrough, so labels read as keys.
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { menuGroups } from "./ui/menuGroups";
|
import { menuGroups } from "./ui/menuGroups";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
jobs: [] as unknown[],
|
jobs: [] as unknown[],
|
||||||
confirm: vi.fn(async (_o?: unknown) => true),
|
confirm: vi.fn(
|
||||||
|
async (_o?: { title: string; message?: string; confirmLabel?: string; danger?: boolean }) =>
|
||||||
|
true,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
@@ -31,13 +37,18 @@ vi.mock("../lib/useLiveQuery", () => ({
|
|||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) }));
|
vi.mock("../lib/usePref", () => ({
|
||||||
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
usePref: <T,>(_key: string, initial: T) => useState<T>(initial),
|
||||||
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
|
||||||
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
}));
|
}));
|
||||||
|
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }) }));
|
||||||
|
|
||||||
import DownloadCenter from "./DownloadCenter";
|
globalThis.ResizeObserver ??= class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
|
import DownloadTray from "./DownloadTray";
|
||||||
|
|
||||||
const job = (
|
const job = (
|
||||||
id: number,
|
id: number,
|
||||||
@@ -68,6 +79,7 @@ const job = (
|
|||||||
extra_links: [],
|
extra_links: [],
|
||||||
can_download: status === "done",
|
can_download: status === "done",
|
||||||
job_kind: "download",
|
job_kind: "download",
|
||||||
|
source_key: "youtube",
|
||||||
asset: { media_kind: "video", duration_s: 60 },
|
asset: { media_kind: "video", duration_s: 60 },
|
||||||
...extra,
|
...extra,
|
||||||
}) as unknown as DownloadJob;
|
}) as unknown as DownloadJob;
|
||||||
@@ -78,25 +90,23 @@ afterEach(() => {
|
|||||||
mocks.confirm.mockClear();
|
mocks.confirm.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
function mount(tab?: string) {
|
function mount() {
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
const tree = () => (
|
const tree = () => (
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<DownloadCenter />
|
<DownloadTray />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
const { rerender } = render(tree());
|
const { rerender } = render(tree());
|
||||||
mounted.rerender = () => rerender(tree());
|
mounted.rerender = () => rerender(tree());
|
||||||
if (tab)
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${tab}`) }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The last mount's re-render: what a poll's new data does to the page.
|
// The last mount's re-render: what a poll's new data does to the tray.
|
||||||
const mounted = { rerender: () => {} };
|
const mounted = { rerender: () => {} };
|
||||||
|
|
||||||
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
||||||
const moreOf = (text: string) =>
|
const moreOf = (text: string) =>
|
||||||
rowOf(text).querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
|
rowOf(text).querySelector<HTMLElement>('button[aria-label="tray.rowMenu"]')!;
|
||||||
const menuRows = () =>
|
const menuRows = () =>
|
||||||
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||||
async function openMenu(open: () => void) {
|
async function openMenu(open: () => void) {
|
||||||
@@ -105,16 +115,10 @@ async function openMenu(open: () => void) {
|
|||||||
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const rowButtons = (text: string) =>
|
describe("DownloadTray · the row menu", () => {
|
||||||
[...rowOf(text).querySelectorAll<HTMLElement>("button, a[aria-label]")].map((b) =>
|
it("the '…' hides until hover or focus", () => {
|
||||||
b.getAttribute("aria-label"),
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("DownloadCenter · the queue row", () => {
|
|
||||||
it("shows pause and its '…' only; the '…' hides until hover or focus", async () => {
|
|
||||||
mocks.jobs = [job(1, "running")];
|
mocks.jobs = [job(1, "running")];
|
||||||
mount();
|
mount();
|
||||||
expect(rowButtons("Clip 1")).toEqual(["downloads.actions.pause", "downloads.rowMenu"]);
|
|
||||||
expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true);
|
expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true);
|
||||||
expect(rowOf("Clip 1").classList.contains("group")).toBe(true);
|
expect(rowOf("Clip 1").classList.contains("group")).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -139,15 +143,10 @@ describe("DownloadCenter · the queue row", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("draws Retry as 'do it again', never the Play glyph (row and menu)", async () => {
|
it("draws Retry as 'do it again', never the Play glyph", async () => {
|
||||||
mocks.jobs = [job(1, "error"), job(2, "paused")];
|
mocks.jobs = [job(1, "error")];
|
||||||
mount();
|
mount();
|
||||||
const glyph = (el: Element) => el.querySelector("svg")?.getAttribute("class") ?? "";
|
const glyph = (el: Element) => el.querySelector("svg")?.getAttribute("class") ?? "";
|
||||||
const retry = rowOf("Clip 1").querySelector('[aria-label="downloads.actions.retry"]')!;
|
|
||||||
expect(glyph(retry)).toContain("lucide-refresh-cw");
|
|
||||||
// A paused job resumes: that one keeps the transport glyph.
|
|
||||||
const resume = rowOf("Clip 2").querySelector('[aria-label="downloads.actions.resume"]')!;
|
|
||||||
expect(glyph(resume)).toContain("lucide-play");
|
|
||||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1")));
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1")));
|
||||||
const row = screen
|
const row = screen
|
||||||
.getAllByRole("menuitem", { hidden: true })
|
.getAllByRole("menuitem", { hidden: true })
|
||||||
@@ -211,6 +210,52 @@ describe("DownloadCenter · the queue row", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DownloadTray · the cancel confirm", () => {
|
||||||
|
const cancelOf = async (title: string) => {
|
||||||
|
mount();
|
||||||
|
await openMenu(() => fireEvent.click(moreOf(title)));
|
||||||
|
fireEvent.click(
|
||||||
|
screen
|
||||||
|
.getAllByRole("menuitem", { hidden: true })
|
||||||
|
.find((b) => b.textContent === "downloads.actions.cancel")!,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||||
|
return mocks.confirm.mock.calls[0]![0]!;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("labels its button with the action, not the dialog's question", async () => {
|
||||||
|
mocks.confirm.mockResolvedValueOnce(false);
|
||||||
|
mocks.jobs = [job(1, "running")];
|
||||||
|
const o = await cancelOf("Clip 1");
|
||||||
|
expect([o.title, o.confirmLabel]).toEqual([
|
||||||
|
"downloads.confirm.cancelTitle",
|
||||||
|
"downloads.confirm.cancelAction",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancelling an upload says the uploaded file goes with it", async () => {
|
||||||
|
// Nothing to fetch again: the cancel deletes the original from the server.
|
||||||
|
mocks.confirm.mockResolvedValueOnce(false);
|
||||||
|
mocks.jobs = [job(3, "queued", { job_kind: "upload" })];
|
||||||
|
const o = await cancelOf("Clip 3");
|
||||||
|
expect([o.title, o.confirmLabel]).toEqual([
|
||||||
|
"downloads.confirm.cancelUploadTitle",
|
||||||
|
"downloads.confirm.cancelUploadAction",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancelling a clip speaks of the clip", async () => {
|
||||||
|
mocks.confirm.mockResolvedValueOnce(false);
|
||||||
|
mocks.jobs = [job(4, "running", { job_kind: "edit" })];
|
||||||
|
const o = await cancelOf("Clip 4");
|
||||||
|
expect([o.title, o.message, o.confirmLabel]).toEqual([
|
||||||
|
"downloads.confirm.cancelEditTitle",
|
||||||
|
"downloads.confirm.cancelEditBody",
|
||||||
|
"downloads.confirm.cancelEditAction",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("menuGroups", () => {
|
describe("menuGroups", () => {
|
||||||
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
|
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
|
||||||
it("rules between non-empty groups only", () => {
|
it("rules between non-empty groups only", () => {
|
||||||
@@ -18,12 +18,15 @@ import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
|||||||
import {
|
import {
|
||||||
IN_FLIGHT,
|
IN_FLIGHT,
|
||||||
clearFinished,
|
clearFinished,
|
||||||
|
doneRevealing,
|
||||||
fetchQueue,
|
fetchQueue,
|
||||||
takeUnannounced,
|
takeUnannounced,
|
||||||
useFinished,
|
useFinished,
|
||||||
|
useRevealing,
|
||||||
useUnannounced,
|
useUnannounced,
|
||||||
} from "../lib/downloadQueue";
|
} from "../lib/downloadQueue";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { anyBlockingLayer } from "../lib/layerStack";
|
||||||
|
import { focusNavItem, navItemAttrs } from "../lib/listNav";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { LS } from "../lib/storage";
|
import { LS } from "../lib/storage";
|
||||||
@@ -57,7 +60,8 @@ type Fold = "open" | "folded";
|
|||||||
const parseFold = (v: unknown): Fold => (v === "folded" ? "folded" : "open");
|
const parseFold = (v: unknown): Fold => (v === "folded" ? "folded" : "open");
|
||||||
const parseClosed = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
const parseClosed = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
||||||
|
|
||||||
// Byte progress (a bar with a %) vs ffmpeg's post-steps (a pulse): the queue row's split.
|
// Byte progress (a bar with a %) vs a post-step that reports none (a pulse): "editing" and
|
||||||
|
// "converting" report a real % from ffmpeg's out_time.
|
||||||
const BYTE_PHASES = ["video", "audio", "editing", "converting"];
|
const BYTE_PHASES = ["video", "audio", "editing", "converting"];
|
||||||
|
|
||||||
const keyOf = (job: QueueJob) => job.id;
|
const keyOf = (job: QueueJob) => job.id;
|
||||||
@@ -132,6 +136,35 @@ export default function DownloadTray() {
|
|||||||
else if (target === "open") foldButton.current?.focus();
|
else if (target === "open") foldButton.current?.focus();
|
||||||
}, [fold]);
|
}, [fold]);
|
||||||
|
|
||||||
|
// --- "View" on a job just started: the tray opens on its row ----------------------------------
|
||||||
|
// Answered over as many renders as it takes: a closed or folded tray is brought back first, and the
|
||||||
|
// row is focused once it is on the page. A job the tray does not list is done already (it opens as
|
||||||
|
// a file), or — once a poll made after the ask has landed without it — not in the queue at all.
|
||||||
|
// Asked from inside a player (a clip's notice, the player's own download), the tray opens under it
|
||||||
|
// but leaves the keyboard there: the row is behind the player, out of the reader's sight.
|
||||||
|
const box = useRef<HTMLDivElement>(null);
|
||||||
|
const revealing = useRevealing();
|
||||||
|
const answerReveal = useEffectEvent(() => {
|
||||||
|
if (!revealing) return;
|
||||||
|
const index = rows.findIndex((j) => j.id === revealing.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
if (!shown) setClosedAt(null);
|
||||||
|
else if (fold === "folded") setFold("open");
|
||||||
|
else {
|
||||||
|
if (box.current && !anyBlockingLayer()) focusNavItem(box.current, index);
|
||||||
|
doneRevealing();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const job = queueQ.data?.find((j) => j.id === revealing.id);
|
||||||
|
if (job?.status === "done") {
|
||||||
|
doneRevealing();
|
||||||
|
openFile(job.id, job.folder_id);
|
||||||
|
} else if (queueQ.dataUpdatedAt > revealing.at) doneRevealing();
|
||||||
|
else void qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||||
|
});
|
||||||
|
useEffect(() => answerReveal(), [revealing, queueQ.data, shown, fold]);
|
||||||
|
|
||||||
// --- the done notice ------------------------------------------------------------------------
|
// --- the done notice ------------------------------------------------------------------------
|
||||||
const announce = useEffectEvent((job: QueueJob) => {
|
const announce = useEffectEvent((job: QueueJob) => {
|
||||||
const title = jobTitle(job);
|
const title = jobTitle(job);
|
||||||
@@ -153,7 +186,6 @@ export default function DownloadTray() {
|
|||||||
}, [fresh]);
|
}, [fresh]);
|
||||||
|
|
||||||
// --- Back to top steps up over the tray while it shows -----------------------------------------
|
// --- Back to top steps up over the tray while it shows -----------------------------------------
|
||||||
const box = useRef<HTMLDivElement>(null);
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = box.current;
|
const el = box.current;
|
||||||
if (!el) {
|
if (!el) {
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ export default function Files() {
|
|||||||
width: "10rem",
|
width: "10rem",
|
||||||
align: "right",
|
align: "right",
|
||||||
cardLabel: false,
|
cardLabel: false,
|
||||||
|
cardRow: true,
|
||||||
render: (f) => {
|
render: (f) => {
|
||||||
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
|
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
|
||||||
const mine = rowMenu.open && rowMenu.target?.id === f.id;
|
const mine = rowMenu.open && rowMenu.target?.id === f.id;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function LibraryPlayer({
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
|
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
|
||||||
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
|
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
|
||||||
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
|
// WatchPage/downloadRow/the tray) — NOT a re-derived container list, which would misclassify
|
||||||
// an uploaded audio file whose container is outside a hardcoded set (uploads accept any container).
|
// an uploaded audio file whose container is outside a hardcoded set (uploads accept any container).
|
||||||
// Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind.
|
// Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind.
|
||||||
const asset = job.asset;
|
const asset = job.asset;
|
||||||
|
|||||||
@@ -120,12 +120,6 @@ export default function NavSidebar({
|
|||||||
// events (the former separate bell is now folded into the inbox page).
|
// events (the former separate bell is now folded into the inbox page).
|
||||||
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
||||||
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
|
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
|
||||||
// Download center badge = items still working (queued/running/paused). Reuses the same
|
|
||||||
// lightweight per-user index the feed cards poll, so no extra request shape.
|
|
||||||
const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, {
|
|
||||||
intervalMs: 30000,
|
|
||||||
});
|
|
||||||
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
|
|
||||||
|
|
||||||
// Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook,
|
// Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook,
|
||||||
// also used by the side panels) so a high-zoom overflow scrolls without a scrollbar.
|
// also used by the side panels) so a high-zoom overflow scrolls without a scrollbar.
|
||||||
@@ -138,7 +132,6 @@ export default function NavSidebar({
|
|||||||
// badge NUMBERS are the only nav-only concern (they need the live polling queries above).
|
// badge NUMBERS are the only nav-only concern (they need the live polling queries above).
|
||||||
const BADGE: Partial<Record<Page, number>> = {
|
const BADGE: Partial<Record<Page, number>> = {
|
||||||
notifications: unread,
|
notifications: unread,
|
||||||
downloads: dlActive,
|
|
||||||
};
|
};
|
||||||
const META = (p: Page): Omit<NavItem, "page"> => ({
|
const META = (p: Page): Omit<NavItem, "page"> => ({
|
||||||
icon: moduleDef(p).icon,
|
icon: moduleDef(p).icon,
|
||||||
|
|||||||
@@ -206,6 +206,18 @@ describe("NavigationProvider · Back", () => {
|
|||||||
expect(window.location.search).toBe("?keep=1");
|
expect(window.location.search).toBe("?keep=1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lands where a retired page's content went, and the entry names that page", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.setPage("plex"));
|
||||||
|
act(() => {
|
||||||
|
window.history.replaceState({ sfPage: "downloads" }, "", "/?page=downloads&keep=1");
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "downloads" } }));
|
||||||
|
});
|
||||||
|
expect(result.current.page).toBe("files");
|
||||||
|
expect(window.history.state).toMatchObject({ sfPage: "files" });
|
||||||
|
expect(window.location.search).toBe("?page=files&keep=1");
|
||||||
|
});
|
||||||
|
|
||||||
// lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must
|
// lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must
|
||||||
// not put it back, or the next landing there (an overlay closing) would refocus a stale item.
|
// not put it back, or the next landing there (an overlay closing) would refocus a stale item.
|
||||||
it("does not put back the focus stamp the landing spent", () => {
|
it("does not put back the focus stamp the landing spent", () => {
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
|
|||||||
import { useLatest } from "../lib/useLatest";
|
import { useLatest } from "../lib/useLatest";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { isPage, urlWithParams, type Page } from "../lib/urlState";
|
import { resolvePage, urlWithParams, type Page } from "../lib/urlState";
|
||||||
import { LOGS_KEYS } from "../lib/logsUrlState";
|
import { LOGS_KEYS } from "../lib/logsUrlState";
|
||||||
import { pageTitleKey } from "../lib/pageMeta";
|
import { pageTitleKey } from "../lib/pageMeta";
|
||||||
import { useDocumentObjectTitle } from "../lib/documentTitle";
|
import { useDocumentObjectTitle } from "../lib/documentTitle";
|
||||||
import { setAccountRaw } from "../lib/storage";
|
import { setAccountRaw } from "../lib/storage";
|
||||||
import { setNavigator } from "../lib/nav";
|
|
||||||
import {
|
import {
|
||||||
backStaysInDocument,
|
backStaysInDocument,
|
||||||
closeOverlaysThenNavigate,
|
closeOverlaysThenNavigate,
|
||||||
@@ -156,9 +155,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
|
|
||||||
useEffect(() => setNavigator(setPage), [setPage]);
|
|
||||||
|
|
||||||
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
|
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
|
||||||
// DURING the render that changes the page, against the page we were last on: as an effect it ran
|
// DURING the render that changes the page, against the page we were last on: as an effect it ran
|
||||||
// after the new page had already committed, so for one frame a module rendered with a live search
|
// after the new page had already committed, so for one frame a module rendered with a live search
|
||||||
@@ -206,17 +202,18 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
function onPop(e: PopStateEvent) {
|
function onPop(e: PopStateEvent) {
|
||||||
// An entry can name a page this build no longer has (one pushed before a module was removed,
|
// An entry can name a page this build no longer has (one pushed before a module was removed,
|
||||||
// reached by Back after a reload onto the new build); that lands on the feed, not on a crash.
|
// reached by Back after a reload onto the new build); that lands where the page's content went
|
||||||
// The entry is rewritten to say so: left alone, the address bar would keep naming the dead page
|
// (`resolvePage`), else on the feed, not on a crash. The entry is rewritten to say so: left
|
||||||
// and a click on Feed would be a no-op (setPage's same-page guard). It spreads the LIVE entry,
|
// alone, the address bar would keep naming the dead page and a click on the page it landed on
|
||||||
// not `e.state`: lib/history's listener ran first and already took the spent `sfNav` off it.
|
// would be a no-op (setPage's same-page guard). It spreads the LIVE entry, not `e.state`:
|
||||||
|
// lib/history's listener ran first and already took the spent `sfNav` off it.
|
||||||
const sf: unknown = e.state?.sfPage;
|
const sf: unknown = e.state?.sfPage;
|
||||||
const p: Page = typeof sf === "string" && isPage(sf) ? sf : "feed";
|
const p: Page = (typeof sf === "string" && resolvePage(sf)) || "feed";
|
||||||
if (typeof sf === "string" && sf !== p)
|
if (typeof sf === "string" && sf !== p)
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
{ ...window.history.state, sfPage: p },
|
{ ...window.history.state, sfPage: p },
|
||||||
"",
|
"",
|
||||||
urlWithParams({ page: null }),
|
urlWithParams({ page: p === "feed" ? null : p }),
|
||||||
);
|
);
|
||||||
setPageState(p);
|
setPageState(p);
|
||||||
setAccountRaw(PAGE_KEY, p);
|
setAccountRaw(PAGE_KEY, p);
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The download formats (Settings → Downloads, and the download dialog's "Manage formats"): one aligned
|
||||||
|
// list, built-ins first, each row saying what it saves. A row's one menu: a built-in's only
|
||||||
|
// Duplicate; the user's own Edit, Duplicate and Delete (behind a confirm). A duplicate is a NEW format
|
||||||
|
// with the original's settings and a name of its own. i18n is key-passthrough.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { api, type DownloadProfile } from "../lib/api";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({ confirm: vi.fn(async (_o?: unknown) => true) }));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||||
|
|
||||||
|
import { DownloadFormats } from "./ProfileEditor";
|
||||||
|
|
||||||
|
const SPEC = {
|
||||||
|
mode: "av",
|
||||||
|
max_height: 480,
|
||||||
|
container: "mp4",
|
||||||
|
audio_format: "m4a",
|
||||||
|
vcodec: null,
|
||||||
|
embed_subs: true,
|
||||||
|
embed_chapters: false,
|
||||||
|
embed_thumbnail: false,
|
||||||
|
sponsorblock: true,
|
||||||
|
};
|
||||||
|
const PROFILES = [
|
||||||
|
{ id: 9, name: "Phone 480", is_builtin: false, spec: SPEC },
|
||||||
|
{ id: 1, name: "best", is_builtin: true, spec: { mode: "a", audio_format: "mp3" } },
|
||||||
|
] as unknown as DownloadProfile[];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
mocks.confirm.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mount() {
|
||||||
|
vi.spyOn(api, "downloadProfiles").mockResolvedValue(PROFILES);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<DownloadFormats />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByText("Phone 480");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = () => [...document.querySelectorAll<HTMLElement>("[data-nav-index]")];
|
||||||
|
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
||||||
|
const menuRows = () =>
|
||||||
|
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||||
|
const pick = (label: string) =>
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
|
||||||
|
);
|
||||||
|
async function openMenu(row: HTMLElement) {
|
||||||
|
act(() => {
|
||||||
|
fireEvent.contextMenu(row);
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DownloadFormats", () => {
|
||||||
|
it("lists the built-ins first, each saying what it saves", async () => {
|
||||||
|
await mount();
|
||||||
|
expect(rows().map((r) => r.textContent)).toEqual([
|
||||||
|
expect.stringContaining("downloads.profiles.modeA · MP3"),
|
||||||
|
expect.stringContaining(
|
||||||
|
"downloads.profiles.modeAv · ≤480p · downloads.profiles.short.subs · downloads.profiles.short.sponsorblock",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a built-in's menu only duplicates; the user's own edits, duplicates and deletes", async () => {
|
||||||
|
await mount();
|
||||||
|
await openMenu(rows()[0]!);
|
||||||
|
expect(menuRows()).toEqual(["downloads.profiles.duplicate"]);
|
||||||
|
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||||
|
await openMenu(rowOf("Phone 480"));
|
||||||
|
expect(menuRows()).toEqual([
|
||||||
|
"downloads.profiles.edit",
|
||||||
|
"downloads.profiles.duplicate",
|
||||||
|
"downloads.profiles.delete",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes only through the confirm", async () => {
|
||||||
|
const del = vi.spyOn(api, "deleteDownloadProfile").mockResolvedValue({} as never);
|
||||||
|
await mount();
|
||||||
|
mocks.confirm.mockResolvedValueOnce(false);
|
||||||
|
await openMenu(rowOf("Phone 480"));
|
||||||
|
pick("downloads.profiles.delete");
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||||
|
expect(del).not.toHaveBeenCalled();
|
||||||
|
await openMenu(rowOf("Phone 480"));
|
||||||
|
pick("downloads.profiles.delete");
|
||||||
|
await waitFor(() => expect(del).toHaveBeenCalledWith(9));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a duplicate is a new format with the original's settings and a name of its own", async () => {
|
||||||
|
const create = vi.spyOn(api, "createDownloadProfile").mockResolvedValue({} as never);
|
||||||
|
const update = vi.spyOn(api, "updateDownloadProfile").mockResolvedValue({} as never);
|
||||||
|
await mount();
|
||||||
|
await openMenu(rowOf("Phone 480"));
|
||||||
|
pick("downloads.profiles.duplicate");
|
||||||
|
const name = await screen.findByPlaceholderText("downloads.profiles.namePlaceholder");
|
||||||
|
expect((name as HTMLInputElement).value).toBe("");
|
||||||
|
fireEvent.change(name, { target: { value: "Phone copy" } });
|
||||||
|
fireEvent.click(screen.getByText("downloads.profiles.create"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(create).toHaveBeenCalledWith({
|
||||||
|
name: "Phone copy",
|
||||||
|
spec: expect.objectContaining({ max_height: 480, embed_subs: true, sponsorblock: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Edit saves over the format itself", async () => {
|
||||||
|
const update = vi.spyOn(api, "updateDownloadProfile").mockResolvedValue({} as never);
|
||||||
|
await mount();
|
||||||
|
await openMenu(rowOf("Phone 480"));
|
||||||
|
pick("downloads.profiles.edit");
|
||||||
|
const name = await screen.findByDisplayValue("Phone 480");
|
||||||
|
fireEvent.change(name, { target: { value: "Phone" } });
|
||||||
|
fireEvent.click(screen.getByText("downloads.profiles.save"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(update).toHaveBeenCalledWith(9, expect.objectContaining({ name: "Phone" })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 Modal from "./ui/Modal";
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
||||||
@@ -10,11 +11,15 @@ import { profileName } from "../lib/vocab";
|
|||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||||
import FilterSelect from "./ui/FilterSelect";
|
import FilterSelect from "./ui/FilterSelect";
|
||||||
import { IconButton } from "./ui/IconButton";
|
|
||||||
import { CheckRow } from "./ui/NativeControls";
|
import { CheckRow } from "./ui/NativeControls";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
import { SectionLabel } from "./ui/Headings";
|
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 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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles });
|
const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles });
|
||||||
const profiles = profilesQ.data ?? [];
|
// Built-ins first, then the user's own — the order the dialog's menu lists them in.
|
||||||
const builtins = profiles.filter((p) => p.is_builtin);
|
const all = profilesQ.data ?? [];
|
||||||
const mine = profiles.filter((p) => !p.is_builtin);
|
const profiles = [...all.filter((p) => p.is_builtin), ...all.filter((p) => !p.is_builtin)];
|
||||||
|
|
||||||
const [editing, setEditing] = useState<DownloadProfile | null>(null);
|
const [editing, setEditing] = useState<DownloadProfile | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -73,6 +107,13 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
setSpec({ ...BLANK, ...p.spec });
|
setSpec({ ...BLANK, ...p.spec });
|
||||||
setFormOpen(true);
|
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({
|
const save = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -106,43 +147,112 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
|
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 (
|
return (
|
||||||
<Modal
|
<div>
|
||||||
initialFocus="close"
|
{profilesQ.isError && !profilesQ.data ? (
|
||||||
title={t("downloads.profiles.title")}
|
<StateMessage tone="error" onRetry={() => void profilesQ.refetch()}>
|
||||||
onClose={onClose}
|
{t("common.loadError")}
|
||||||
maxWidth="max-w-xl"
|
</StateMessage>
|
||||||
>
|
) : !profilesQ.data ? (
|
||||||
{/* Existing formats */}
|
<LoadingState shape="list" />
|
||||||
<div className="space-y-1.5 mb-4">
|
) : (
|
||||||
<SectionLabel level={3}>{t("downloads.profiles.builtin")}</SectionLabel>
|
<div className="mb-4">
|
||||||
{builtins.map((p) => (
|
<DataTable
|
||||||
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
|
rows={profiles}
|
||||||
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
|
columns={columns}
|
||||||
<span className="flex-1 truncate">{profileName(t, p)}</span>
|
rowKey={(p) => String(p.id)}
|
||||||
</div>
|
pager={false}
|
||||||
))}
|
keyboardNav
|
||||||
{mine.length > 0 && (
|
fixedLayout
|
||||||
<SectionLabel level={3} className="pt-2">
|
rowProps={(p) => menu.triggerProps(p)}
|
||||||
{t("downloads.profiles.yours")}
|
/>
|
||||||
</SectionLabel>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mine.map((p) => (
|
<ContextMenu
|
||||||
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
|
menu={menu}
|
||||||
<span className="flex-1 truncate">{p.name}</span>
|
ariaLabel={t("downloads.profiles.rowMenu")}
|
||||||
<IconButton onClick={() => startEdit(p)} label={t("downloads.actions.rename")}>
|
items={menu.target ? items(menu.target) : []}
|
||||||
<Pencil />
|
/>
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
variant="danger"
|
|
||||||
onClick={() => onDelete(p)}
|
|
||||||
label={t("downloads.profiles.delete")}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!formOpen ? (
|
{!formOpen ? (
|
||||||
<Button variant="primary" size="sm" icon={<Plus className="w-4 h-4" />} onClick={startNew}>
|
<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
|
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`. */}
|
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
|
||||||
</div>
|
</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" && (
|
{spec.mode !== "a" && (
|
||||||
<Check
|
<Check
|
||||||
label={t("downloads.profiles.embedSubs")}
|
label={t("downloads.profiles.embedSubs")}
|
||||||
@@ -257,6 +369,20 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Settings has a Downloads tab: the download formats and the sign-ins to sites that need one, both
|
||||||
|
// preferences of the downloading. The sign-ins left the Account tab for it. i18n is key-passthrough.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
vi.mock("../lib/storage", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../lib/storage")>()),
|
||||||
|
// Opened on the Downloads tab: Appearance, the default, needs the whole prefs provider.
|
||||||
|
useTabPersistedState: () => useState("downloads"),
|
||||||
|
}));
|
||||||
|
vi.mock("../lib/useMe", () => ({
|
||||||
|
useMe: () => ({ id: 1, role: "user", email: "a@b.c", google_enabled: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("./prefsContext", () => ({ usePrefs: () => ({ saveState: "idle" }) }));
|
||||||
|
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
|
||||||
|
vi.mock("./confirmContext", () => ({ useConfirm: () => vi.fn(async () => true) }));
|
||||||
|
|
||||||
|
import SettingsPanel from "./SettingsPanel";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
vi.spyOn(api, "downloadProfiles").mockResolvedValue([]);
|
||||||
|
vi.spyOn(api, "downloadCredentials").mockResolvedValue({ hosts: [] } as never);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<SettingsPanel />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Settings · Downloads", () => {
|
||||||
|
it("holds the formats and the site sign-ins; Account no longer does", async () => {
|
||||||
|
mount();
|
||||||
|
expect(await screen.findByText("settings.downloads.formats")).toBeTruthy();
|
||||||
|
expect(screen.getByText("settings.downloadAuth.title")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByTestId("settings-tab-account"));
|
||||||
|
expect(screen.queryByText("settings.downloadAuth.title")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,18 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { formatDate, formatDateTime, formatUnit } from "../lib/format";
|
import { formatDate, formatDateTime, formatUnit } from "../lib/format";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
import {
|
||||||
|
Activity,
|
||||||
|
Bell,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
Monitor,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DownloadFormats } from "./ProfileEditor";
|
||||||
import DiagnosticsSettings from "./DiagnosticsSettings";
|
import DiagnosticsSettings from "./DiagnosticsSettings";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, HttpError, type Me } from "../lib/api";
|
import { api, HttpError, type Me } from "../lib/api";
|
||||||
@@ -50,10 +61,11 @@ export interface PrefsController {
|
|||||||
saveState: PrefsSaveState;
|
saveState: PrefsSaveState;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = "appearance" | "notifications" | "account" | "diagnostics";
|
type TabId = "appearance" | "notifications" | "downloads" | "account" | "diagnostics";
|
||||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
{ id: "appearance", icon: Monitor },
|
{ id: "appearance", icon: Monitor },
|
||||||
{ id: "notifications", icon: Bell },
|
{ id: "notifications", icon: Bell },
|
||||||
|
{ id: "downloads", icon: Download },
|
||||||
{ id: "account", icon: User },
|
{ id: "account", icon: User },
|
||||||
{ id: "diagnostics", icon: Activity },
|
{ id: "diagnostics", icon: Activity },
|
||||||
];
|
];
|
||||||
@@ -101,6 +113,7 @@ export default function SettingsPanel() {
|
|||||||
<div {...panel.panelProps(tab)} className="flex-1 min-w-0 p-4">
|
<div {...panel.panelProps(tab)} className="flex-1 min-w-0 p-4">
|
||||||
{tab === "appearance" && <Appearance prefs={prefs} />}
|
{tab === "appearance" && <Appearance prefs={prefs} />}
|
||||||
{tab === "notifications" && <Notifications prefs={prefs} />}
|
{tab === "notifications" && <Notifications prefs={prefs} />}
|
||||||
|
{tab === "downloads" && <Downloads />}
|
||||||
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
||||||
{tab === "diagnostics" && <DiagnosticsSettings />}
|
{tab === "diagnostics" && <DiagnosticsSettings />}
|
||||||
</div>
|
</div>
|
||||||
@@ -615,7 +628,7 @@ function PlexWatchSync() {
|
|||||||
// (e.g. a private Facebook group you belong to) so downloads from it use YOUR login. Write-only —
|
// (e.g. a private Facebook group you belong to) so downloads from it use YOUR login. Write-only —
|
||||||
// the stored blob is never shown back; each row shows only the host + whether it's set + when.
|
// the stored blob is never shown back; each row shows only the host + whether it's set + when.
|
||||||
// A cookie-authed download is kept private to your account (never cache-shared with another user).
|
// A cookie-authed download is kept private to your account (never cache-shared with another user).
|
||||||
// Exported for a focused DOM test (write-only behaviour); rendered inside Account below.
|
// Exported for a focused DOM test (write-only behaviour); rendered on the Downloads tab below.
|
||||||
export function DownloadAuth() {
|
export function DownloadAuth() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -796,6 +809,23 @@ export function AccountHeader({ me }: { me: Me }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How this account downloads: its formats (the download dialog's choices) and its sign-ins to sites
|
||||||
|
// that need one. Preferences of the downloading, not of the account's identity.
|
||||||
|
function Downloads() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section title={t("settings.downloads.formats")}>
|
||||||
|
<p className="text-xs text-muted leading-relaxed mb-2">
|
||||||
|
{t("settings.downloads.formatsIntro")}
|
||||||
|
</p>
|
||||||
|
<DownloadFormats />
|
||||||
|
</Section>
|
||||||
|
<DownloadAuth />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -825,8 +855,6 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||||||
|
|
||||||
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
||||||
|
|
||||||
<DownloadAuth />
|
|
||||||
|
|
||||||
{me.google_enabled ? (
|
{me.google_enabled ? (
|
||||||
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
|
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
|
||||||
<Section title={t("settings.account.youtubeAccess")}>
|
<Section title={t("settings.account.youtubeAccess")}>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const h = vi.hoisted(() => ({
|
|||||||
// user's bandwidth on it. Generous by default; the cap test below lowers it.
|
// user's bandwidth on it. Generous by default; the cap test below lowers it.
|
||||||
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
||||||
notify: vi.fn(),
|
notify: vi.fn(),
|
||||||
navigateTo: vi.fn(),
|
showInTray: vi.fn(),
|
||||||
openFile: vi.fn(),
|
openFile: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
|||||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||||
notify: h.notify,
|
notify: h.notify,
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo }));
|
vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
|
||||||
|
|
||||||
import UploadDialog from "./UploadDialog";
|
import UploadDialog from "./UploadDialog";
|
||||||
import { FilesActionsContext } from "./filesContext";
|
import { FilesActionsContext } from "./filesContext";
|
||||||
@@ -67,7 +67,7 @@ const fileInput = () => document.querySelector('input[type="file"]') as HTMLInpu
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
h.uploadFile.mockClear();
|
h.uploadFile.mockClear();
|
||||||
h.openFile.mockClear();
|
h.openFile.mockClear();
|
||||||
h.navigateTo.mockClear();
|
h.showInTray.mockClear();
|
||||||
h.notify.mockClear();
|
h.notify.mockClear();
|
||||||
h.filesUsage.mockClear();
|
h.filesUsage.mockClear();
|
||||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
||||||
@@ -115,8 +115,7 @@ describe("UploadDialog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
|
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
|
||||||
// Done: Files opens on the file itself, in its folder. Still converting: it is in the download
|
// Done: Files opens on the file itself, in its folder. Still converting: the tray opens on it.
|
||||||
// queue until it is done.
|
|
||||||
h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never);
|
h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never);
|
||||||
wrap(vi.fn(), 5);
|
wrap(vi.fn(), 5);
|
||||||
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } });
|
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } });
|
||||||
@@ -125,7 +124,7 @@ describe("UploadDialog", () => {
|
|||||||
expect((h.uploadFile.mock.calls[0]![1] as { folderId?: number }).folderId).toBe(5);
|
expect((h.uploadFile.mock.calls[0]![1] as { folderId?: number }).folderId).toBe(5);
|
||||||
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
||||||
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
|
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
|
||||||
expect(h.navigateTo).not.toHaveBeenCalled();
|
expect(h.showInTray).not.toHaveBeenCalled();
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
h.notify.mockClear();
|
h.notify.mockClear();
|
||||||
@@ -135,7 +134,7 @@ describe("UploadDialog", () => {
|
|||||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||||
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
||||||
expect(h.navigateTo).toHaveBeenLastCalledWith("downloads");
|
expect(h.showInTray).toHaveBeenLastCalledWith(43);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens ready to send a file dropped on the page", async () => {
|
it("opens ready to send a file dropped on the page", async () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { isAborted } from "../lib/api/core";
|
|||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { navigateTo } from "../lib/nav";
|
import { showInTray } from "../lib/downloadQueue";
|
||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../lib/api";
|
||||||
import { useFilesActions } from "./filesContext";
|
import { useFilesActions } from "./filesContext";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
@@ -68,11 +68,10 @@ export default function UploadDialog({
|
|||||||
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
|
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
|
||||||
const [pickSeq, setPickSeq] = useState(0);
|
const [pickSeq, setPickSeq] = useState(0);
|
||||||
const { openFile } = useFilesActions();
|
const { openFile } = useFilesActions();
|
||||||
// A done file is in Files, opened on it; one the worker still converts is in the download queue
|
// A done file is in Files, opened on it; one the worker still converts is in the tray until it is.
|
||||||
// until it is.
|
|
||||||
const openUploaded = (job: DownloadJob) => {
|
const openUploaded = (job: DownloadJob) => {
|
||||||
if (job.status === "done") openFile(job.id, job.folder_id ?? null);
|
if (job.status === "done") openFile(job.id, job.folder_id ?? null);
|
||||||
else navigateTo("downloads");
|
else showInTray(job.id);
|
||||||
};
|
};
|
||||||
const pick_ = (f: File) => {
|
const pick_ = (f: File) => {
|
||||||
setFile(f);
|
setFile(f);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Clapperboard,
|
Clapperboard,
|
||||||
Clock,
|
Clock,
|
||||||
Code2,
|
Code2,
|
||||||
Download,
|
FolderOpen,
|
||||||
Expand,
|
Expand,
|
||||||
Filter,
|
Filter,
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -182,7 +182,7 @@ const GALLERY: { key: string; src: string }[] = [
|
|||||||
{ key: "playlists", src: "/welcome/playlists.webp" },
|
{ key: "playlists", src: "/welcome/playlists.webp" },
|
||||||
{ key: "search", src: "/welcome/search.webp" },
|
{ key: "search", src: "/welcome/search.webp" },
|
||||||
{ key: "player", src: "/welcome/player.webp" },
|
{ key: "player", src: "/welcome/player.webp" },
|
||||||
{ key: "downloads", src: "/welcome/downloads.webp" },
|
{ key: "files", src: "/welcome/files.webp" },
|
||||||
{ key: "plex", src: "/welcome/plex.webp" },
|
{ key: "plex", src: "/welcome/plex.webp" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ const FEATURES: { key: string; icon: LucideIcon }[] = [
|
|||||||
{ key: "search", icon: Search },
|
{ key: "search", icon: Search },
|
||||||
{ key: "playlists", icon: ListVideo },
|
{ key: "playlists", icon: ListVideo },
|
||||||
{ key: "player", icon: PlayCircle },
|
{ key: "player", icon: PlayCircle },
|
||||||
{ key: "downloads", icon: Download },
|
{ key: "files", icon: FolderOpen },
|
||||||
{ key: "plex", icon: Clapperboard },
|
{ key: "plex", icon: Clapperboard },
|
||||||
{ key: "private", icon: Lock },
|
{ key: "private", icon: Lock },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
|||||||
width: "3.25rem",
|
width: "3.25rem",
|
||||||
nowrap: true,
|
nowrap: true,
|
||||||
cardLabel: false,
|
cardLabel: false,
|
||||||
|
cardRow: true,
|
||||||
// The row's full-history, hide and unsubscribe buttons went into the menu: the row
|
// The row's full-history, hide and unsubscribe buttons went into the menu: the row
|
||||||
// keeps what is read or tuned all the time — priority, tags, the ✓ — and ends in its "…".
|
// keeps what is read or tuned all the time — priority, tags, the ✓ — and ends in its "…".
|
||||||
render: more,
|
render: more,
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The admin Users page's Storage tab: every user's footprint against their OWN limit (or
|
||||||
|
// "Unlimited") with their file count, and every user's downloads — each list's row has one menu.
|
||||||
|
// A user's: Edit quota, Reset (behind a confirm). A download's: Open source, the copies, and Edit
|
||||||
|
// this user's quota — which opens the quota of the download's OWNER. i18n is key-passthrough.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { api, type AdminStorage, type DownloadJob } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
storage: undefined as unknown,
|
||||||
|
jobs: undefined as unknown,
|
||||||
|
confirm: vi.fn(async (_o?: unknown) => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({
|
||||||
|
i18n: { language: "en" },
|
||||||
|
t: (key: string, o?: { size?: string; email?: string }) =>
|
||||||
|
`${key}${o?.size ? `:${o.size}` : ""}${o?.email ? `:${o.email}` : ""}`,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||||
|
vi.mock("../lib/useLiveQuery", () => ({
|
||||||
|
useLiveQuery: (key: readonly unknown[]) => ({
|
||||||
|
data: key[0] === qk.adminStorage()[0] ? mocks.storage : mocks.jobs,
|
||||||
|
isError: false,
|
||||||
|
isLoading: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { StorageTab } from "./downloadAdmin";
|
||||||
|
|
||||||
|
const GiB = 1024 ** 3;
|
||||||
|
|
||||||
|
const storage = (): AdminStorage => ({
|
||||||
|
files_on_disk: 3,
|
||||||
|
total_bytes: 3 * GiB,
|
||||||
|
total_assets: 3,
|
||||||
|
total_cap_bytes: 0,
|
||||||
|
per_user: [
|
||||||
|
{
|
||||||
|
user_id: 4,
|
||||||
|
email: "anna@x",
|
||||||
|
footprint_bytes: GiB,
|
||||||
|
files: 27,
|
||||||
|
max_bytes: 5 * GiB,
|
||||||
|
unlimited: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: 1,
|
||||||
|
email: "peter@x",
|
||||||
|
footprint_bytes: 2 * GiB,
|
||||||
|
files: 180,
|
||||||
|
// Unlimited wins over the limit the row still carries.
|
||||||
|
max_bytes: 10 * GiB,
|
||||||
|
unlimited: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = (id: number, extra: Partial<DownloadJob> = {}) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
status: "done",
|
||||||
|
display_name: `Clip ${id}`,
|
||||||
|
source_url: `https://example.com/${id}`,
|
||||||
|
user_id: 4,
|
||||||
|
user_email: "anna@x",
|
||||||
|
asset: { size_bytes: 1024 },
|
||||||
|
...extra,
|
||||||
|
}) as unknown as DownloadJob;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
mocks.confirm.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
mocks.storage = storage();
|
||||||
|
mocks.jobs = [job(7), job(8, { source_url: null, user_id: 1, user_email: "peter@x" })];
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<StorageTab />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowOf = (text: string) =>
|
||||||
|
screen.getAllByText(text)[0]!.closest<HTMLElement>("[data-nav-index]")!;
|
||||||
|
const menuRows = () =>
|
||||||
|
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||||
|
const pick = (label: string) =>
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
|
||||||
|
);
|
||||||
|
async function openMenu(row: HTMLElement) {
|
||||||
|
act(() => {
|
||||||
|
fireEvent.contextMenu(row);
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StorageTab", () => {
|
||||||
|
it("names each user's use against their own limit, and their files", () => {
|
||||||
|
mount();
|
||||||
|
const anna = rowOf("anna@x");
|
||||||
|
expect(anna.textContent).toMatch(/downloads\.admin\.ofQuota:5(\.0)? GB/);
|
||||||
|
expect(anna.textContent).toContain("27");
|
||||||
|
expect(anna.querySelector('[role="meter"]')?.getAttribute("aria-valuenow")).toBe("20");
|
||||||
|
expect(rowOf("peter@x").textContent).toContain("downloads.admin.unlimitedShort");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a user's menu edits their quota, and resets it only through the confirm", async () => {
|
||||||
|
vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never);
|
||||||
|
const reset = vi.spyOn(api, "adminResetDownloadQuota").mockResolvedValue({} as never);
|
||||||
|
mount();
|
||||||
|
await openMenu(rowOf("anna@x"));
|
||||||
|
expect(menuRows()).toEqual(["downloads.admin.editQuota", "downloads.admin.reset"]);
|
||||||
|
mocks.confirm.mockResolvedValueOnce(false);
|
||||||
|
pick("downloads.admin.reset");
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||||
|
expect(reset).not.toHaveBeenCalled();
|
||||||
|
await openMenu(rowOf("anna@x"));
|
||||||
|
pick("downloads.admin.reset");
|
||||||
|
await waitFor(() => expect(reset).toHaveBeenCalledWith(4));
|
||||||
|
await openMenu(rowOf("anna@x"));
|
||||||
|
pick("downloads.admin.editQuota");
|
||||||
|
expect(await screen.findByText("downloads.admin.quotaTitle:anna@x")).toBeTruthy();
|
||||||
|
expect(api.adminGetDownloadQuota).toHaveBeenCalledWith(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a download's menu opens its source, copies, and opens its OWNER's quota", async () => {
|
||||||
|
vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never);
|
||||||
|
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||||||
|
mount();
|
||||||
|
await openMenu(rowOf("Clip 7"));
|
||||||
|
expect(menuRows()).toEqual([
|
||||||
|
"downloads.admin.openSource",
|
||||||
|
"common.copy.title",
|
||||||
|
"common.copy.link",
|
||||||
|
"downloads.admin.editUserQuota",
|
||||||
|
]);
|
||||||
|
pick("downloads.admin.openSource");
|
||||||
|
expect(open).toHaveBeenCalledWith("https://example.com/7", "_blank", "noopener,noreferrer");
|
||||||
|
// One with no source link offers neither the link nor its copy.
|
||||||
|
await openMenu(rowOf("Clip 8"));
|
||||||
|
expect(menuRows()).toEqual(["common.copy.title", "downloads.admin.editUserQuota"]);
|
||||||
|
pick("downloads.admin.editUserQuota");
|
||||||
|
await waitFor(() => expect(api.adminGetDownloadQuota).toHaveBeenCalledWith(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,23 +2,33 @@ import { useState } from "react";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Settings2 } from "lucide-react";
|
import clsx from "clsx";
|
||||||
|
import { ExternalLink, RotateCcw, Settings2 } from "lucide-react";
|
||||||
import Modal, { ModalFooter } from "./ui/Modal";
|
import Modal, { ModalFooter } from "./ui/Modal";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { api } from "../lib/api";
|
import { api, type AdminStorageUser, type DownloadJob } from "../lib/api";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { StatusPill } from "./downloadRow";
|
import { StatusPill } from "./downloadRow";
|
||||||
import { jobTitle } from "./downloadTitle";
|
import { jobTitle } from "./downloadTitle";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { useConfirm } from "./confirmContext";
|
||||||
|
import { useCopyItems } from "./copyItems";
|
||||||
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
|
import DataTable, { type Column } from "./ui/DataTable";
|
||||||
|
import { Section } from "./ui/form";
|
||||||
|
import { menuGroups } from "./ui/menuGroups";
|
||||||
|
import { MoreButton } from "./ui/MoreButton";
|
||||||
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
import { CheckRow } from "./ui/NativeControls";
|
import { CheckRow } from "./ui/NativeControls";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import UserPicker from "./UserPicker";
|
import UserPicker from "./UserPicker";
|
||||||
|
|
||||||
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
|
// The admin Users page's "Storage" tab: the disk in three numbers, every user's footprint against
|
||||||
// list. Split out of DownloadCenter (self-contained, admin-only surface).
|
// their own limit (the quota editor), and every user's downloads. Both lists are tables with aligned
|
||||||
|
// columns and one menu per row (its "…", a right-click, a long-press, Shift+F10).
|
||||||
|
|
||||||
const GiB = 1024 ** 3;
|
const GiB = 1024 ** 3;
|
||||||
|
|
||||||
@@ -173,18 +183,201 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export function StorageTab() {
|
||||||
export function AdminSystem() {
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const copy = useCopyItems();
|
||||||
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
|
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
|
||||||
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
|
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
|
||||||
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
||||||
const s = storage.data;
|
const s = storage.data;
|
||||||
|
|
||||||
|
const reset = useMutation({
|
||||||
|
mutationFn: (userId: number) => api.adminResetDownloadQuota(userId),
|
||||||
|
onSuccess: (_d, userId) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.adminStorage() });
|
||||||
|
},
|
||||||
|
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
|
||||||
|
});
|
||||||
|
const onReset = async (u: AdminStorageUser) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("downloads.admin.resetTitle"),
|
||||||
|
message: t("downloads.admin.resetBody", { email: u.email ?? "" }),
|
||||||
|
confirmLabel: t("downloads.admin.reset"),
|
||||||
|
});
|
||||||
|
if (ok) reset.mutate(u.user_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- every user ------------------------------------------------------------------------------
|
||||||
|
const userMenu = useContextMenu<AdminStorageUser>({ keyOf: (u) => u.user_id });
|
||||||
|
const userItems = (u: AdminStorageUser): ActionMenuItem[] => [
|
||||||
|
{
|
||||||
|
id: "quota",
|
||||||
|
label: t("downloads.admin.editQuota"),
|
||||||
|
icon: <Settings2 className="w-4 h-4" />,
|
||||||
|
onSelect: () => setQuotaFor({ id: u.user_id, email: u.email ?? "" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reset",
|
||||||
|
label: t("downloads.admin.reset"),
|
||||||
|
icon: <RotateCcw className="w-4 h-4" />,
|
||||||
|
onSelect: () => void onReset(u),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// The bar sits beside the name and the number after it: a right-aligned "Used" before a
|
||||||
|
// left-aligned bar ran the two headers together into one phrase.
|
||||||
|
const userColumns: Column<AdminStorageUser>[] = [
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
header: t("downloads.admin.columns.user"),
|
||||||
|
cardPrimary: true,
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (u) => u.email ?? "",
|
||||||
|
render: (u) => <span className="block truncate">{u.email}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "quota",
|
||||||
|
header: t("downloads.admin.columns.quota"),
|
||||||
|
width: "9rem",
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (u) => (u.unlimited || !u.max_bytes ? -1 : u.footprint_bytes / u.max_bytes),
|
||||||
|
render: (u) => <QuotaFill user={u} lang={lang} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "used",
|
||||||
|
header: t("downloads.admin.columns.used"),
|
||||||
|
width: "6.5rem",
|
||||||
|
align: "right",
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (u) => u.footprint_bytes,
|
||||||
|
render: (u) => <span className="tabular-nums">{formatBytes(u.footprint_bytes, lang)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "files",
|
||||||
|
header: t("downloads.admin.columns.files"),
|
||||||
|
width: "4.5rem",
|
||||||
|
align: "right",
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (u) => u.files,
|
||||||
|
render: (u) => <span className="tabular-nums">{u.files}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "menu",
|
||||||
|
header: "",
|
||||||
|
width: "3rem",
|
||||||
|
align: "right",
|
||||||
|
cardLabel: false,
|
||||||
|
cardRow: true,
|
||||||
|
render: (u) => (
|
||||||
|
<MoreButton
|
||||||
|
label={t("downloads.admin.userMenu")}
|
||||||
|
size="md"
|
||||||
|
expanded={userMenu.open && userMenu.target?.user_id === u.user_id}
|
||||||
|
menuProps={userMenu.buttonProps(u)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- every download ----------------------------------------------------------------------------
|
||||||
|
const jobMenu = useContextMenu<DownloadJob>({ keyOf: (j) => j.id });
|
||||||
|
const jobItems = (j: DownloadJob): ActionMenuItem[] => {
|
||||||
|
const source = j.source_url;
|
||||||
|
const owner = j.user_id;
|
||||||
|
return menuGroups(
|
||||||
|
source
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "open",
|
||||||
|
label: t("downloads.admin.openSource"),
|
||||||
|
icon: <ExternalLink className="w-4 h-4" />,
|
||||||
|
// A new tab only opens inside the click (a popup blocker refuses one a task later).
|
||||||
|
inGesture: true,
|
||||||
|
onSelect: () => void window.open(source, "_blank", "noopener,noreferrer"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
copy(jobTitle(j), source),
|
||||||
|
owner != null
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "quota",
|
||||||
|
label: t("downloads.admin.editUserQuota"),
|
||||||
|
icon: <Settings2 className="w-4 h-4" />,
|
||||||
|
onSelect: () => setQuotaFor({ id: owner, email: j.user_email ?? "" }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const jobColumns: Column<DownloadJob>[] = [
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
header: t("downloads.admin.columns.title"),
|
||||||
|
cardPrimary: true,
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (j) => jobTitle(j),
|
||||||
|
render: (j) => (
|
||||||
|
<span className="block truncate" title={jobTitle(j)}>
|
||||||
|
{jobTitle(j)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
header: t("downloads.admin.columns.user"),
|
||||||
|
width: "11rem",
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (j) => j.user_email ?? "",
|
||||||
|
narrowHidden: true,
|
||||||
|
render: (j) => <span className="block truncate text-muted">{j.user_email}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: t("downloads.admin.columns.status"),
|
||||||
|
width: "7rem",
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (j) => j.status,
|
||||||
|
render: (j) => <StatusPill job={j} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "size",
|
||||||
|
header: t("downloads.admin.columns.size"),
|
||||||
|
width: "6rem",
|
||||||
|
align: "right",
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (j) => j.asset?.size_bytes ?? 0,
|
||||||
|
render: (j) => (
|
||||||
|
<span className="tabular-nums text-muted">
|
||||||
|
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "menu",
|
||||||
|
header: "",
|
||||||
|
width: "3rem",
|
||||||
|
align: "right",
|
||||||
|
cardLabel: false,
|
||||||
|
cardRow: true,
|
||||||
|
render: (j) => (
|
||||||
|
<MoreButton
|
||||||
|
label={t("downloads.admin.jobMenu")}
|
||||||
|
size="md"
|
||||||
|
expanded={jobMenu.open && jobMenu.target?.id === j.id}
|
||||||
|
menuProps={jobMenu.buttonProps(j)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{s && (
|
{s && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-4">
|
||||||
{[
|
{[
|
||||||
[t("downloads.admin.filesOnDisk"), String(s.files_on_disk)],
|
[t("downloads.admin.filesOnDisk"), String(s.files_on_disk)],
|
||||||
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes, lang)],
|
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes, lang)],
|
||||||
@@ -195,65 +388,103 @@ export function AdminSystem() {
|
|||||||
].map(([label, val]) => (
|
].map(([label, val]) => (
|
||||||
<div key={label} className={`${SURFACE.card} p-3`}>
|
<div key={label} className={`${SURFACE.card} p-3`}>
|
||||||
<div className="text-xs text-muted">{label}</div>
|
<div className="text-xs text-muted">{label}</div>
|
||||||
<div className="text-lg font-semibold mt-0.5">{val}</div>
|
<div className="text-lg font-semibold mt-0.5 tabular-nums">{val}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{s && (
|
<Section card title={t("downloads.admin.perUser")}>
|
||||||
<div className="mb-6">
|
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
|
||||||
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
<div className="mb-3 flex justify-end">
|
||||||
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
|
<QuotaUserPicker onPick={setQuotaFor} />
|
||||||
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
|
|
||||||
<QuotaUserPicker onPick={setQuotaFor} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{s.per_user.map((u) => (
|
|
||||||
<div
|
|
||||||
key={u.user_id}
|
|
||||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
|
||||||
>
|
|
||||||
<span className="flex-1 truncate">{u.email}</span>
|
|
||||||
<span className="text-muted">{formatBytes(u.footprint_bytes, lang)}</span>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
|
||||||
label={t("downloads.admin.editQuota")}
|
|
||||||
>
|
|
||||||
<Settings2 />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{s.per_user.length === 0 && (
|
|
||||||
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{storage.isError && !s ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => void storage.refetch()}>
|
||||||
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
|
{t("common.loadError")}
|
||||||
<div className="space-y-1.5">
|
</StateMessage>
|
||||||
{(jobs.data ?? []).map((j) => (
|
) : !s ? (
|
||||||
<div
|
<LoadingState shape="list" />
|
||||||
key={j.id}
|
) : (
|
||||||
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
<div className="@container">
|
||||||
>
|
<DataTable
|
||||||
<span className="flex-1 truncate">{jobTitle(j)}</span>
|
rows={s.per_user}
|
||||||
<span className="text-muted truncate w-40">{j.user_email}</span>
|
columns={userColumns}
|
||||||
<StatusPill job={j} />
|
rowKey={(u) => String(u.user_id)}
|
||||||
<span className="text-muted w-16 text-right">
|
pageSize={25}
|
||||||
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : ""}
|
keyboardNav
|
||||||
</span>
|
fixedLayout
|
||||||
|
rowProps={(u) => userMenu.triggerProps(u)}
|
||||||
|
emptyText={t("downloads.admin.noFootprint")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
{jobs.data && jobs.data.length === 0 && (
|
|
||||||
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
|
<Section card title={t("downloads.admin.allDownloads")}>
|
||||||
|
{jobs.isError && !jobs.data ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => void jobs.refetch()}>
|
||||||
|
{t("common.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : !jobs.data ? (
|
||||||
|
<LoadingState shape="list" />
|
||||||
|
) : (
|
||||||
|
<div className="@container">
|
||||||
|
<DataTable
|
||||||
|
rows={jobs.data}
|
||||||
|
columns={jobColumns}
|
||||||
|
rowKey={(j) => String(j.id)}
|
||||||
|
pageSize={25}
|
||||||
|
keyboardNav
|
||||||
|
fixedLayout
|
||||||
|
rowProps={(j) => jobMenu.triggerProps(j)}
|
||||||
|
emptyText={t("downloads.empty.admin")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<ContextMenu
|
||||||
|
menu={userMenu}
|
||||||
|
ariaLabel={t("downloads.admin.userMenu")}
|
||||||
|
items={userMenu.target ? userItems(userMenu.target) : []}
|
||||||
|
/>
|
||||||
|
<ContextMenu
|
||||||
|
menu={jobMenu}
|
||||||
|
ariaLabel={t("downloads.admin.jobMenu")}
|
||||||
|
items={jobMenu.target ? jobItems(jobMenu.target) : []}
|
||||||
|
/>
|
||||||
{quotaFor && (
|
{quotaFor && (
|
||||||
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
|
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How full a user is against their own limit: a bar, and the limit under it. */
|
||||||
|
function QuotaFill({ user, lang }: { user: AdminStorageUser; lang: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (user.unlimited || !user.max_bytes)
|
||||||
|
return <span className="text-xs text-muted">{t("downloads.admin.unlimitedShort")}</span>;
|
||||||
|
const pct = Math.min(100, Math.round((user.footprint_bytes / user.max_bytes) * 100));
|
||||||
|
return (
|
||||||
|
<span className="flex flex-col gap-1">
|
||||||
|
<span
|
||||||
|
className="h-1.5 overflow-hidden rounded-full bg-fg/10"
|
||||||
|
role="meter"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={pct}
|
||||||
|
aria-label={t("downloads.admin.columns.quota")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={clsx("block h-full rounded-full", pct >= 90 ? "bg-danger" : "bg-accent")}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-2xs text-muted tabular-nums">
|
||||||
|
{t("downloads.admin.ofQuota", { size: formatBytes(user.max_bytes, lang) })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { IconBtn } from "./downloadRow";
|
|||||||
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
|
|
||||||
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
// Per-item user modals (self-contained, prop-driven): a library file's details (Files) and a failed
|
||||||
// editing and the failure-detail window.
|
// download's reasons (the tray).
|
||||||
|
|
||||||
// Edit a library file's details: its channel (name + link) and extra reference links. Display-only
|
// Edit a library file's details: its channel (name + link) and extra reference links. Display-only
|
||||||
// overrides — the on-disk file is never touched. Its name is renamed in place in the Files list.
|
// overrides — the on-disk file is never touched. Its name is renamed in place in the Files list.
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// Library-row metadata: the completed row surfaces the concrete container/format alongside the
|
// A job's picture (the Files list and grid): with none at all, a quiet glyph of what it is; a remote
|
||||||
// already-shown size. The format chip reads the raw `asset.container` (uppercased in the UI), so it must
|
// thumbnail that fails falls back to the same glyph. i18n is key-passthrough here.
|
||||||
// render when present and be absent when the asset has no container yet (queued/running before the worker
|
|
||||||
// fills it). i18n is key-passthrough here.
|
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
import { render, cleanup, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -15,7 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { DownloadRow, IconBtn } from "./downloadRow";
|
import { JobThumb } from "./downloadRow";
|
||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../lib/api";
|
||||||
|
|
||||||
const baseJob: DownloadJob = {
|
const baseJob: DownloadJob = {
|
||||||
@@ -82,201 +80,15 @@ const withAsset = (
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe("DownloadRow format chip", () => {
|
|
||||||
it("shows the uppercased container next to the size when ready", () => {
|
|
||||||
render(<DownloadRow job={withAsset("mp4")} />);
|
|
||||||
// Raw container is stored lowercase; the row uppercases it via CSS, so the text node stays "mp4".
|
|
||||||
expect(screen.getByText(/·\s*mp4/i)).toBeTruthy();
|
|
||||||
expect(screen.getByText(/142 MB/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the format chip when the asset has no container yet", () => {
|
|
||||||
const { container } = render(<DownloadRow job={withAsset(null)} />);
|
|
||||||
expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A generic uploaded file (media_kind='file') has no video frame and no timeline: the row
|
|
||||||
// shows a neutral file glyph instead of a thumbnail <img>, and no duration chip (it stays media info
|
|
||||||
// like the container/size). Media rows keep their thumbnail path (an <img>, or empty until a poster).
|
|
||||||
describe("DownloadRow file kind", () => {
|
|
||||||
it("renders a file glyph, not a thumbnail <img>, for a generic file", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<DownloadRow
|
|
||||||
job={withAsset("pdf", { media_kind: "file", duration_s: null, size_bytes: 2_400_000 })}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// The file glyph is an inline svg; no <img> element is emitted for a file.
|
|
||||||
expect(container.querySelector("img")).toBeNull();
|
|
||||||
expect(container.querySelector("svg")).not.toBeNull();
|
|
||||||
// File info still reads (container + size), but there is no duration chip.
|
|
||||||
expect(screen.getByText(/·\s*pdf/i)).toBeTruthy();
|
|
||||||
expect(screen.getByText(/2\.3 MB/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the thumbnail path (an <img> when a poster exists) for a media asset", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<DownloadRow job={{ ...withAsset("mp4"), poster_url: "/api/downloads/1/poster.jpg" }} />,
|
|
||||||
);
|
|
||||||
expect(container.querySelector("img")).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("DownloadRow · keyboard", () => {
|
|
||||||
it("is a list item only when it is given a position", () => {
|
|
||||||
const { container, rerender } = render(<DownloadRow job={baseJob} navIndex={3} />);
|
|
||||||
expect(container.firstElementChild?.getAttribute("data-nav-index")).toBe("3");
|
|
||||||
rerender(<DownloadRow job={baseJob} />);
|
|
||||||
expect(container.firstElementChild?.hasAttribute("data-nav-index")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks the action that opens the row, and only that one", () => {
|
|
||||||
render(
|
|
||||||
<DownloadRow job={baseJob} navIndex={0}>
|
|
||||||
<IconBtn open onClick={() => {}} title="play">
|
|
||||||
p
|
|
||||||
</IconBtn>
|
|
||||||
<IconBtn onClick={() => {}} title="delete">
|
|
||||||
d
|
|
||||||
</IconBtn>
|
|
||||||
</DownloadRow>,
|
|
||||||
);
|
|
||||||
expect(screen.getByTitle("play").hasAttribute("data-nav-open")).toBe(true);
|
|
||||||
expect(screen.getByTitle("delete").hasAttribute("data-nav-open")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The row once resized its actions with a `[&_button]:size-8` override, which a save LINK in the
|
|
||||||
// same row escaped (28px beside 32px buttons). The size is the primitive's own rung, set by IconBtn.
|
|
||||||
it("sizes its actions through the primitive's rung, not a descendant override", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<DownloadRow job={baseJob}>
|
|
||||||
<IconBtn onClick={() => {}} title="play">
|
|
||||||
p
|
|
||||||
</IconBtn>
|
|
||||||
</DownloadRow>,
|
|
||||||
);
|
|
||||||
expect(screen.getByTitle("play").className).toContain("size-8");
|
|
||||||
expect(container.innerHTML).not.toContain("[&_button]");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The row answers to its own width (a container query) and its meta line never wraps — on
|
|
||||||
// the iPad, zoomed in, its "18.9 / MB" pieces broke into a jumble (the owner, 2026-09-27). jsdom lays
|
|
||||||
// nothing out, so this holds the structure the layout rests on; the widths were measured in a browser.
|
|
||||||
describe("DownloadRow · narrow", () => {
|
|
||||||
it("keeps the meta line one cut line of inline text, not a flex row that wraps its pieces", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<DownloadRow job={{ ...withAsset("mp4"), created_at: "2026-09-20T10:00:00Z" }} />,
|
|
||||||
);
|
|
||||||
const meta = container.querySelector("[data-dl-meta]")!;
|
|
||||||
expect(meta.className.split(/\s+/)).toContain("truncate");
|
|
||||||
expect(meta.className).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/);
|
|
||||||
for (const el of meta.querySelectorAll("*"))
|
|
||||||
expect(el.className.toString()).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is its own size container and stacks below 40rem", () => {
|
|
||||||
const { container } = render(<DownloadRow job={withAsset("mp4")} />);
|
|
||||||
const row = container.firstElementChild as HTMLElement;
|
|
||||||
expect(row.className).toContain("@container/dlrow");
|
|
||||||
const grid = row.firstElementChild as HTMLElement;
|
|
||||||
expect(grid.className).toContain(
|
|
||||||
"@max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stacked, puts the source link and the actions on ONE wrapping line, not a line each", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<DownloadRow job={{ ...withAsset("mp4"), source_url: "https://example.com/v" }}>
|
|
||||||
<IconBtn onClick={() => {}} title="play">
|
|
||||||
x
|
|
||||||
</IconBtn>
|
|
||||||
</DownloadRow>,
|
|
||||||
);
|
|
||||||
const link = container.querySelector("a[href='https://example.com/v']")!;
|
|
||||||
const foot = screen.getByTitle("play").closest(".contents")!;
|
|
||||||
expect(foot.contains(link)).toBe(true);
|
|
||||||
for (const c of ["@max-[40rem]/dlrow:[grid-area:foot]", "@max-[40rem]/dlrow:flex-wrap"])
|
|
||||||
expect(foot.className).toContain(c);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives the failure link a real 24px line on a mouse, not the text's 20 (DESIGN B1)", () => {
|
|
||||||
render(
|
|
||||||
<DownloadRow job={{ ...baseJob, status: "error", error: "boom" }} onShowError={() => {}} />,
|
|
||||||
);
|
|
||||||
const link = screen.getByTitle("downloads.errors.fail.title");
|
|
||||||
const cls = link.className.split(/\s+/);
|
|
||||||
expect(cls).toEqual(expect.arrayContaining(["inline-block", "min-h-6", "leading-6"]));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("draws nothing for an empty link list (a bare 0 once leaked into the row)", () => {
|
|
||||||
const { container } = render(<DownloadRow job={{ ...withAsset("mp4"), extra_links: [] }} />);
|
|
||||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
||||||
const texts: string[] = [];
|
|
||||||
while (walker.nextNode()) texts.push(walker.currentNode.textContent ?? "");
|
|
||||||
expect(texts).not.toContain("0");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A link queued (or failed) before its details: the title, the channel line and the link line all
|
|
||||||
// fell back to the same URL (the owner, 2026-09-27). Now the title IS the short link, once.
|
|
||||||
describe("DownloadRow · a link with no details yet", () => {
|
|
||||||
const bare: DownloadJob = {
|
|
||||||
...baseJob,
|
|
||||||
status: "error",
|
|
||||||
source_kind: "url",
|
|
||||||
source_ref: "https://www.facebook.com/watch?v=12345",
|
|
||||||
source_url: "https://www.facebook.com/watch?v=12345",
|
|
||||||
display_name: null,
|
|
||||||
asset: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
it("shows the short link once, as the title's link, with no channel line", () => {
|
|
||||||
const { container } = render(<DownloadRow job={bare} />);
|
|
||||||
const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url);
|
|
||||||
expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]);
|
|
||||||
expect(container.textContent).not.toContain("https://");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Measured at 21px on the Downloads page: every link in the row stands on a 24px line on
|
|
||||||
// a mouse (DESIGN B1), as the link line and the failure link already did.
|
|
||||||
it("gives the title link and the channel link a 24px line", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<DownloadRow
|
|
||||||
job={{
|
|
||||||
...bare,
|
|
||||||
display_uploader: "Someone",
|
|
||||||
display_uploader_url: "https://example.com/c",
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const title = [...container.querySelectorAll("a")].find((a) => a.href === bare.source_url)!;
|
|
||||||
const channel = container.querySelector<HTMLAnchorElement>("a[href='https://example.com/c']")!;
|
|
||||||
// inline-block: an inline link's box is the font's height (21px) whatever its line height.
|
|
||||||
for (const a of [title, channel])
|
|
||||||
expect(a.className.split(/\s+/)).toEqual(
|
|
||||||
expect.arrayContaining(["inline-block", "leading-6"]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the named row as it was: title text, and the link on its own line", () => {
|
|
||||||
const named = { ...bare, display_name: "A clip" };
|
|
||||||
const { container } = render(<DownloadRow job={named} />);
|
|
||||||
expect(screen.getByText("A clip").closest("a")).toBeNull();
|
|
||||||
const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url);
|
|
||||||
expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27).
|
// No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27).
|
||||||
describe("DownloadRow · no picture", () => {
|
describe("JobThumb · no picture", () => {
|
||||||
it("draws a film glyph for a video, a note for audio", () => {
|
it("draws a film glyph for a video, a note for audio", () => {
|
||||||
const { container, unmount } = render(<DownloadRow job={withAsset("mp4")} />);
|
const { container, unmount } = render(<JobThumb job={withAsset("mp4")} />);
|
||||||
expect(container.querySelector("img")).toBeNull();
|
expect(container.querySelector("img")).toBeNull();
|
||||||
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
|
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
|
||||||
unmount();
|
unmount();
|
||||||
const { container: audio } = render(
|
const { container: audio } = render(
|
||||||
<DownloadRow job={withAsset("m4a", { media_kind: "audio" })} />,
|
<JobThumb job={withAsset("m4a", { media_kind: "audio" })} />,
|
||||||
);
|
);
|
||||||
expect(audio.querySelector("svg.lucide-music")).not.toBeNull();
|
expect(audio.querySelector("svg.lucide-music")).not.toBeNull();
|
||||||
});
|
});
|
||||||
@@ -284,7 +96,7 @@ describe("DownloadRow · no picture", () => {
|
|||||||
// failed too (a dead CDN link), the row drew the browser's broken-image mark.
|
// failed too (a dead CDN link), the row drew the browser's broken-image mark.
|
||||||
it("falls back to the glyph when the remote picture fails too", () => {
|
it("falls back to the glyph when the remote picture fails too", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<DownloadRow job={withAsset("mp4", { thumbnail_url: "https://cdn.example/dead.jpg" })} />,
|
<JobThumb job={withAsset("mp4", { thumbnail_url: "https://cdn.example/dead.jpg" })} />,
|
||||||
);
|
);
|
||||||
const img = container.querySelector("img")!;
|
const img = container.querySelector("img")!;
|
||||||
expect(img.getAttribute("src")).toBe("https://cdn.example/dead.jpg");
|
expect(img.getAttribute("src")).toBe("https://cdn.example/dead.jpg");
|
||||||
@@ -293,27 +105,3 @@ describe("DownloadRow · no picture", () => {
|
|||||||
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
|
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The phone-safe conversion reports ffmpeg's real progress, so it gets the % bar a download has;
|
|
||||||
// a yt-dlp post-step reports none and keeps the pulse.
|
|
||||||
describe("DownloadRow · a running conversion", () => {
|
|
||||||
const running = (phase: string): DownloadJob => ({
|
|
||||||
...baseJob,
|
|
||||||
status: "running",
|
|
||||||
progress: 40,
|
|
||||||
phase,
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows its phase and its %", () => {
|
|
||||||
const { container } = render(<DownloadRow job={running("converting")} />);
|
|
||||||
expect(screen.getByText("downloads.phase.converting")).toBeTruthy();
|
|
||||||
expect(screen.getByText("40%")).toBeTruthy();
|
|
||||||
expect(container.querySelector(".animate-pulse")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a post-step with no % keeps the pulse", () => {
|
|
||||||
const { container } = render(<DownloadRow job={running("merging")} />);
|
|
||||||
expect(screen.queryByText("40%")).toBeNull();
|
|
||||||
expect(container.querySelector(".animate-pulse")).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CalendarClock, File, Film, Link2, Music, Scissors } from "lucide-react";
|
import { File, Film, Music } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import {
|
|
||||||
formatBytes,
|
|
||||||
formatDateTime,
|
|
||||||
formatDuration,
|
|
||||||
formatEta,
|
|
||||||
formatSpeed,
|
|
||||||
relativeTime,
|
|
||||||
} from "../lib/format";
|
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
|
||||||
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
|
||||||
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
|
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { MediaProgress } from "./ui/MediaCard";
|
import { MediaProgress } from "./ui/MediaCard";
|
||||||
import { Pressable } from "./ui/Pressable";
|
|
||||||
import { SURFACE } from "./ui/surfaces";
|
|
||||||
|
|
||||||
// Download-row presentation + the small shared primitives (status pill, icon button, the job's
|
// The small shared primitives of a download (status pill, icon button, the job's picture), all purely
|
||||||
// picture), split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the
|
// prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the
|
||||||
// queue and the admin tab; the Files list borrows the picture.
|
// details dialog the icon button.
|
||||||
|
|
||||||
const STATUS_CLS: Record<string, string> = {
|
const STATUS_CLS: Record<string, string> = {
|
||||||
queued: "text-muted",
|
queued: "text-muted",
|
||||||
@@ -32,10 +19,6 @@ const STATUS_CLS: Record<string, string> = {
|
|||||||
canceled: "text-muted",
|
canceled: "text-muted",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
|
|
||||||
// "editing" (the phase-2 editor) and "converting" (the phone-safe conversion of a download or an
|
|
||||||
// upload) report a real % from ffmpeg's out_time, so they get a bar.
|
|
||||||
const DOWNLOAD_PHASES = ["video", "audio", "editing", "converting"];
|
|
||||||
const PHASE_KEYS = [
|
const PHASE_KEYS = [
|
||||||
"video",
|
"video",
|
||||||
"audio",
|
"audio",
|
||||||
@@ -143,16 +126,6 @@ export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: str
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The channel to show under the title: a user override wins over the auto-extracted asset value.
|
|
||||||
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. No
|
|
||||||
// channel known yet → no line: the ref it once fell back to repeated the title (the owner, 2026-09-27).
|
|
||||||
function jobChannel(job: DownloadJob): { name: string | null; url: string | null } {
|
|
||||||
return {
|
|
||||||
name: job.display_uploader || job.asset?.uploader || null,
|
|
||||||
url: job.display_uploader_url || job.asset?.uploader_url || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IconBtn({
|
export function IconBtn({
|
||||||
onClick,
|
onClick,
|
||||||
title,
|
title,
|
||||||
@@ -179,202 +152,3 @@ export function IconBtn({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A link inside the row keeps the BROWSER's own right-click menu (open in a new tab, copy the
|
|
||||||
// address): the row's menu answers everywhere else on it.
|
|
||||||
const nativeMenu = { onContextMenu: (e: React.MouseEvent) => e.stopPropagation() };
|
|
||||||
|
|
||||||
// "Downloaded from" reference: opens the original page in a new tab. Its copy moved into the row's
|
|
||||||
// menu ("Copy link"); an extra link copies through the browser's own link menu.
|
|
||||||
function SourceRef({ url }: { url: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 text-sm text-muted min-w-0">
|
|
||||||
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title={`${t("downloads.source.open")} — ${url}`}
|
|
||||||
className="truncate leading-6 hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
|
|
||||||
{...nativeMenu}
|
|
||||||
>
|
|
||||||
{displayUrl(url)}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
|
|
||||||
function ChannelLine({ job }: { job: DownloadJob }) {
|
|
||||||
const { name, url } = jobChannel(job);
|
|
||||||
if (!name) return null;
|
|
||||||
if (url) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm mt-0.5 truncate">
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title={url}
|
|
||||||
className="inline-block max-w-full truncate align-top leading-6 text-muted hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
|
|
||||||
{...nativeMenu}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <div className="text-sm text-muted truncate mt-0.5">{name}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadRow({
|
|
||||||
job,
|
|
||||||
onShowError,
|
|
||||||
navIndex,
|
|
||||||
rowProps,
|
|
||||||
more,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
job: DownloadJob;
|
|
||||||
onShowError?: (job: DownloadJob) => void;
|
|
||||||
/** Its position in a keyboard-walkable list (lib/listNav). */
|
|
||||||
navIndex?: number;
|
|
||||||
/** The list's menu triggers (right-click, long-press, Shift+F10): `useContextMenu().triggerProps`. */
|
|
||||||
rowProps?: Record<string, unknown>;
|
|
||||||
/** The row's "…", last in its action strip. */
|
|
||||||
more?: ReactNode;
|
|
||||||
children?: ReactNode;
|
|
||||||
}) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const lang = i18n.language;
|
|
||||||
const running = job.status === "running";
|
|
||||||
const linkTitle = !hasOwnTitle(job) && !!job.source_url;
|
|
||||||
const sourceLink = linkTitle ? null : job.source_url;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
|
|
||||||
{...rowProps}
|
|
||||||
className={`${SURFACE.card} @container/dlrow group p-2.5 glass-hover transition`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-1 [grid-template-areas:'thumb_head_act''thumb_meta_act''thumb_src_act''thumb_prog_act'] @max-[40rem]/dlrow:grid-cols-[auto_minmax(0,1fr)] @max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']`}
|
|
||||||
>
|
|
||||||
<div className="[grid-area:thumb]">
|
|
||||||
<JobThumb job={job} />
|
|
||||||
</div>
|
|
||||||
<div className="[grid-area:head] min-w-0">
|
|
||||||
<div
|
|
||||||
className={`font-medium leading-snug break-words line-clamp-1 @max-[40rem]/dlrow:line-clamp-2`}
|
|
||||||
>
|
|
||||||
{(job.job_kind === "edit" || job.job_kind === "clip") && (
|
|
||||||
<span className="mr-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 align-middle rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
|
||||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{linkTitle ? (
|
|
||||||
// No name yet: the title IS the link, so the link line below leaves it out (it said the
|
|
||||||
// same URL a third time).
|
|
||||||
<a
|
|
||||||
href={job.source_url!}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title={`${t("downloads.source.open")} — ${job.source_url}`}
|
|
||||||
className="inline-block max-w-full truncate align-top leading-6 hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
|
|
||||||
{...nativeMenu}
|
|
||||||
>
|
|
||||||
{jobTitle(job)}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
jobTitle(job)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChannelLine job={job} />
|
|
||||||
</div>
|
|
||||||
{/* The meta line never wraps (the owner, 2026-09-27: on the iPad its "18.9 / MB" pieces broke
|
|
||||||
into a jumble). One line, cut at its end, so the least telling part (the age) goes first. */}
|
|
||||||
<div data-dl-meta className="[grid-area:meta] min-w-0 truncate text-sm">
|
|
||||||
<StatusPill job={job} />
|
|
||||||
{job.asset?.container ? (
|
|
||||||
// Concrete container (mp4 / m4a / webm …) — reads as the file format, and its audio-vs-video
|
|
||||||
// extension is the video/audio distinction the running-only phase pill can't show at rest.
|
|
||||||
<span className="text-muted uppercase"> · {job.asset.container}</span>
|
|
||||||
) : null}
|
|
||||||
{job.asset?.size_bytes ? (
|
|
||||||
<span className="text-muted"> · {formatBytes(job.asset.size_bytes, lang)}</span>
|
|
||||||
) : null}
|
|
||||||
{job.asset?.duration_s ? (
|
|
||||||
<span className="text-muted"> · {formatDuration(job.asset.duration_s)}</span>
|
|
||||||
) : null}
|
|
||||||
{job.status === "done" && job.created_at ? (
|
|
||||||
<span className="text-muted" title={formatDateTime(job.created_at, lang)}>
|
|
||||||
{" · "}
|
|
||||||
<CalendarClock className="inline w-3.5 h-3.5 align-[-0.15em]" />{" "}
|
|
||||||
{relativeTime(job.created_at, lang, t)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{job.status === "error" ? (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
<Pressable
|
|
||||||
onClick={() => onShowError?.(job)}
|
|
||||||
className="text-danger inline-block leading-6 min-h-6 touch-target pointer-coarse:leading-(--touch-target)"
|
|
||||||
title={t(failTitleKey(job))}
|
|
||||||
>
|
|
||||||
· {t(failCopyKeys(job, "short"))}{" "}
|
|
||||||
<span className="text-muted underline">
|
|
||||||
{t("downloads.errors.fail.detailsLink")}
|
|
||||||
</span>
|
|
||||||
</Pressable>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{/* Wide, this wrapper is `contents`: the source and the actions are grid items of their own
|
|
||||||
(src under the meta, act down the right). Stacked, it is ONE wrapping line: the link and
|
|
||||||
the actions side by side while they fit, the actions under the link only when they don't
|
|
||||||
(at 375). On touch each is a 44px box, so separate lines stacked 44px gaps (the owner,
|
|
||||||
2026-09-27: "what are those huge empty parts?"). */}
|
|
||||||
<div className="contents @max-[40rem]/dlrow:[grid-area:foot] @max-[40rem]/dlrow:flex @max-[40rem]/dlrow:flex-wrap @max-[40rem]/dlrow:items-center @max-[40rem]/dlrow:gap-x-2">
|
|
||||||
{(!!sourceLink || !!job.extra_links?.length) && (
|
|
||||||
<div className="[grid-area:src] min-w-0 @max-[40rem]/dlrow:min-w-32 @max-[40rem]/dlrow:flex-1">
|
|
||||||
{sourceLink && <SourceRef url={sourceLink} />}
|
|
||||||
{job.extra_links?.map((url) => (
|
|
||||||
<SourceRef key={url} url={url} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* The row's visible actions — its primary one and the save link — then its "…": the md
|
|
||||||
rung here (the row stands alone), the sm rung in the modals, one IconBtn for both. */}
|
|
||||||
<div className="[grid-area:act] flex items-start gap-1 shrink-0 @max-[40rem]/dlrow:ml-auto">
|
|
||||||
{children}
|
|
||||||
{more}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{running && (
|
|
||||||
<div className="[grid-area:prog] mt-0.5">
|
|
||||||
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
|
|
||||||
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
|
|
||||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
|
||||||
<div className="h-full w-full bg-accent/60 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-accent transition-all"
|
|
||||||
style={{ width: `${job.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted mt-0.5 flex gap-2">
|
|
||||||
<span>{job.progress}%</span>
|
|
||||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps, lang)}</span> : null}
|
|
||||||
{job.eta_s ? <span>{formatEta(t, job.eta_s)}</span> : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../lib/api";
|
||||||
|
|
||||||
// A download job's display title (a user rename wins over the extracted asset title), shared by the
|
// A download job's display title (a user rename wins over the extracted asset title), shared by the
|
||||||
// row, the admin list and the library player. Its own module so downloadRow exports only components.
|
// tray, the Files list, the admin Storage tab and the library player.
|
||||||
// With no name yet — a link queued before its details arrive, or one that failed first — it is the
|
// With no name yet — a link queued before its details arrive, or one that failed first — it is the
|
||||||
// link itself, shortened the way the row shows a link, never the raw ref (a URL, or a bare video id).
|
// link itself, shortened the way the row shows a link, never the raw ref (a URL, or a bare video id).
|
||||||
export function jobTitle(job: DownloadJob): string {
|
export function jobTitle(job: DownloadJob): string {
|
||||||
@@ -12,13 +12,8 @@ export function jobTitle(job: DownloadJob): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The job has a name of its own (a rename or an extracted title), not the link standing in for one. */
|
|
||||||
export function hasOwnTitle(job: DownloadJob): boolean {
|
|
||||||
return !!(job.display_name || job.asset?.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
|
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
|
||||||
export function displayUrl(url: string): string {
|
function displayUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
return (u.host + u.pathname + u.search).replace(/\/$/, "");
|
return (u.host + u.pathname + u.search).replace(/\/$/, "");
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const CARDS = [
|
|||||||
"EpisodeCard",
|
"EpisodeCard",
|
||||||
"PosterTile",
|
"PosterTile",
|
||||||
"ShelfTile",
|
"ShelfTile",
|
||||||
"DownloadRow",
|
|
||||||
];
|
];
|
||||||
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
|
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
|
|||||||
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
|
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
|
||||||
const Files = lazyWithPreload(() => import("./Files"));
|
const Files = lazyWithPreload(() => import("./Files"));
|
||||||
const FilesRail = lazyWithPreload(() => import("./FilesRail"));
|
const FilesRail = lazyWithPreload(() => import("./FilesRail"));
|
||||||
const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter"));
|
|
||||||
const Stats = lazyWithPreload(() => import("./Stats"));
|
const Stats = lazyWithPreload(() => import("./Stats"));
|
||||||
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
||||||
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
|
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
|
||||||
@@ -57,7 +56,6 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
|
|||||||
plex: { Component: PlexBrowse, maxWidth: "100rem" },
|
plex: { Component: PlexBrowse, maxWidth: "100rem" },
|
||||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||||
files: { Component: Files, maxWidth: "80rem" },
|
files: { Component: Files, maxWidth: "80rem" },
|
||||||
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
|
||||||
stats: { Component: Stats },
|
stats: { Component: Stats },
|
||||||
scheduler: { Component: Scheduler },
|
scheduler: { Component: Scheduler },
|
||||||
config: { Component: ConfigPanel },
|
config: { Component: ConfigPanel },
|
||||||
|
|||||||
@@ -69,13 +69,23 @@ describe("loadInitialPage precedence", () => {
|
|||||||
// whatever you happened to close the app on is precisely the habit the anti-paralysis surface
|
// whatever you happened to close the app on is precisely the habit the anti-paralysis surface
|
||||||
// exists to replace, so the remembered page only wins once the account opts out.
|
// exists to replace, so the remembered page only wins once the account opts out.
|
||||||
it("a bare URL lands on Home even when another module is remembered", () => {
|
it("a bare URL lands on Home even when another module is remembered", () => {
|
||||||
stored[LS.page] = "downloads";
|
stored[LS.page] = "plex";
|
||||||
expect(loadInitialPage()).toBe("home");
|
expect(loadInitialPage()).toBe("home");
|
||||||
});
|
});
|
||||||
it("opting out restores the account's stored landing page", () => {
|
it("opting out restores the account's stored landing page", () => {
|
||||||
|
stored[LS.startOnHome] = START_ON_HOME.off;
|
||||||
|
stored[LS.page] = "plex";
|
||||||
|
expect(loadInitialPage()).toBe("plex");
|
||||||
|
});
|
||||||
|
// The Downloads page is gone; what it held is in Files. A bookmark and a remembered page both land
|
||||||
|
// there instead of on the feed.
|
||||||
|
it("a retired page lands where its content went — linked or remembered", () => {
|
||||||
|
setUrl("?page=downloads");
|
||||||
|
expect(loadInitialPage()).toBe("files");
|
||||||
|
setUrl("/");
|
||||||
stored[LS.startOnHome] = START_ON_HOME.off;
|
stored[LS.startOnHome] = START_ON_HOME.off;
|
||||||
stored[LS.page] = "downloads";
|
stored[LS.page] = "downloads";
|
||||||
expect(loadInitialPage()).toBe("downloads");
|
expect(loadInitialPage()).toBe("files");
|
||||||
});
|
});
|
||||||
it("opted out with no / invalid stored page still defaults to feed", () => {
|
it("opted out with no / invalid stored page still defaults to feed", () => {
|
||||||
stored[LS.startOnHome] = START_ON_HOME.off;
|
stored[LS.startOnHome] = START_ON_HOME.off;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// provider file keeps react-refresh happy (component-only exports). `loadInitialPage` picks the landing
|
// provider file keeps react-refresh happy (component-only exports). `loadInitialPage` picks the landing
|
||||||
// module from a bookmark's params; `channelViewFrom` is the shared _chan/?chan resolver behind BOTH the
|
// module from a bookmark's params; `channelViewFrom` is the shared _chan/?chan resolver behind BOTH the
|
||||||
// channelView seed and the onPop handler.
|
// channelView seed and the onPop handler.
|
||||||
import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState";
|
import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState";
|
||||||
import { getAccountRaw, LS } from "../lib/storage";
|
import { getAccountRaw, LS } from "../lib/storage";
|
||||||
|
|
||||||
export const PAGE_KEY = LS.page;
|
export const PAGE_KEY = LS.page;
|
||||||
@@ -36,8 +36,7 @@ export function loadInitialPage(): Page {
|
|||||||
// replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
|
// replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
|
||||||
// module back.
|
// module back.
|
||||||
if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home";
|
if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home";
|
||||||
const stored = getAccountRaw(PAGE_KEY);
|
return resolvePage(getAccountRaw(PAGE_KEY)) ?? "feed";
|
||||||
return isPage(stored) ? stored : "feed";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the open channel page from a landed history entry: the in-app `_chan` id (with its optional
|
/** Resolve the open channel page from a landed history entry: the in-app `_chan` id (with its optional
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Below `md` a table is a list of cards, its columns flowing one after another. A column marked
|
||||||
|
// `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one
|
||||||
|
// card's line and on the next line of another.
|
||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, cleanup, screen } from "@testing-library/react";
|
||||||
|
import DataTable, { type Column } from "./DataTable";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const narrow = (matches: boolean) =>
|
||||||
|
vi.stubGlobal(
|
||||||
|
"matchMedia",
|
||||||
|
(query: string) =>
|
||||||
|
({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
}) as unknown as MediaQueryList,
|
||||||
|
);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Row = { id: string };
|
||||||
|
const columns: Column<Row>[] = [
|
||||||
|
{ key: "name", header: "Name", cardPrimary: true, render: (r) => r.id },
|
||||||
|
{ key: "size", header: "Size", render: () => "1 MB" },
|
||||||
|
{ key: "menu", header: "", cardLabel: false, cardRow: true, render: () => <button>more</button> },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("DataTable cards", () => {
|
||||||
|
it("gives a cardRow column a line of its own; the others flow", () => {
|
||||||
|
narrow(false);
|
||||||
|
render(<DataTable rows={[{ id: "a" }]} columns={columns} rowKey={(r) => r.id} />);
|
||||||
|
expect(screen.getByText("more").parentElement!.className).toContain("basis-full");
|
||||||
|
expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A short list that never outgrows a screen opts out of paging: every row, and no pager — not even
|
||||||
|
// the rows-per-page choice, which a paged table shows whenever it has rows. A size remembered for
|
||||||
|
// the same table does not bring paging back.
|
||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, cleanup, screen } from "@testing-library/react";
|
||||||
|
import DataTable, { type Column } from "./DataTable";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Row = { id: string };
|
||||||
|
const columns: Column<Row>[] = [{ key: "id", header: "Id", render: (r) => r.id }];
|
||||||
|
const rows: Row[] = Array.from({ length: 12 }, (_, i) => ({ id: `row ${i + 1}` }));
|
||||||
|
|
||||||
|
describe("DataTable pager", () => {
|
||||||
|
it("pages by default, rows-per-page shown", () => {
|
||||||
|
render(<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} pageSize={10} />);
|
||||||
|
expect(screen.queryByText("row 11")).toBeNull();
|
||||||
|
expect(screen.getByText("datatable.rowsPerPage")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pager={false} shows every row and no pager, whatever size was remembered", () => {
|
||||||
|
localStorage.setItem("t", JSON.stringify({ size: 10, page: 0 }));
|
||||||
|
render(
|
||||||
|
<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} pager={false} persistKey="t" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("row 12")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("datatable.rowsPerPage")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -94,6 +94,7 @@ export default function DataTable<T>({
|
|||||||
rowKey,
|
rowKey,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
pageSizeOptions = [10, 25, 50, 100],
|
pageSizeOptions = [10, 25, 50, 100],
|
||||||
|
pager = true,
|
||||||
persistKey,
|
persistKey,
|
||||||
emptyText,
|
emptyText,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
@@ -119,6 +120,10 @@ export default function DataTable<T>({
|
|||||||
rowKey: (row: T) => string;
|
rowKey: (row: T) => string;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
pageSizeOptions?: number[];
|
pageSizeOptions?: number[];
|
||||||
|
// A short list that never outgrows a screen (a user's download formats) has nothing to page: false
|
||||||
|
// shows every row and no pager — not even the rows-per-page choice, which there would only be
|
||||||
|
// clutter. It overrides `pageSize` and a persisted size, so the two cannot disagree.
|
||||||
|
pager?: boolean;
|
||||||
persistKey?: string;
|
persistKey?: string;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
rowClassName?: (row: T) => string;
|
rowClassName?: (row: T) => string;
|
||||||
@@ -191,7 +196,8 @@ export default function DataTable<T>({
|
|||||||
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
|
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
|
||||||
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
|
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
|
||||||
const [page, setPage] = useState(initial.page);
|
const [page, setPage] = useState(initial.page);
|
||||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
// 0 = "All": an unpaged table is one page of every row.
|
||||||
|
const [size, setSize] = useState(pager ? (initial.size ?? pageSize) : 0);
|
||||||
const wide = useMediaQuery("(min-width: 48rem)");
|
const wide = useMediaQuery("(min-width: 48rem)");
|
||||||
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
const cardsRef = useRef<HTMLDivElement>(null);
|
const cardsRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -315,20 +321,21 @@ export default function DataTable<T>({
|
|||||||
// Stays NULLABLE: the wrappers below decide whether to render a controls row at all, and an always
|
// Stays NULLABLE: the wrappers below decide whether to render a controls row at all, and an always
|
||||||
// -truthy element would leave an empty `my-3` band (and an empty in-band PageToolbar) on a table
|
// -truthy element would leave an empty `my-3` band (and an empty in-band PageToolbar) on a table
|
||||||
// with nothing to page. The emptiness rule itself lives in Pager, so the two can't drift apart.
|
// with nothing to page. The emptiness rule itself lives in Pager, so the two can't drift apart.
|
||||||
const pagerNode = hasPagerContent(sorted.length, size) ? (
|
const pagerNode =
|
||||||
<Pager
|
pager && hasPagerContent(sorted.length, size) ? (
|
||||||
page={safePage}
|
<Pager
|
||||||
size={size}
|
page={safePage}
|
||||||
total={sorted.length}
|
size={size}
|
||||||
pageSizeOptions={pageSizeOptions}
|
total={sorted.length}
|
||||||
onPageChange={setPage}
|
pageSizeOptions={pageSizeOptions}
|
||||||
onSizeChange={(s) => {
|
onPageChange={setPage}
|
||||||
setSize(s);
|
onSizeChange={(s) => {
|
||||||
setPage(0);
|
setSize(s);
|
||||||
}}
|
setPage(0);
|
||||||
sizeLabel={t("datatable.rowsPerPage")}
|
}}
|
||||||
/>
|
sizeLabel={t("datatable.rowsPerPage")}
|
||||||
) : null;
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const controls =
|
const controls =
|
||||||
controlsLeading != null || pagerNode ? (
|
controlsLeading != null || pagerNode ? (
|
||||||
@@ -424,7 +431,10 @@ export default function DataTable<T>({
|
|||||||
{columns
|
{columns
|
||||||
.filter((c) => !c.hideInCard && !c.cardPrimary)
|
.filter((c) => !c.hideInCard && !c.cardPrimary)
|
||||||
.map((col) => (
|
.map((col) => (
|
||||||
<span key={col.key} className="inline-flex items-center gap-1">
|
<span
|
||||||
|
key={col.key}
|
||||||
|
className={`inline-flex items-center gap-1${col.cardRow ? " basis-full" : ""}`}
|
||||||
|
>
|
||||||
{col.cardLabel !== false && (
|
{col.cardLabel !== false && (
|
||||||
<span className="text-muted text-xs">{col.header}</span>
|
<span className="text-muted text-xs">{col.header}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ const PLAIN: Record<string, [number, string]> = {
|
|||||||
"UploadDialog.tsx": [1, "the drop zone"],
|
"UploadDialog.tsx": [1, "the drop zone"],
|
||||||
"VideoEditor.tsx": [2, "timeline glyphs"],
|
"VideoEditor.tsx": [2, "timeline glyphs"],
|
||||||
"Welcome.tsx": [1, "a screenshot (zoom)"],
|
"Welcome.tsx": [1, "a screenshot (zoom)"],
|
||||||
"downloadRow.tsx": [1, "the error-details link"],
|
|
||||||
"playlistColumns.tsx": [1, "the thumbnail"],
|
"playlistColumns.tsx": [1, "the thumbnail"],
|
||||||
"plexDetailUi.tsx": [2, "a switch row and the poster"],
|
"plexDetailUi.tsx": [2, "a switch row and the poster"],
|
||||||
"plexViews.tsx": [1, "the rating filter (a number)"],
|
"plexViews.tsx": [1, "the rating filter (a number)"],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"navLabel": "Downloads",
|
|
||||||
"menu": {
|
"menu": {
|
||||||
"download": "Download…"
|
"download": "Download…"
|
||||||
},
|
},
|
||||||
@@ -19,9 +18,7 @@
|
|||||||
"manage": "Manage formats…"
|
"manage": "Manage formats…"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"paste": "Paste link from clipboard",
|
"paste": "Paste link from clipboard"
|
||||||
"upload": "Upload",
|
|
||||||
"manage": "Manage formats…"
|
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Upload a file",
|
"title": "Upload a file",
|
||||||
@@ -37,10 +34,6 @@
|
|||||||
"addedConverting": "File uploaded — converting it so it plays on any phone",
|
"addedConverting": "File uploaded — converting it so it plays on any phone",
|
||||||
"failed": "Couldn't upload the file"
|
"failed": "Couldn't upload the file"
|
||||||
},
|
},
|
||||||
"tabs": {
|
|
||||||
"queue": "Queue",
|
|
||||||
"system": "System"
|
|
||||||
},
|
|
||||||
"status": {
|
"status": {
|
||||||
"queued": "Queued",
|
"queued": "Queued",
|
||||||
"running": "Downloading",
|
"running": "Downloading",
|
||||||
@@ -69,7 +62,6 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"download": "Save to my device",
|
"download": "Save to my device",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"rename": "Rename",
|
|
||||||
"edit": "Edit / trim…",
|
"edit": "Edit / trim…",
|
||||||
"redownload": "Re-download (rebuild file)",
|
"redownload": "Re-download (rebuild file)",
|
||||||
"removeShared": "Remove from my list"
|
"removeShared": "Remove from my list"
|
||||||
@@ -78,12 +70,8 @@
|
|||||||
"unsupported": "Your browser can't play this file."
|
"unsupported": "Your browser can't play this file."
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "Nothing in the queue.",
|
|
||||||
"admin": "No downloads on this instance yet."
|
"admin": "No downloads on this instance yet."
|
||||||
},
|
},
|
||||||
"source": {
|
|
||||||
"open": "Open the original page in a new tab"
|
|
||||||
},
|
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Edit details",
|
"title": "Edit details",
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
@@ -157,7 +145,6 @@
|
|||||||
"profiles": {
|
"profiles": {
|
||||||
"title": "Download formats",
|
"title": "Download formats",
|
||||||
"builtin": "Built-in",
|
"builtin": "Built-in",
|
||||||
"yours": "Your formats",
|
|
||||||
"new": "New format",
|
"new": "New format",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"namePlaceholder": "My format",
|
"namePlaceholder": "My format",
|
||||||
@@ -175,7 +162,17 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteConfirm": "Delete the format “{{name}}”?"
|
"deleteConfirm": "Delete the format “{{name}}”?",
|
||||||
|
"edit": "Edit",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"saves": "What it saves",
|
||||||
|
"rowMenu": "Format actions",
|
||||||
|
"short": {
|
||||||
|
"subs": "subtitles",
|
||||||
|
"chapters": "chapters",
|
||||||
|
"thumbnail": "thumbnail",
|
||||||
|
"sponsorblock": "SponsorBlock"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"filesOnDisk": "Files on disk",
|
"filesOnDisk": "Files on disk",
|
||||||
@@ -192,10 +189,27 @@
|
|||||||
"reset": "Reset to default",
|
"reset": "Reset to default",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"quotaPickPlaceholder": "Set a user's quota…",
|
"quotaPickPlaceholder": "Set a user's quota…",
|
||||||
"noFootprint": "No downloads yet."
|
"noFootprint": "No downloads yet.",
|
||||||
|
"allDownloads": "Every download",
|
||||||
|
"columns": {
|
||||||
|
"user": "User",
|
||||||
|
"used": "Used",
|
||||||
|
"quota": "Of quota",
|
||||||
|
"files": "Files",
|
||||||
|
"title": "Title",
|
||||||
|
"status": "Status",
|
||||||
|
"size": "Size"
|
||||||
|
},
|
||||||
|
"ofQuota": "of {{size}}",
|
||||||
|
"unlimitedShort": "Unlimited",
|
||||||
|
"userMenu": "User storage actions",
|
||||||
|
"jobMenu": "Download actions",
|
||||||
|
"openSource": "Open source",
|
||||||
|
"editUserQuota": "Edit this user's quota",
|
||||||
|
"resetTitle": "Reset the quota?",
|
||||||
|
"resetBody": "{{email}} goes back to the instance's default limits."
|
||||||
},
|
},
|
||||||
"clipBadge": "Clip",
|
"clipBadge": "Clip",
|
||||||
"rowMenu": "Download actions",
|
|
||||||
"errors": {
|
"errors": {
|
||||||
"quota": {
|
"quota": {
|
||||||
"max_bytes": "You've used your download storage quota ({{size}} GB). Free up space first.",
|
"max_bytes": "You've used your download storage quota ({{size}} GB). Free up space first.",
|
||||||
@@ -223,7 +237,6 @@
|
|||||||
"title": "Couldn't download",
|
"title": "Couldn't download",
|
||||||
"titleUpload": "The upload isn't ready",
|
"titleUpload": "The upload isn't ready",
|
||||||
"titleEdit": "The clip isn't ready",
|
"titleEdit": "The clip isn't ready",
|
||||||
"detailsLink": "details",
|
|
||||||
"detailsToggle": "Technical details",
|
"detailsToggle": "Technical details",
|
||||||
"upload": {
|
"upload": {
|
||||||
"quota_exceeded": {
|
"quota_exceeded": {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
"goPlex": "Go to Plex",
|
"goPlex": "Go to Plex",
|
||||||
"goNotifications": "Go to Notifications",
|
"goNotifications": "Go to Notifications",
|
||||||
"goFiles": "Go to Files",
|
"goFiles": "Go to Files",
|
||||||
"goDownloads": "Go to Downloads",
|
|
||||||
"goStats": "Go to Stats",
|
"goStats": "Go to Stats",
|
||||||
"goSettings": "Go to Settings",
|
"goSettings": "Go to Settings",
|
||||||
"search": "Jump to the search box",
|
"search": "Jump to the search box",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"plex": "Plex",
|
"plex": "Plex",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"files": "Files",
|
"files": "Files",
|
||||||
"downloads": "Downloads",
|
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"scheduler": "Scheduler",
|
"scheduler": "Scheduler",
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"diagnostics": "Diagnostics"
|
"diagnostics": "Diagnostics",
|
||||||
|
"downloads": "Downloads"
|
||||||
},
|
},
|
||||||
"plexSync": {
|
"plexSync": {
|
||||||
"title": "Plex watch sync",
|
"title": "Plex watch sync",
|
||||||
@@ -230,5 +231,9 @@
|
|||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"empty": "No event matches the filter.",
|
"empty": "No event matches the filter.",
|
||||||
"emptyAll": "No events yet."
|
"emptyAll": "No events yet."
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"formats": "Download formats",
|
||||||
|
"formatsIntro": "The choices the download dialog offers. The built-in ones are fixed; duplicate one to make your own."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"roles": "Users & roles",
|
"roles": "Users & roles",
|
||||||
"access": "Access requests"
|
"access": "Access requests",
|
||||||
|
"storage": "Storage"
|
||||||
},
|
},
|
||||||
"pendingAccess": {
|
"pendingAccess": {
|
||||||
"title": "Signed up, but still outside",
|
"title": "Signed up, but still outside",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"channels": "Channels",
|
"channels": "Channels",
|
||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"player": "In-app player",
|
"player": "In-app player",
|
||||||
"downloads": "Downloads",
|
"files": "Your files",
|
||||||
"search": "Search results",
|
"search": "Search results",
|
||||||
"plex": "Your Plex library"
|
"plex": "Your Plex library"
|
||||||
},
|
},
|
||||||
@@ -40,9 +40,9 @@
|
|||||||
"title": "Watch in-app, resume anywhere",
|
"title": "Watch in-app, resume anywhere",
|
||||||
"body": "A built-in player picks each video up where you left off, with keyboard and scroll controls."
|
"body": "A built-in player picks each video up where you left off, with keyboard and scroll controls."
|
||||||
},
|
},
|
||||||
"downloads": {
|
"files": {
|
||||||
"title": "Downloads",
|
"title": "Files",
|
||||||
"body": "Save videos to your server in a Plex-friendly layout, trim or join them, then share a link."
|
"body": "Every file you keep in one library: paste a link anywhere to download it, trim or join a video, upload your own, then share a link that plays on any phone."
|
||||||
},
|
},
|
||||||
"plex": {
|
"plex": {
|
||||||
"title": "Your Plex library too",
|
"title": "Your Plex library too",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"navLabel": "Letöltések",
|
|
||||||
"menu": {
|
"menu": {
|
||||||
"download": "Letöltés…"
|
"download": "Letöltés…"
|
||||||
},
|
},
|
||||||
@@ -19,9 +18,7 @@
|
|||||||
"manage": "Formátumok kezelése…"
|
"manage": "Formátumok kezelése…"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"paste": "Link beillesztése a vágólapról",
|
"paste": "Link beillesztése a vágólapról"
|
||||||
"upload": "Feltöltés",
|
|
||||||
"manage": "Formátumok kezelése…"
|
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Fájl feltöltése",
|
"title": "Fájl feltöltése",
|
||||||
@@ -37,10 +34,6 @@
|
|||||||
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
|
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
|
||||||
"failed": "Nem sikerült feltölteni a fájlt"
|
"failed": "Nem sikerült feltölteni a fájlt"
|
||||||
},
|
},
|
||||||
"tabs": {
|
|
||||||
"queue": "Sor",
|
|
||||||
"system": "Rendszer"
|
|
||||||
},
|
|
||||||
"status": {
|
"status": {
|
||||||
"queued": "Várakozik",
|
"queued": "Várakozik",
|
||||||
"running": "Letöltés",
|
"running": "Letöltés",
|
||||||
@@ -69,7 +62,6 @@
|
|||||||
"retry": "Újra",
|
"retry": "Újra",
|
||||||
"download": "Mentés a gépemre",
|
"download": "Mentés a gépemre",
|
||||||
"play": "Lejátszás",
|
"play": "Lejátszás",
|
||||||
"rename": "Átnevezés",
|
|
||||||
"edit": "Szerkesztés / vágás…",
|
"edit": "Szerkesztés / vágás…",
|
||||||
"redownload": "Újraletöltés (fájl újraépítése)",
|
"redownload": "Újraletöltés (fájl újraépítése)",
|
||||||
"removeShared": "Eltávolítás a listámból"
|
"removeShared": "Eltávolítás a listámból"
|
||||||
@@ -78,12 +70,8 @@
|
|||||||
"unsupported": "A böngésződ nem tudja lejátszani ezt a fájlt."
|
"unsupported": "A böngésződ nem tudja lejátszani ezt a fájlt."
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "A sor üres.",
|
|
||||||
"admin": "Ezen a példányon még nincs letöltés."
|
"admin": "Ezen a példányon még nincs letöltés."
|
||||||
},
|
},
|
||||||
"source": {
|
|
||||||
"open": "Eredeti oldal megnyitása új lapon"
|
|
||||||
},
|
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Adatok szerkesztése",
|
"title": "Adatok szerkesztése",
|
||||||
"channel": "Csatorna",
|
"channel": "Csatorna",
|
||||||
@@ -157,7 +145,6 @@
|
|||||||
"profiles": {
|
"profiles": {
|
||||||
"title": "Letöltési formátumok",
|
"title": "Letöltési formátumok",
|
||||||
"builtin": "Beépített",
|
"builtin": "Beépített",
|
||||||
"yours": "Saját formátumaid",
|
|
||||||
"new": "Új formátum",
|
"new": "Új formátum",
|
||||||
"name": "Név",
|
"name": "Név",
|
||||||
"namePlaceholder": "Saját formátum",
|
"namePlaceholder": "Saját formátum",
|
||||||
@@ -175,7 +162,17 @@
|
|||||||
"save": "Mentés",
|
"save": "Mentés",
|
||||||
"create": "Létrehozás",
|
"create": "Létrehozás",
|
||||||
"delete": "Törlés",
|
"delete": "Törlés",
|
||||||
"deleteConfirm": "Törlöd a(z) „{{name}}” formátumot?"
|
"deleteConfirm": "Törlöd a(z) „{{name}}” formátumot?",
|
||||||
|
"edit": "Szerkesztés",
|
||||||
|
"duplicate": "Másolat készítése",
|
||||||
|
"saves": "Mit ment",
|
||||||
|
"rowMenu": "Formátum-műveletek",
|
||||||
|
"short": {
|
||||||
|
"subs": "felirat",
|
||||||
|
"chapters": "fejezetek",
|
||||||
|
"thumbnail": "bélyegkép",
|
||||||
|
"sponsorblock": "SponsorBlock"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"filesOnDisk": "Fájlok a lemezen",
|
"filesOnDisk": "Fájlok a lemezen",
|
||||||
@@ -192,10 +189,27 @@
|
|||||||
"reset": "Visszaállítás alapértékre",
|
"reset": "Visszaállítás alapértékre",
|
||||||
"save": "Mentés",
|
"save": "Mentés",
|
||||||
"quotaPickPlaceholder": "Kvóta beállítása egy felhasználónak…",
|
"quotaPickPlaceholder": "Kvóta beállítása egy felhasználónak…",
|
||||||
"noFootprint": "Még nincs letöltés."
|
"noFootprint": "Még nincs letöltés.",
|
||||||
|
"allDownloads": "Minden letöltés",
|
||||||
|
"columns": {
|
||||||
|
"user": "Felhasználó",
|
||||||
|
"used": "Foglalt",
|
||||||
|
"quota": "A kvótából",
|
||||||
|
"files": "Fájlok",
|
||||||
|
"title": "Cím",
|
||||||
|
"status": "Állapot",
|
||||||
|
"size": "Méret"
|
||||||
|
},
|
||||||
|
"ofQuota": "{{size}}-ból",
|
||||||
|
"unlimitedShort": "Korlátlan",
|
||||||
|
"userMenu": "Tárhely-műveletek",
|
||||||
|
"jobMenu": "Letöltés-műveletek",
|
||||||
|
"openSource": "Forrás megnyitása",
|
||||||
|
"editUserQuota": "A felhasználó kvótájának szerkesztése",
|
||||||
|
"resetTitle": "Visszaállítod a kvótát?",
|
||||||
|
"resetBody": "{{email}} visszakapja a példány alapértelmezett korlátait."
|
||||||
},
|
},
|
||||||
"clipBadge": "Klip",
|
"clipBadge": "Klip",
|
||||||
"rowMenu": "Letöltés műveletei",
|
|
||||||
"errors": {
|
"errors": {
|
||||||
"quota": {
|
"quota": {
|
||||||
"max_bytes": "Elfogyott a letöltési tárhelykereted ({{size}} GB). Előbb szabadíts fel helyet.",
|
"max_bytes": "Elfogyott a letöltési tárhelykereted ({{size}} GB). Előbb szabadíts fel helyet.",
|
||||||
@@ -223,7 +237,6 @@
|
|||||||
"title": "Nem sikerült letölteni",
|
"title": "Nem sikerült letölteni",
|
||||||
"titleUpload": "A feltöltés nem készült el",
|
"titleUpload": "A feltöltés nem készült el",
|
||||||
"titleEdit": "A klip nem készült el",
|
"titleEdit": "A klip nem készült el",
|
||||||
"detailsLink": "részletek",
|
|
||||||
"detailsToggle": "Technikai részletek",
|
"detailsToggle": "Technikai részletek",
|
||||||
"upload": {
|
"upload": {
|
||||||
"quota_exceeded": {
|
"quota_exceeded": {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
"goPlex": "Ugrás: Plex",
|
"goPlex": "Ugrás: Plex",
|
||||||
"goNotifications": "Ugrás: Értesítések",
|
"goNotifications": "Ugrás: Értesítések",
|
||||||
"goFiles": "Ugrás a Fájlokhoz",
|
"goFiles": "Ugrás a Fájlokhoz",
|
||||||
"goDownloads": "Ugrás: Letöltések",
|
|
||||||
"goStats": "Ugrás: Statisztika",
|
"goStats": "Ugrás: Statisztika",
|
||||||
"goSettings": "Ugrás: Beállítások",
|
"goSettings": "Ugrás: Beállítások",
|
||||||
"search": "Ugrás a keresőbe",
|
"search": "Ugrás a keresőbe",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"plex": "Plex",
|
"plex": "Plex",
|
||||||
"notifications": "Értesítések",
|
"notifications": "Értesítések",
|
||||||
"files": "Fájlok",
|
"files": "Fájlok",
|
||||||
"downloads": "Letöltések",
|
|
||||||
"stats": "Statisztika",
|
"stats": "Statisztika",
|
||||||
"scheduler": "Ütemező",
|
"scheduler": "Ütemező",
|
||||||
"config": "Konfig",
|
"config": "Konfig",
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"appearance": "Megjelenés",
|
"appearance": "Megjelenés",
|
||||||
"notifications": "Értesítések",
|
"notifications": "Értesítések",
|
||||||
"account": "Fiók",
|
"account": "Fiók",
|
||||||
"diagnostics": "Diagnosztika"
|
"diagnostics": "Diagnosztika",
|
||||||
|
"downloads": "Letöltések"
|
||||||
},
|
},
|
||||||
"plexSync": {
|
"plexSync": {
|
||||||
"title": "Plex nézettség-szinkron",
|
"title": "Plex nézettség-szinkron",
|
||||||
@@ -230,5 +231,9 @@
|
|||||||
"clear": "Ürítés",
|
"clear": "Ürítés",
|
||||||
"empty": "Nincs a szűrésnek megfelelő esemény.",
|
"empty": "Nincs a szűrésnek megfelelő esemény.",
|
||||||
"emptyAll": "Még nincs esemény."
|
"emptyAll": "Még nincs esemény."
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"formats": "Letöltési formátumok",
|
||||||
|
"formatsIntro": "Ezek közül választhatsz a letöltés ablakában. A beépítettek nem módosíthatók; másold le valamelyiket, ha sajátot szeretnél."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"roles": "Felhasználók és szerepek",
|
"roles": "Felhasználók és szerepek",
|
||||||
"access": "Hozzáférési kérelmek"
|
"access": "Hozzáférési kérelmek",
|
||||||
|
"storage": "Tárhely"
|
||||||
},
|
},
|
||||||
"pendingAccess": {
|
"pendingAccess": {
|
||||||
"title": "Regisztrált, de még kívül van",
|
"title": "Regisztrált, de még kívül van",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"channels": "Csatornák",
|
"channels": "Csatornák",
|
||||||
"playlists": "Lejátszási listák",
|
"playlists": "Lejátszási listák",
|
||||||
"player": "Beépített lejátszó",
|
"player": "Beépített lejátszó",
|
||||||
"downloads": "Letöltések",
|
"files": "A fájljaid",
|
||||||
"search": "Keresési találatok",
|
"search": "Keresési találatok",
|
||||||
"plex": "A Plex-könyvtárad"
|
"plex": "A Plex-könyvtárad"
|
||||||
},
|
},
|
||||||
@@ -40,9 +40,9 @@
|
|||||||
"title": "Nézd appon belül, folytasd bárhonnan",
|
"title": "Nézd appon belül, folytasd bárhonnan",
|
||||||
"body": "A beépített lejátszó onnan folytatja a videót, ahol abbahagytad — billentyű- és görgetés-vezérléssel."
|
"body": "A beépített lejátszó onnan folytatja a videót, ahol abbahagytad — billentyű- és görgetés-vezérléssel."
|
||||||
},
|
},
|
||||||
"downloads": {
|
"files": {
|
||||||
"title": "Letöltések",
|
"title": "Fájlok",
|
||||||
"body": "Ments videókat a szerveredre Plex-barát elrendezésben, vágd vagy fűzd össze őket, majd oszd meg egy linkkel."
|
"body": "Minden megtartott fájlod egy könyvtárban: illessz be bárhová egy linket, és letölti; vágj vagy fűzz össze videót, tölts fel sajátot, majd ossz meg olyan linket, ami bármelyik telefonon lejátszható."
|
||||||
},
|
},
|
||||||
"plex": {
|
"plex": {
|
||||||
"title": "A Plex-könyvtárad is",
|
"title": "A Plex-könyvtárad is",
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export interface DownloadJob {
|
|||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
asset: DownloadAsset | null;
|
asset: DownloadAsset | null;
|
||||||
shared?: boolean;
|
shared?: boolean;
|
||||||
|
// Whose it is — only the admin Storage tab's listing of every user's downloads carries them.
|
||||||
|
user_id?: number;
|
||||||
user_email?: string;
|
user_email?: string;
|
||||||
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings
|
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings
|
||||||
// (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not.
|
// (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not.
|
||||||
@@ -147,12 +149,23 @@ export interface ShareLink {
|
|||||||
// languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about.
|
// languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about.
|
||||||
export const DISPLAY_NAME_MAX = 255;
|
export const DISPLAY_NAME_MAX = 255;
|
||||||
|
|
||||||
|
/** One user's row on the admin Storage tab: their footprint against their own limit, and how many
|
||||||
|
* files their Files lists. */
|
||||||
|
export interface AdminStorageUser {
|
||||||
|
user_id: number;
|
||||||
|
email: string | null;
|
||||||
|
footprint_bytes: number;
|
||||||
|
files: number;
|
||||||
|
max_bytes: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminStorage {
|
export interface AdminStorage {
|
||||||
files_on_disk: number;
|
files_on_disk: number;
|
||||||
total_bytes: number;
|
total_bytes: number;
|
||||||
total_assets: number;
|
total_assets: number;
|
||||||
total_cap_bytes: number;
|
total_cap_bytes: number;
|
||||||
per_user: { user_id: number; email: string | null; footprint_bytes: number }[];
|
per_user: AdminStorageUser[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminDownloadQuota {
|
export interface AdminDownloadQuota {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { createStore } from "./store";
|
|||||||
// The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them
|
// The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them
|
||||||
// ("Done · Open") until it is closed, and announces each once (the done notice).
|
// ("Done · Open") until it is closed, and announces each once (the done notice).
|
||||||
//
|
//
|
||||||
// Watched where the queue is FETCHED, not in a component's effect: every reader of the queue (the
|
// Watched where the queue is FETCHED, not in a component's effect: every reader of the queue fetches
|
||||||
// tray, the Downloads page) fetches through `fetchQueue`, so a finish is seen once whichever of them
|
// through `fetchQueue`, so a finish is seen once whichever of them polled, and nothing has to diff the
|
||||||
// polled, and nothing has to diff the previous render's data.
|
// previous render's data.
|
||||||
|
|
||||||
/** The statuses a job leaves on its own: it is waiting, or working. */
|
/** The statuses a job leaves on its own: it is waiting, or working. */
|
||||||
export const IN_FLIGHT: ReadonlySet<string> = new Set(["queued", "running", "paused"]);
|
export const IN_FLIGHT: ReadonlySet<string> = new Set(["queued", "running", "paused"]);
|
||||||
@@ -14,6 +14,13 @@ export const IN_FLIGHT: ReadonlySet<string> = new Set(["queued", "running", "pau
|
|||||||
let lastSeen: Map<number, string> | null = null;
|
let lastSeen: Map<number, string> | null = null;
|
||||||
const finished = createStore<QueueJob[]>([]);
|
const finished = createStore<QueueJob[]>([]);
|
||||||
const unannounced = createStore<QueueJob[]>([]);
|
const unannounced = createStore<QueueJob[]>([]);
|
||||||
|
const revealing = createStore<Reveal | null>(null);
|
||||||
|
|
||||||
|
/** A job to show in the tray, asked for at `at` (ms). */
|
||||||
|
export interface Reveal {
|
||||||
|
id: number;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Note a fresh queue: a job that is done now and was not on the last look has just finished. The
|
/** Note a fresh queue: a job that is done now and was not on the last look has just finished. The
|
||||||
* first look only learns what is there — a file finished before this tab opened is old news. */
|
* first look only learns what is there — a file finished before this tab opened is old news. */
|
||||||
@@ -51,9 +58,21 @@ export function takeUnannounced(): QueueJob[] {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "View" on a job just started (a download, a clip, an upload still converting): the tray opens on
|
||||||
|
* its row — unfolded, back if it was closed, the keyboard on the row. One the tray no longer lists
|
||||||
|
* because it is done already opens as a file. */
|
||||||
|
export const showInTray = (id: number): void => revealing.set({ id, at: Date.now() });
|
||||||
|
|
||||||
|
/** The pending "View", until the tray has answered it. */
|
||||||
|
export const useRevealing = (): Reveal | null => revealing.useValue();
|
||||||
|
|
||||||
|
/** The tray answered the "View" (or the job is not in the queue at all). */
|
||||||
|
export const doneRevealing = (): void => revealing.set(null);
|
||||||
|
|
||||||
/** Forget what was watched (a test starts from a tab that has looked at nothing). */
|
/** Forget what was watched (a test starts from a tab that has looked at nothing). */
|
||||||
export function resetQueueWatch(): void {
|
export function resetQueueWatch(): void {
|
||||||
lastSeen = null;
|
lastSeen = null;
|
||||||
finished.set([]);
|
finished.set([]);
|
||||||
unannounced.set([]);
|
unannounced.set([]);
|
||||||
|
revealing.set(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,12 +45,6 @@ export function formatBytes(bytes: number | null | undefined, lang: string): str
|
|||||||
return `${num} ${units[i]}`;
|
return `${num} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
|
|
||||||
export function formatSpeed(bps: number | null | undefined, lang: string): string {
|
|
||||||
if (!bps) return "";
|
|
||||||
return `${formatBytes(bps, lang)}/s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces,
|
/** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces,
|
||||||
* and Intl.RelativeTimeFormat turns the pair into the localized words — so a divisor can no
|
* and Intl.RelativeTimeFormat turns the pair into the localized words — so a divisor can no
|
||||||
* longer be paired with the wrong label, which is exactly what shifted every tier by one from
|
* longer be paired with the wrong label, which is exactly what shifted every tier by one from
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ describe("two-step sequences", () => {
|
|||||||
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
|
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
|
||||||
dispatch(press({ key: "Shift", shiftKey: true }), "page", pageHandlers(calls));
|
dispatch(press({ key: "Shift", shiftKey: true }), "page", pageHandlers(calls));
|
||||||
dispatch(press({ key: "d" }), "page", pageHandlers(calls));
|
dispatch(press({ key: "d" }), "page", pageHandlers(calls));
|
||||||
expect(calls).toEqual(["goDownloads"]);
|
expect(calls).toEqual(["goFiles"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// No second step needs a modifier (see the page-level rows below), so a chord with one is the reader changing their mind: it
|
// No second step needs a modifier (see the page-level rows below), so a chord with one is the reader changing their mind: it
|
||||||
|
|||||||
@@ -117,14 +117,8 @@ export const SHORTCUTS = [
|
|||||||
labelKey: "keys.goNotifications",
|
labelKey: "keys.goNotifications",
|
||||||
page: "notifications",
|
page: "notifications",
|
||||||
},
|
},
|
||||||
{ id: "goFiles", scope: "page", keys: ["g l"], labelKey: "keys.goFiles", page: "files" },
|
// "g d" is the retired Downloads page's chord: what it held is in Files now, so it goes there too.
|
||||||
{
|
{ id: "goFiles", scope: "page", keys: ["g l", "g d"], labelKey: "keys.goFiles", page: "files" },
|
||||||
id: "goDownloads",
|
|
||||||
scope: "page",
|
|
||||||
keys: ["g d"],
|
|
||||||
labelKey: "keys.goDownloads",
|
|
||||||
page: "downloads",
|
|
||||||
},
|
|
||||||
{ id: "goStats", scope: "page", keys: ["g s"], labelKey: "keys.goStats", page: "stats" },
|
{ id: "goStats", scope: "page", keys: ["g s"], labelKey: "keys.goStats", page: "stats" },
|
||||||
{ id: "goSettings", scope: "page", keys: ["g ,"], labelKey: "keys.goSettings", page: "settings" },
|
{ id: "goSettings", scope: "page", keys: ["g ,"], labelKey: "keys.goSettings", page: "settings" },
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Bell,
|
Bell,
|
||||||
Clapperboard,
|
Clapperboard,
|
||||||
Download,
|
|
||||||
Home,
|
Home,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
@@ -95,13 +94,6 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
railKey: "nav.rail.files",
|
railKey: "nav.rail.files",
|
||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "downloads",
|
|
||||||
labelKey: "downloads.navLabel",
|
|
||||||
railKey: "nav.rail.downloads",
|
|
||||||
icon: Download,
|
|
||||||
hasBadge: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "stats",
|
id: "stats",
|
||||||
labelKey: "header.account.stats",
|
labelKey: "header.account.stats",
|
||||||
|
|||||||
+1
-16
@@ -1,21 +1,6 @@
|
|||||||
import type { Page } from "./urlState";
|
import type { Page } from "./urlState";
|
||||||
|
|
||||||
// Tiny decoupled navigator so deep components (a feed card's download toast, the Download
|
// Page links: a page's own address, and whether a click on one is the app's to handle.
|
||||||
// Center) can switch pages without threading setPage through the whole tree. NavigationProvider
|
|
||||||
// registers its setPage on mount; navigateTo is a no-op until then.
|
|
||||||
let _navigate: ((p: Page) => void) | null = null;
|
|
||||||
|
|
||||||
export function setNavigator(fn: (p: Page) => void): void {
|
|
||||||
_navigate = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The "View" on a "Download added" toast is clicked INSIDE the player (its toast host). That is
|
|
||||||
* safe because `setPage` closes every open overlay before it pushes (see
|
|
||||||
* `lib/history.closeOverlaysThenNavigate`) — without it the player's Back-to-close cleanup popped the
|
|
||||||
* entry the navigation had just pushed. */
|
|
||||||
export function navigateTo(p: Page): void {
|
|
||||||
_navigate?.(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A page's own address, for a link to it (the nav rail's rows). Only the page: the rest of a page's
|
/** A page's own address, for a link to it (the nav rail's rows). Only the page: the rest of a page's
|
||||||
* state — the feed's filters, Plex's library — a fresh tab restores from the account's own storage,
|
* state — the feed's filters, Plex's library — a fresh tab restores from the account's own storage,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ afterEach(() => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
const ALL: Page[] = ["home", "feed", "channels", "playlists", "plex", "downloads", "settings"];
|
const ALL: Page[] = ["home", "feed", "channels", "playlists", "plex", "files", "settings"];
|
||||||
|
|
||||||
function Host({
|
function Host({
|
||||||
onNavigate,
|
onNavigate,
|
||||||
@@ -61,6 +61,15 @@ describe("the g chords", () => {
|
|||||||
expect(nav.mock.calls).toEqual([["feed"], ["settings"]]);
|
expect(nav.mock.calls).toEqual([["feed"], ["settings"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The retired Downloads page's chord still answers — where its content went.
|
||||||
|
it("g d, the old Downloads chord, goes to Files", () => {
|
||||||
|
const nav = vi.fn();
|
||||||
|
render(<Host onNavigate={nav} />);
|
||||||
|
key({ key: "g" });
|
||||||
|
key({ key: "d" });
|
||||||
|
expect(nav.mock.calls).toEqual([["files"]]);
|
||||||
|
});
|
||||||
|
|
||||||
// `h` alone opens the sheet; the `h` of `g h` must not ALSO do that.
|
// `h` alone opens the sheet; the `h` of `g h` must not ALSO do that.
|
||||||
it("take the h of g h away from the help sheet", () => {
|
it("take the h of g h away from the help sheet", () => {
|
||||||
const nav = vi.fn();
|
const nav = vi.fn();
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const EXPECTED: Record<Page, string> = {
|
|||||||
playlists: "header.account.playlists",
|
playlists: "header.account.playlists",
|
||||||
notifications: "inbox.navLabel",
|
notifications: "inbox.navLabel",
|
||||||
files: "files.navLabel",
|
files: "files.navLabel",
|
||||||
downloads: "downloads.navLabel",
|
|
||||||
stats: "header.usageStats",
|
stats: "header.usageStats",
|
||||||
plex: "plex.navLabel",
|
plex: "plex.navLabel",
|
||||||
scheduler: "header.scheduler",
|
scheduler: "header.scheduler",
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export const LS = {
|
|||||||
// The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that
|
// The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that
|
||||||
// changes. Per-account mirror of the server pref.
|
// changes. Per-account mirror of the server pref.
|
||||||
downloadTrayClosed: "siftlode.downloadTrayClosed",
|
downloadTrayClosed: "siftlode.downloadTrayClosed",
|
||||||
|
// The download format used last — the download dialog opens on it. Per-account mirror of the
|
||||||
|
// server pref.
|
||||||
|
downloadProfile: "siftlode.downloadProfile",
|
||||||
hints: "siftlode.hints",
|
hints: "siftlode.hints",
|
||||||
lang: "siftlode.lang",
|
lang: "siftlode.lang",
|
||||||
filters: "siftlode.filters",
|
filters: "siftlode.filters",
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export interface Column<T> {
|
|||||||
cardPrimary?: boolean;
|
cardPrimary?: boolean;
|
||||||
hideInCard?: boolean;
|
hideInCard?: boolean;
|
||||||
cardLabel?: boolean;
|
cardLabel?: boolean;
|
||||||
|
// …and `cardRow` gives the value a line of its own: a row's lone "…" flowed in after the facts,
|
||||||
|
// at the end of one card's line and on the next line of another — never in the same place twice.
|
||||||
|
cardRow?: boolean;
|
||||||
// A column a table WITHOUT the card fallback (the playlist's drag-reorderable hybrid) leaves out
|
// A column a table WITHOUT the card fallback (the playlist's drag-reorderable hybrid) leaves out
|
||||||
// while the TABLE is narrower than 42rem: its fixed widths alone overran the box and left the title
|
// while the TABLE is narrower than 42rem: its fixed widths alone overran the box and left the title
|
||||||
// no width. The table's own width, not the viewport's (an `@container` round it): beside the
|
// no width. The table's own width, not the viewport's (an `@container` round it): beside the
|
||||||
|
|||||||
@@ -132,20 +132,32 @@ const PAGES = [
|
|||||||
"logs",
|
"logs",
|
||||||
"notifications",
|
"notifications",
|
||||||
"files",
|
"files",
|
||||||
"downloads",
|
|
||||||
"plex",
|
"plex",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Page = (typeof PAGES)[number];
|
export type Page = (typeof PAGES)[number];
|
||||||
|
|
||||||
/** Narrow an arbitrary string to a known Page (else null). */
|
/** Narrow an arbitrary string to a known Page (else null). */
|
||||||
export function isPage(p: string | null | undefined): p is Page {
|
function isPage(p: string | null | undefined): p is Page {
|
||||||
return !!p && (PAGES as readonly string[]).includes(p);
|
return !!p && (PAGES as readonly string[]).includes(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A page this build no longer has, and the page that took its place. A bookmark, the remembered last
|
||||||
|
// page and an old history entry can still name one; all three resolve through `resolvePage`, so a
|
||||||
|
// retired page lands where its content went instead of on the feed.
|
||||||
|
const RETIRED: Readonly<Record<string, Page>> = {
|
||||||
|
// The download queue became the tray; what it downloaded lives in Files.
|
||||||
|
downloads: "files",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A page id as stored or linked: a live page, a retired page's successor, else null. */
|
||||||
|
export function resolvePage(p: string | null | undefined): Page | null {
|
||||||
|
if (isPage(p)) return p;
|
||||||
|
return p && Object.prototype.hasOwnProperty.call(RETIRED, p) ? RETIRED[p]! : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function readPage(): Page {
|
export function readPage(): Page {
|
||||||
const p = new URLSearchParams(window.location.search).get("page");
|
return resolvePage(new URLSearchParams(window.location.search).get("page")) ?? "feed";
|
||||||
return isPage(p) ? p : "feed";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query —
|
/** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query —
|
||||||
|
|||||||
Reference in New Issue
Block a user