refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The activity page around its run and audit lists (the server-log tab has its own file): the view
|
||||
// is the URL — a link narrows the runs to one job, a status chip narrows them further; a run opens
|
||||
// to its log lines and an admin action to its request's, each a new history entry that Back undoes;
|
||||
// the account filter reaches the quota tab; a
|
||||
// failed read is an error with a retry, never an empty list.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { AuditQuery, AuditRow, HistoryPage, LogQuery, RunQuery, RunRow } from "../../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const runsMock = vi.fn<(q: RunQuery, before: string | null) => Promise<HistoryPage<RunRow>>>();
|
||||
const auditMock = vi.fn<(q: AuditQuery, before: string | null) => Promise<HistoryPage<AuditRow>>>();
|
||||
const logsMock = vi.fn<(q: LogQuery, before: string | null) => Promise<unknown>>();
|
||||
const quotaMock = vi.fn<(since: string, user: string | null) => Promise<unknown>>();
|
||||
const runFacetsMock = vi.fn<() => Promise<unknown>>();
|
||||
const usersMock = vi.fn<() => Promise<unknown>>();
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminRuns: (q: RunQuery, before: string | null) => runsMock(q, before),
|
||||
adminRunFacets: () => runFacetsMock(),
|
||||
adminAudit: (q: AuditQuery, before: string | null) => auditMock(q, before),
|
||||
adminAuditFacets: () => Promise.resolve({ actions: [] }),
|
||||
adminLogs: (q: LogQuery, before: string | null) => logsMock(q, before),
|
||||
adminLogFacets: () =>
|
||||
Promise.resolve({ levels: [], sources: [], events: [], users: [], accounts: {}, total: 0 }),
|
||||
adminUsers: () => usersMock(),
|
||||
adminQuota: (since: string, user: string | null) => quotaMock(since, user),
|
||||
clearAudit: () => Promise.resolve({ ok: true, cleared: 1 }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import Activity from "./Activity";
|
||||
import { ChoiceContext } from "../../components/ui/confirmContext";
|
||||
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
||||
|
||||
// The filter bar lives in the toolbar, which portals into the shell's band: give it one.
|
||||
const slot = document.body.appendChild(document.createElement("div"));
|
||||
|
||||
const RUN: RunRow = {
|
||||
id: 41,
|
||||
job: "backfill",
|
||||
trigger: "manual",
|
||||
actor_id: 1,
|
||||
target: null,
|
||||
target_title: null,
|
||||
status: "error",
|
||||
started_at: "2026-10-18T02:31:04Z",
|
||||
finished_at: "2026-10-18T02:31:45Z",
|
||||
summary: null,
|
||||
result: null,
|
||||
error: "quotaExceeded",
|
||||
failure: null,
|
||||
quota_units: 212,
|
||||
log_lines: 9,
|
||||
};
|
||||
|
||||
const ACTION: AuditRow = {
|
||||
id: 5,
|
||||
created_at: "2026-10-18T02:27:00Z",
|
||||
actor_id: 1,
|
||||
action: "scheduler_interval",
|
||||
target_type: "job",
|
||||
target_id: "rss_poll",
|
||||
summary: "rss_poll → 25 min/run",
|
||||
before: null,
|
||||
after: { interval_minutes: 25 },
|
||||
request_id: "0efdd960c9ad1977",
|
||||
log_lines: 4,
|
||||
};
|
||||
|
||||
const page = <T,>(items: T[]): HistoryPage<T> => ({
|
||||
items,
|
||||
accounts: { "1": "admin@example.com" },
|
||||
next_cursor: null,
|
||||
});
|
||||
|
||||
function renderPage(qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{/* Every confirmation is answered yes. */}
|
||||
<ChoiceContext.Provider value={() => Promise.resolve("confirm")}>
|
||||
<PageToolbarSlot.Provider value={slot}>
|
||||
<Activity />
|
||||
</PageToolbarSlot.Provider>
|
||||
</ChoiceContext.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const url = () => new URLSearchParams(window.location.search);
|
||||
const rowButton = (text: RegExp) =>
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.hasAttribute("aria-expanded") && text.test(b.textContent ?? ""))!;
|
||||
|
||||
beforeEach(() => {
|
||||
runsMock.mockResolvedValue(page([RUN]));
|
||||
auditMock.mockResolvedValue(page([ACTION]));
|
||||
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
||||
quotaMock.mockResolvedValue({ rows: [], daily: [] });
|
||||
runFacetsMock.mockResolvedValue({ jobs: [], statuses: [], total: 0 });
|
||||
usersMock.mockResolvedValue([{ id: 1, email: "admin@example.com" }]);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Activity", () => {
|
||||
it("a link narrows the runs to one job, and a status chip narrows them further", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&rjob=backfill");
|
||||
renderPage();
|
||||
await waitFor(() =>
|
||||
expect(runsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: "backfill", status: undefined, since: expect.any(String) }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /activity\.runs\.status\.error/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(runsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ job: "backfill", status: "error" }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect([url().get("rjob"), url().get("rst")]).toEqual(["backfill", "error"]);
|
||||
});
|
||||
|
||||
it("Older reads the next page from its cursor, and offers none past the last", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity");
|
||||
runsMock.mockImplementation((_q, before) =>
|
||||
Promise.resolve(
|
||||
before === null
|
||||
? { ...page([RUN]), next_cursor: "c1" }
|
||||
: page([{ ...RUN, id: 40, error: "the older one" }]),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "activity.older" }));
|
||||
await waitFor(() => expect(screen.getByText("the older one")).toBeTruthy());
|
||||
expect(runsMock).toHaveBeenLastCalledWith(expect.anything(), "c1");
|
||||
expect(screen.getByText("quotaExceeded")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "activity.older" })).toBeNull();
|
||||
});
|
||||
|
||||
it("an older page that fails says so and keeps what was loaded", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity");
|
||||
runsMock.mockImplementation((_q, before) =>
|
||||
before === null
|
||||
? Promise.resolve({ ...page([RUN]), next_cursor: "c1" })
|
||||
: Promise.reject(new Error("500")),
|
||||
);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "activity.older" }));
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("activity.olderFailed");
|
||||
expect(screen.getByText("quotaExceeded")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers no older page while a filter change is still loading", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity");
|
||||
// The cursor on screen is the previous filter's: it would continue the wrong list.
|
||||
runsMock.mockImplementation((q) =>
|
||||
q.status ? new Promise(() => {}) : Promise.resolve({ ...page([RUN]), next_cursor: "c1" }),
|
||||
);
|
||||
renderPage();
|
||||
await screen.findByRole("button", { name: "activity.older" });
|
||||
fireEvent.click(screen.getByRole("button", { name: /activity\.runs\.status\.error/ }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "activity.older" })).toBeNull(),
|
||||
);
|
||||
expect(screen.getByText("quotaExceeded")).toBeTruthy(); // the old rows stay, dimmed
|
||||
});
|
||||
|
||||
it("a run opens to its log lines, and Back returns to the runs", async () => {
|
||||
window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity&aw=24h&aq=quota");
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy());
|
||||
fireEvent.click(rowButton(/quotaExceeded/));
|
||||
fireEvent.click(screen.getByRole("button", { name: "activity.runs.logLines" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ run_id: 41, q: undefined }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
// Every other filter is off, and the range is the log's whole retention.
|
||||
expect([url().get("at"), url().get("lrun"), url().get("aw"), url().get("aq")]).toEqual([
|
||||
"logs",
|
||||
"41",
|
||||
"90d",
|
||||
null,
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
const landed = new Promise((resolve) =>
|
||||
window.addEventListener("popstate", resolve, { once: true }),
|
||||
);
|
||||
window.history.back();
|
||||
await landed;
|
||||
});
|
||||
await waitFor(() => expect(url().get("at")).toBeNull());
|
||||
expect([url().get("aw"), url().get("aq"), url().get("lrun")]).toEqual(["24h", "quota", null]);
|
||||
expect(screen.getByText("quotaExceeded")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a run's outcome is its result in the reader's words, its failure in the danger colour", async () => {
|
||||
runsMock.mockResolvedValue(
|
||||
page<RunRow>([
|
||||
{ ...RUN, id: 1, status: "ok", error: null, result: { new: 3 }, summary: "new=3" },
|
||||
// Moved over from the audit log before the result rode along as data: the one-liner only.
|
||||
{ ...RUN, id: 2, status: "ok", error: null, result: null, summary: "failed=2" },
|
||||
{ ...RUN, id: 3, status: "interrupted", error: null },
|
||||
{ ...RUN, id: 4 },
|
||||
]),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("scheduler.result.new: 3")).toBeTruthy());
|
||||
expect(screen.queryByText("new=3")).toBeNull();
|
||||
expect(screen.getByText("scheduler.result.failed: 2")).toBeTruthy();
|
||||
expect(screen.getByText("scheduler.interrupted").className).toContain("text-warn");
|
||||
expect(screen.getByText("quotaExceeded").className).toContain("text-danger");
|
||||
});
|
||||
|
||||
it("filter choices that failed to load say so beside the filters, with a retry", async () => {
|
||||
runFacetsMock.mockRejectedValueOnce(new Error("500"));
|
||||
usersMock.mockRejectedValueOnce(new Error("500"));
|
||||
renderPage();
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole("alert").map((a) => a.textContent)).toEqual([
|
||||
expect.stringContaining("activity.accountsFailed"),
|
||||
expect.stringContaining("activity.filtersFailed"),
|
||||
]),
|
||||
);
|
||||
for (const retry of screen.getAllByRole("button", { name: "common.retry" }))
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() => expect(screen.queryAllByRole("alert")).toEqual([]));
|
||||
expect(runFacetsMock).toHaveBeenCalledTimes(2);
|
||||
expect(usersMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("accounts that could not be refreshed, still listed, are not called unloaded", async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0 } } });
|
||||
qc.setQueryData(qk.adminUsers(), [{ id: 1, email: "admin@example.com" }]);
|
||||
usersMock.mockRejectedValue(new Error("502"));
|
||||
renderPage(qc);
|
||||
await waitFor(() => expect(usersMock).toHaveBeenCalled());
|
||||
await waitFor(() => expect(qc.getQueryState(qk.adminUsers())?.status).toBe("error"));
|
||||
expect(screen.queryByText("activity.accountsFailed")).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing the admin actions reads the list again", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=admin");
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
|
||||
auditMock.mockResolvedValue(page<AuditRow>([]));
|
||||
fireEvent.click(screen.getByRole("button", { name: "audit.clear" }));
|
||||
await waitFor(() => expect(screen.getByText("audit.empty")).toBeTruthy());
|
||||
// An emptied list is announced, as the error and loading states are.
|
||||
expect(screen.getByText("audit.empty").closest('[role="status"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closing a dialog over the server log leaves Live on", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=logs");
|
||||
renderPage();
|
||||
const live = await screen.findByRole("button", { name: "adminLogs.live" });
|
||||
fireEvent.click(live);
|
||||
expect(live.getAttribute("aria-pressed")).toBe("true");
|
||||
// A closing dialog steps back over its own history entry: a popstate onto the same address.
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
});
|
||||
const after = screen.getByRole("button", { name: "adminLogs.live" });
|
||||
expect(after.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(after).toBe(live); // the same log, not a fresh one
|
||||
});
|
||||
|
||||
it("Back/Forward on the server log reads only the filters of the entry it lands on", async () => {
|
||||
// A browser runs the microtasks between two popstate listeners, so each listener's update
|
||||
// renders on its own; a test dispatch does not. Each listener runs in its own act here.
|
||||
const listeners: EventListener[] = [];
|
||||
const addOther = window.addEventListener.bind(window);
|
||||
const removeOther = window.removeEventListener.bind(window);
|
||||
const add = vi.spyOn(window, "addEventListener").mockImplementation((type, l, o) => {
|
||||
if (type === "popstate") listeners.push(l as EventListener);
|
||||
else addOther(type, l, o);
|
||||
});
|
||||
const remove = vi.spyOn(window, "removeEventListener").mockImplementation((type, l, o) => {
|
||||
if (type === "popstate") listeners.splice(listeners.indexOf(l as EventListener), 1);
|
||||
else removeOther(type, l, o);
|
||||
});
|
||||
window.history.replaceState(null, "", "/?page=activity&at=logs&aw=24h");
|
||||
renderPage();
|
||||
await waitFor(() => expect(logsMock).toHaveBeenCalled());
|
||||
logsMock.mockClear();
|
||||
// Forward onto a pivot's entry: one run's lines over the log's whole retention.
|
||||
window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41");
|
||||
for (const l of [...listeners]) {
|
||||
await act(async () => l(new PopStateEvent("popstate")));
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null),
|
||||
);
|
||||
const days = (since?: string) => (Date.now() - Date.parse(since ?? "")) / 86_400_000;
|
||||
// Never one run's lines over a day, nor the whole log over 90 days.
|
||||
expect(logsMock.mock.calls.map(([q]) => [q.run_id ?? null, days(q.since) > 30])).toEqual(
|
||||
logsMock.mock.calls.map(() => [41, true]),
|
||||
);
|
||||
add.mockRestore();
|
||||
remove.mockRestore();
|
||||
});
|
||||
|
||||
it("an action whose request's lines are gone says so instead of linking to an empty list", async () => {
|
||||
auditMock.mockResolvedValue(page([{ ...ACTION, log_lines: 0 }]));
|
||||
window.history.replaceState(null, "", "/?page=activity&at=admin");
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
|
||||
fireEvent.click(rowButton(/scheduler_interval/));
|
||||
expect(screen.getByText("activity.audit.noRequestLines")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "activity.audit.requestLines" })).toBeNull();
|
||||
});
|
||||
|
||||
it("the admin tab lists the actions, and an action opens its request's lines", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=admin");
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
|
||||
expect(runsMock).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(rowButton(/auditActions\.scheduler_interval/));
|
||||
fireEvent.click(screen.getByRole("button", { name: "activity.audit.requestLines" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ request_id: "0efdd960c9ad1977" }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(url().get("lreq")).toBe("0efdd960c9ad1977");
|
||||
});
|
||||
|
||||
it("the account filter reaches the quota tab, over the page's range", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=quota&au=none&aw=30d");
|
||||
renderPage();
|
||||
await waitFor(() => expect(quotaMock).toHaveBeenCalled());
|
||||
const [since, user] = quotaMock.mock.calls[0]!;
|
||||
expect(user).toBe("none");
|
||||
const days = (Date.now() - new Date(since).getTime()) / 86_400_000;
|
||||
expect(Math.round(days)).toBe(30);
|
||||
});
|
||||
|
||||
it("each day's spend is readable without a pointer", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=quota");
|
||||
quotaMock.mockResolvedValue({
|
||||
rows: [{ user_id: null, email: "System", total: 9, by_action: {} }],
|
||||
daily: [
|
||||
{ day: "2026-10-16", total: 4 },
|
||||
{ day: "2026-10-17", total: 5 },
|
||||
],
|
||||
});
|
||||
renderPage();
|
||||
const chart = await screen.findByRole("list", { name: "activity.quota.daily" });
|
||||
expect(
|
||||
within(chart)
|
||||
.getAllByRole("listitem")
|
||||
.map((d) => d.textContent),
|
||||
).toEqual(["stats.dailyTooltip", "stats.dailyTooltip"]);
|
||||
});
|
||||
|
||||
// The runs moved over from the audit log kept one instant for start and end: their length is
|
||||
// unknown, and a "1 sec" would be invented.
|
||||
it("a run whose start and end are one instant has no length", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity");
|
||||
runsMock.mockResolvedValue(
|
||||
page([
|
||||
{ ...RUN, id: 1, status: "ok", error: null, finished_at: RUN.started_at, summary: "moved" },
|
||||
{ ...RUN, id: 2, status: "ok", error: null, summary: "timed" },
|
||||
]),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("moved")).toBeTruthy());
|
||||
const cells = (text: string) =>
|
||||
[...rowButton(new RegExp(text)).children].map((c) => c.textContent);
|
||||
expect(cells("moved")).toContain("—");
|
||||
expect(cells("timed")).not.toContain("—");
|
||||
});
|
||||
|
||||
// A bare "212" in a row reads as nothing in particular: the spend carries its unit, and a run that
|
||||
// spent none says nothing rather than "0".
|
||||
it("a run's quota spend reads with its unit, and none spent leaves the cell blank", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity");
|
||||
runsMock.mockResolvedValue(
|
||||
page([
|
||||
{ ...RUN, id: 1, status: "ok", error: null, summary: "spent" },
|
||||
{ ...RUN, id: 2, status: "ok", error: null, summary: "free", quota_units: 0 },
|
||||
]),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("spent")).toBeTruthy());
|
||||
const cells = (text: string) =>
|
||||
[...rowButton(new RegExp(text)).children].map((c) => c.textContent);
|
||||
expect(cells("spent")).toContain("activity.runs.units");
|
||||
expect(cells("spent")).not.toContain("212");
|
||||
expect(cells("free")).not.toContain("0");
|
||||
expect(cells("free")).not.toContain("activity.runs.units");
|
||||
});
|
||||
|
||||
// Most runs store no line at all: a link there would only lead to an empty list.
|
||||
it("a run that logged nothing says so instead of linking to an empty list", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity");
|
||||
runsMock.mockResolvedValue(page([{ ...RUN, log_lines: 0 }]));
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy());
|
||||
fireEvent.click(rowButton(/quotaExceeded/));
|
||||
expect(screen.getByText("activity.runs.noLogLines")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "activity.runs.logLines" })).toBeNull();
|
||||
});
|
||||
|
||||
it("a failed read is an error with a retry, not an empty list", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity");
|
||||
runsMock.mockRejectedValue(new Error("502"));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
expect(screen.queryByText("activity.runs.empty")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
ACTIVITY_TABS,
|
||||
ACTIVITY_WINDOWS,
|
||||
activityParamsToState,
|
||||
activityParamUpdates,
|
||||
type ActivityTab,
|
||||
type ActivityUrlState,
|
||||
type ActivityWindow,
|
||||
} from "../../lib/activityUrlState";
|
||||
import { nextNavId, notifyNav, rememberNavPosition } from "../../lib/history";
|
||||
import {
|
||||
DEFAULT_LOGS_STATE,
|
||||
logsParamsToState,
|
||||
logsParamUpdates,
|
||||
type LogsUrlState,
|
||||
} from "../../lib/logsUrlState";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { urlWithParams } from "../../lib/urlState";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import ActivityAudit from "./ActivityAudit";
|
||||
import ActivityQuota from "./ActivityQuota";
|
||||
import ActivityRuns from "./ActivityRuns";
|
||||
import AdminLogs, { type LogPivot } from "./AdminLogs";
|
||||
import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
||||
import FilterSelect from "../../components/ui/FilterSelect";
|
||||
import { InnerInput } from "../../components/ui/Input";
|
||||
import { fieldClass } from "../../components/ui/fieldStyles";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import Tabs from "../../components/ui/Tabs";
|
||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||
|
||||
// Activity ("Napló"): what happened, in one place — the background runs, the admins' actions, the
|
||||
// server's log and the quota spend, each its own table on the server, read here as four tabs under
|
||||
// one filter bar (time range, account, text). The view lives in the URL, so it is a link; a pivot to
|
||||
// one run's or one request's log lines is a new history entry, and Back returns to where it was made.
|
||||
|
||||
// A pivot shows everything the run or request logged: every other filter would only hide part of it,
|
||||
// and the longest range is the log's own retention.
|
||||
const PIVOT_SHARED: Pick<ActivityUrlState, "window" | "user" | "q"> = {
|
||||
window: "90d",
|
||||
user: null,
|
||||
q: "",
|
||||
};
|
||||
|
||||
export default function Activity() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<ActivityUrlState>(() =>
|
||||
activityParamsToState(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
// Bumped when the view changes by a jump (a pivot, Back/Forward) rather than by typing: the search
|
||||
// text then applies at once instead of after the typing debounce.
|
||||
const [jumps, setJumps] = useState(0);
|
||||
// Bumped by a pivot only: the server log, already open, takes the pivot's filters from the address
|
||||
// bar when it mounts again. Back/Forward it follows in place — a popstate is also a dialog closing,
|
||||
// onto the same address, and a fresh log would drop its Live and its focus.
|
||||
const [pivots, setPivots] = useState(0);
|
||||
// The log's half of the entry Back/Forward landed on, read here with this page's half: one listener,
|
||||
// so both change in one render. Two listeners rendered in between (a browser runs the microtasks
|
||||
// after each), and that render read the log with one entry's filters and the other's range.
|
||||
const [landed, setLanded] = useState<{ logs: LogsUrlState } | null>(null);
|
||||
const q = useDebounced(state.q, 300, jumps);
|
||||
const panel = useTabPanel();
|
||||
// `page` too: a link to a page this one replaced (audit, logs) is rewritten to this page's id.
|
||||
useUrlMirror({ page: "activity", ...activityParamUpdates(state) });
|
||||
useEffect(() => {
|
||||
const onPop = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
setJumps((n) => n + 1);
|
||||
setState(activityParamsToState(params));
|
||||
setLanded({ logs: logsParamsToState(params) });
|
||||
};
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
const update = (patch: Partial<ActivityUrlState>) => setState((s) => ({ ...s, ...patch }));
|
||||
|
||||
const pivot = (target: LogPivot) => {
|
||||
const next: ActivityUrlState = { ...state, ...PIVOT_SHARED, tab: "logs" };
|
||||
window.history.pushState(
|
||||
{ ...rememberNavPosition(), sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams({
|
||||
page: "activity",
|
||||
...activityParamUpdates(next),
|
||||
...logsParamUpdates({ ...DEFAULT_LOGS_STATE, ...target }),
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
setJumps((n) => n + 1);
|
||||
setPivots((n) => n + 1);
|
||||
setState(next);
|
||||
};
|
||||
|
||||
const shared = { window: state.window, user: state.user, q };
|
||||
// The account menu's list (SharedBar reads the same query): without it the menu offers only "Any".
|
||||
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||
return (
|
||||
<div {...panel.panelProps(state.tab)} className="px-4 pb-4 pt-3 max-w-6xl w-full mx-auto">
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 max-w-6xl w-full mx-auto">
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
active={state.tab}
|
||||
onChange={(tab) => update({ tab: tab as ActivityTab })}
|
||||
tabs={ACTIVITY_TABS.map((id) => ({ id, label: t(`activity.tabs.${id}`) }))}
|
||||
className="flex flex-wrap gap-1 pb-2"
|
||||
/>
|
||||
<SharedBar state={state} onChange={update} />
|
||||
</div>
|
||||
</PageToolbar>
|
||||
{/* Only when the menu really lacks them: a refresh that failed keeps the list it had. */}
|
||||
{users.isError && !users.data && (
|
||||
<StateMessage tone="error" compact onRetry={() => void users.refetch()}>
|
||||
{t("activity.accountsFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
{/* The quota tab says what its numbers are itself, beside them. */}
|
||||
{state.tab !== "quota" && (
|
||||
<p className="mb-3 text-xs leading-relaxed text-muted">
|
||||
{t(`activity.intro.${state.tab}`)}
|
||||
</p>
|
||||
)}
|
||||
{state.tab === "runs" ? (
|
||||
<ActivityRuns
|
||||
shared={shared}
|
||||
job={state.job}
|
||||
status={state.status}
|
||||
onFilter={update}
|
||||
onLogs={(runId) => pivot({ requestId: null, jobId: null, runId })}
|
||||
/>
|
||||
) : state.tab === "admin" ? (
|
||||
<ActivityAudit
|
||||
shared={shared}
|
||||
action={state.action}
|
||||
onAction={(action) => update({ action })}
|
||||
onRequest={(requestId) => pivot({ requestId, jobId: null, runId: null })}
|
||||
/>
|
||||
) : state.tab === "logs" ? (
|
||||
// Keyed by the pivots: a pivot hands the log its filters through the address bar, which it
|
||||
// reads when it mounts.
|
||||
<AdminLogs key={pivots} shared={shared} landed={landed} onPivot={pivot} />
|
||||
) : (
|
||||
<ActivityQuota shared={shared} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The filters every tab shares: the text (not on the quota tab — it has nothing to match), the time
|
||||
* range, and the account. */
|
||||
function SharedBar({
|
||||
state,
|
||||
onChange,
|
||||
}: {
|
||||
state: ActivityUrlState;
|
||||
onChange: (patch: Partial<ActivityUrlState>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||
const known = users.data ?? [];
|
||||
// The account in use stays offered even when the list does not have it (an erased account).
|
||||
const kept =
|
||||
state.user && state.user !== "none" && !known.some((u) => String(u.id) === state.user)
|
||||
? [{ value: state.user, label: `#${state.user}` }]
|
||||
: [];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{state.tab !== "quota" && (
|
||||
<label
|
||||
className={clsx(
|
||||
fieldClass({ size: "sm", pill: true }),
|
||||
"flex min-w-[12rem] flex-1 items-center gap-1.5",
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
|
||||
<InnerInput
|
||||
type="search"
|
||||
value={state.q}
|
||||
onChange={(e) => onChange({ q: e.target.value.slice(0, 200) })}
|
||||
placeholder={t("activity.search")}
|
||||
aria-label={t("activity.search")}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("activity.windowLabel")}
|
||||
value={state.window}
|
||||
onChange={(window) => onChange({ window: window as ActivityWindow })}
|
||||
options={ACTIVITY_WINDOWS.map((w) => ({ value: w, label: t(`activity.windows.${w}`) }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("activity.account")}
|
||||
options={[
|
||||
{ value: "", label: t("activity.any") },
|
||||
{ value: "none", label: t("activity.noAccount") },
|
||||
...known.map((u) => ({ value: String(u.id), label: u.email })),
|
||||
...kept,
|
||||
]}
|
||||
selected={[state.user ?? ""]}
|
||||
onChange={([user]) => onChange({ user: user || null })}
|
||||
testId="activity-user"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { api, type AuditQuery, type AuditRow } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { sinceOf } from "../../lib/activityUrlState";
|
||||
import { auditActionLabel, auditText } from "../../lib/auditText";
|
||||
import { formatDateTimeSec } from "../../lib/format";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useHistoryList } from "../../lib/useHistoryList";
|
||||
import type { Shared } from "./ActivityRuns";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import HistoryList from "./HistoryList";
|
||||
import HistoryRow from "../../features/diagnostics/HistoryRow";
|
||||
import { RelativeTime } from "../../components/ui/RelativeTime";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import FilterSelect from "../../components/ui/FilterSelect";
|
||||
|
||||
// Activity → Admin actions: the append-only who/what/when trail, newest first, filtered on the
|
||||
// server. A row opens to its before/after and to the lines its request logged. Clear stays here,
|
||||
// leaving one row that says who cleared it.
|
||||
|
||||
const GRID =
|
||||
"grid-cols-[minmax(0,1fr)_auto] sm:grid-cols-[minmax(0,12rem)_minmax(0,12rem)_8rem_minmax(10rem,1fr)]";
|
||||
|
||||
export default function ActivityAudit({
|
||||
shared,
|
||||
action,
|
||||
onAction,
|
||||
onRequest,
|
||||
}: {
|
||||
shared: Shared;
|
||||
action: string | null;
|
||||
onAction: (action: string | null) => void;
|
||||
/** Open the server log at this request's lines. */
|
||||
onRequest: (requestId: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const query: AuditQuery = {
|
||||
user: shared.user ?? undefined,
|
||||
q: shared.q.trim() || undefined,
|
||||
action: action ?? undefined,
|
||||
};
|
||||
const key = { ...query, window: shared.window };
|
||||
const state = useHistoryList(qk.adminAudit(key), (before) =>
|
||||
api.adminAudit({ ...query, since: sinceOf(shared.window) }, before),
|
||||
);
|
||||
const facets = useQuery({
|
||||
queryKey: qk.adminAuditFacets(key),
|
||||
queryFn: () => api.adminAuditFacets({ ...query, since: sinceOf(shared.window) }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const actions = [
|
||||
...(facets.data?.actions ?? []),
|
||||
...(action && !facets.data?.actions.some((f) => f.value === action)
|
||||
? [{ value: action, count: 0 }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.clearAudit(),
|
||||
onSuccess: (r) => {
|
||||
void qc.invalidateQueries({ queryKey: qk.adminAudit() });
|
||||
void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() });
|
||||
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
||||
},
|
||||
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
|
||||
});
|
||||
const onClear = async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t("audit.clearTitle"),
|
||||
message: t("audit.clearConfirm"),
|
||||
confirmLabel: t("audit.clear"),
|
||||
danger: true,
|
||||
})
|
||||
)
|
||||
clear.mutate();
|
||||
};
|
||||
const actor = (row: AuditRow) =>
|
||||
row.actor_id === null ? null : (state.accounts[String(row.actor_id)] ?? `#${row.actor_id}`);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
label={t("activity.audit.action")}
|
||||
options={[
|
||||
{ value: "", label: t("activity.any") },
|
||||
...actions.map((f) => ({
|
||||
value: f.value,
|
||||
label: auditActionLabel(t, f.value),
|
||||
count: f.count,
|
||||
})),
|
||||
]}
|
||||
selected={[action ?? ""]}
|
||||
onChange={([value]) => onAction(value || null)}
|
||||
testId="audit-action"
|
||||
/>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
className="ml-auto"
|
||||
onClick={() => void onClear()}
|
||||
disabled={clear.isPending}
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
>
|
||||
{t("audit.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* The action menu above is built from these counts: without them it offers only "Any". */}
|
||||
{facets.isError && (
|
||||
<StateMessage tone="error" compact onRetry={() => void facets.refetch()}>
|
||||
{t("activity.filtersFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
<HistoryList
|
||||
state={state}
|
||||
empty={t("audit.empty")}
|
||||
count={t("activity.audit.count", { count: state.rows.length })}
|
||||
>
|
||||
{state.rows.map((row, index) => {
|
||||
const { head, sub } = auditText(t, row, lang);
|
||||
const who = actor(row);
|
||||
return (
|
||||
<HistoryRow
|
||||
key={row.id}
|
||||
navIndex={index}
|
||||
navText={`${auditActionLabel(t, row.action)} ${head ?? ""}`}
|
||||
grid={GRID}
|
||||
open={open === row.id}
|
||||
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||
title={formatDateTimeSec(row.created_at, lang)}
|
||||
cells={
|
||||
<>
|
||||
<span className="truncate font-medium">{auditActionLabel(t, row.action)}</span>
|
||||
<RelativeTime
|
||||
at={row.created_at}
|
||||
seconds
|
||||
className="tabular-nums text-muted sm:hidden"
|
||||
/>
|
||||
<span
|
||||
className={`hidden truncate sm:block ${who ? "text-muted" : "italic text-muted"}`}
|
||||
>
|
||||
{who ?? t("audit.system")}
|
||||
</span>
|
||||
<RelativeTime
|
||||
at={row.created_at}
|
||||
seconds
|
||||
className="hidden tabular-nums text-muted sm:block"
|
||||
/>
|
||||
<span className="col-span-2 truncate text-muted sm:col-span-1">
|
||||
{[head, sub].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AuditDetail row={row} who={who} onRequest={onRequest} />
|
||||
</HistoryRow>
|
||||
);
|
||||
})}
|
||||
</HistoryList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditDetail({
|
||||
row,
|
||||
who,
|
||||
onRequest,
|
||||
}: {
|
||||
row: AuditRow;
|
||||
who: string | null;
|
||||
onRequest: (requestId: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { head, sub } = auditText(t, row, lang);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
||||
<dt>{t("audit.cols.actor")}</dt>
|
||||
<dd>{who ?? t("audit.system")}</dd>
|
||||
<dt>{t("audit.cols.when")}</dt>
|
||||
<dd className="tabular-nums">{formatDateTimeSec(row.created_at, lang)}</dd>
|
||||
{(head || sub) && (
|
||||
<>
|
||||
<dt>{t("audit.cols.details")}</dt>
|
||||
<dd className="wrap-break-word">{[head, sub].filter(Boolean).join(" · ")}</dd>
|
||||
</>
|
||||
)}
|
||||
{row.target_id && (
|
||||
<>
|
||||
<dt>{t("activity.audit.target")}</dt>
|
||||
<dd className="font-mono">{row.target_id}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{(row.before || row.after) && (
|
||||
<pre className="overflow-x-auto whitespace-pre font-mono">
|
||||
{JSON.stringify({ before: row.before, after: row.after }, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{/* A request's lines are kept days, its row months: a link to none would open an empty list. */}
|
||||
{row.request_id &&
|
||||
(row.log_lines > 0 ? (
|
||||
<div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="underline decoration-dotted underline-offset-2 hover:decoration-solid"
|
||||
onClick={() => onRequest(row.request_id!)}
|
||||
>
|
||||
{t("activity.audit.requestLines")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p>{t("activity.audit.noRequestLines")}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, type AdminQuotaRow } from "../../lib/api";
|
||||
import { sinceOf } from "../../lib/activityUrlState";
|
||||
import { formatNumber, quotaActionLabel } from "../../lib/format";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import type { Shared } from "./ActivityRuns";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// Activity → Quota: the shared YouTube API budget's spend over the page's time range — per day
|
||||
// (Pacific days, as the budget resets), then per account, each opening to its actions. The
|
||||
// account filter narrows both to one account; the text filter has nothing to match here.
|
||||
|
||||
export default function ActivityQuota({ shared }: { shared: Shared }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const q = useQuery({
|
||||
queryKey: qk.adminQuota({ window: shared.window, user: shared.user }),
|
||||
queryFn: () => api.adminQuota(sinceOf(shared.window), shared.user),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const data = q.data;
|
||||
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
|
||||
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
|
||||
|
||||
return (
|
||||
<div className={q.isPlaceholderData ? "opacity-60" : ""} aria-busy={q.isPlaceholderData}>
|
||||
<p className="text-xs text-muted mb-3 leading-relaxed">
|
||||
{t("stats.introBefore")}
|
||||
<b className="text-fg/80">{t("stats.introSystem")}</b>
|
||||
{t("stats.introAfter")}
|
||||
</p>
|
||||
{q.isError && !data ? (
|
||||
<StateMessage tone="error" onRetry={() => void q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !data ? (
|
||||
<LoadingState label={t("stats.loading")} />
|
||||
) : (
|
||||
<>
|
||||
{q.isError && (
|
||||
<StateMessage tone="error" compact onRetry={() => void q.refetch()} className="mb-3">
|
||||
{t("activity.stale")}
|
||||
</StateMessage>
|
||||
)}
|
||||
<div className={`${SURFACE.card} p-3 mb-4`}>
|
||||
<div className="text-xs text-muted mb-2">
|
||||
{t(shared.window === "all" ? "activity.quota.totalAll" : "activity.quota.total", {
|
||||
count: grandTotal,
|
||||
window: t(`activity.windows.${shared.window}`),
|
||||
units: formatNumber(grandTotal, lang),
|
||||
})}
|
||||
</div>
|
||||
{data.daily.length === 0 ? (
|
||||
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
|
||||
) : (
|
||||
// Each day's figure is in its bar's text too (read out, not shown): the tooltip only
|
||||
// reaches a pointer.
|
||||
<div
|
||||
className="flex items-end gap-0.5 h-24"
|
||||
role="list"
|
||||
aria-label={t("activity.quota.daily")}
|
||||
>
|
||||
{data.daily.map((d) => (
|
||||
<div
|
||||
key={d.day}
|
||||
role="listitem"
|
||||
// The day under the pointer is lit by the B3 layer over its whole column, not by
|
||||
// dimming the bar: an opacity dim reads as "disabled".
|
||||
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
|
||||
title={t("stats.dailyTooltip", {
|
||||
count: d.total,
|
||||
day: d.day,
|
||||
units: formatNumber(d.total, lang),
|
||||
})}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("stats.dailyTooltip", {
|
||||
count: d.total,
|
||||
day: d.day,
|
||||
units: formatNumber(d.total, lang),
|
||||
})}
|
||||
</span>
|
||||
<div
|
||||
className="w-full bg-accent rounded-t"
|
||||
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.rows.length === 0 ? (
|
||||
<div className="text-muted py-4">{t("stats.noUsageInRange")}</div>
|
||||
) : (
|
||||
data.rows.map((r) => (
|
||||
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
|
||||
const isSystem = row.user_id === null;
|
||||
return (
|
||||
<div className={`${SURFACE.card} p-3`}>
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-3"
|
||||
>
|
||||
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
||||
{isSystem ? t("stats.system") : row.email}
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums shrink-0">
|
||||
{formatNumber(row.total, lang)}
|
||||
</span>
|
||||
</Pressable>
|
||||
{/* Proportion bar */}
|
||||
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full"
|
||||
style={{ width: `${Math.max(2, (row.total / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{open && actions.length > 0 && (
|
||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||
{actions.map(([action, units]) => (
|
||||
<div key={action} className="flex items-center justify-between gap-2">
|
||||
<span>{quotaActionLabel(t, action)}</span>
|
||||
<span className="tabular-nums">{formatNumber(units, lang)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, type LogFacet, type RunQuery, type RunRow } from "../../lib/api";
|
||||
import {
|
||||
RUN_STATUSES,
|
||||
sinceOf,
|
||||
type ActivityUrlState,
|
||||
type RunStatusFilter,
|
||||
} from "../../lib/activityUrlState";
|
||||
import { formatDateTimeSec, formatNumber, formatRuntime } from "../../lib/format";
|
||||
import { jobFailureText, jobResultText, parseSummary } from "../../lib/jobResult";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useHistoryList } from "../../lib/useHistoryList";
|
||||
import HistoryList from "./HistoryList";
|
||||
import HistoryRow from "../../features/diagnostics/HistoryRow";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import { RelativeTime } from "../../components/ui/RelativeTime";
|
||||
import { StatusDot } from "./RunStatusDot";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
||||
import FilterSelect from "../../components/ui/FilterSelect";
|
||||
|
||||
// Activity → Runs: every background run, newest first — the scheduled ones, an admin's "run now", a
|
||||
// user's work on one channel. A row opens to who started it, when, what it returned or why it
|
||||
// failed, and two ways on: the lines the run logged, and the other runs of its job.
|
||||
|
||||
const GRID =
|
||||
"grid-cols-[0.75rem_minmax(0,1fr)_auto] sm:grid-cols-[0.75rem_minmax(8rem,12rem)_minmax(0,8rem)_5.5rem_4rem_5.5rem_minmax(10rem,1fr)]";
|
||||
|
||||
/** The shared filters, as the page passes them down. */
|
||||
export interface Shared {
|
||||
window: ActivityUrlState["window"];
|
||||
user: string | null;
|
||||
q: string;
|
||||
}
|
||||
|
||||
export default function ActivityRuns({
|
||||
shared,
|
||||
job,
|
||||
status,
|
||||
onFilter,
|
||||
onLogs,
|
||||
}: {
|
||||
shared: Shared;
|
||||
job: string | null;
|
||||
status: RunStatusFilter | null;
|
||||
onFilter: (patch: { job?: string | null; status?: RunStatusFilter | null }) => void;
|
||||
/** Open the server log at this run's lines. */
|
||||
onLogs: (runId: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const query: RunQuery = {
|
||||
user: shared.user ?? undefined,
|
||||
q: shared.q.trim() || undefined,
|
||||
job: job ?? undefined,
|
||||
status: status ?? undefined,
|
||||
};
|
||||
const key = { ...query, window: shared.window };
|
||||
const state = useHistoryList(qk.adminRuns(key), (before) =>
|
||||
api.adminRuns({ ...query, since: sinceOf(shared.window) }, before),
|
||||
);
|
||||
const facets = useQuery({
|
||||
queryKey: qk.adminRunFacets(key),
|
||||
queryFn: () => api.adminRunFacets({ ...query, since: sinceOf(shared.window) }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const count = (list: LogFacet[] | undefined, value: string) =>
|
||||
list?.find((f) => f.value === value)?.count ?? (facets.data ? 0 : undefined);
|
||||
const jobName = (id: string) => t(`scheduler.jobs.${id}`, id);
|
||||
// The job in use stays offered at zero: under the other filters it may count nothing, and a menu
|
||||
// without it would show no way back.
|
||||
const jobs = [
|
||||
...(facets.data?.jobs ?? []),
|
||||
...(job && !facets.data?.jobs.some((f) => f.value === job) ? [{ value: job, count: 0 }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
label={t("activity.runs.job")}
|
||||
options={[
|
||||
{ value: "", label: t("activity.any") },
|
||||
...jobs.map((f) => ({ value: f.value, label: jobName(f.value), count: f.count })),
|
||||
]}
|
||||
selected={[job ?? ""]}
|
||||
onChange={([value]) => onFilter({ job: value || null })}
|
||||
testId="runs-job"
|
||||
/>
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("activity.runs.statusLabel")}
|
||||
value={status ?? "all"}
|
||||
onChange={(value) =>
|
||||
onFilter({ status: value === "all" ? null : (value as RunStatusFilter) })
|
||||
}
|
||||
options={[
|
||||
{ value: "all", label: t("activity.any"), count: facets.data?.total },
|
||||
...RUN_STATUSES.map((s) => ({
|
||||
value: s,
|
||||
label: t(`activity.runs.status.${s}`),
|
||||
count: count(facets.data?.statuses, s),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/* The menus above are built from these counts: without them they offer only "Any". */}
|
||||
{facets.isError && (
|
||||
<StateMessage tone="error" compact onRetry={() => void facets.refetch()}>
|
||||
{t("activity.filtersFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
<HistoryList
|
||||
state={state}
|
||||
empty={t("activity.runs.empty")}
|
||||
count={t("activity.runs.count", { count: state.rows.length })}
|
||||
>
|
||||
{state.rows.map((run, index) => (
|
||||
<HistoryRow
|
||||
key={run.id}
|
||||
navIndex={index}
|
||||
navText={`${jobName(run.job)} ${run.target_title ?? ""}`}
|
||||
grid={GRID}
|
||||
open={open === run.id}
|
||||
onToggle={() => setOpen(open === run.id ? null : run.id)}
|
||||
title={formatDateTimeSec(run.started_at, lang)}
|
||||
cells={
|
||||
<RunCells
|
||||
run={run}
|
||||
actor={run.actor_id === null ? null : state.accounts[String(run.actor_id)]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<RunDetail
|
||||
run={run}
|
||||
actor={run.actor_id === null ? null : state.accounts[String(run.actor_id)]}
|
||||
onLogs={() => onLogs(run.id)}
|
||||
onJob={() => onFilter({ job: run.job })}
|
||||
/>
|
||||
</HistoryRow>
|
||||
))}
|
||||
</HistoryList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** How long the run took; null while it runs, 0 when its start and end are one instant (the runs
|
||||
* moved over from the audit log kept only when they were written — the length is unknown). */
|
||||
function seconds(run: RunRow): number | null {
|
||||
if (!run.finished_at) return null;
|
||||
return (new Date(run.finished_at).getTime() - new Date(run.started_at).getTime()) / 1000;
|
||||
}
|
||||
|
||||
/** What the run came to, in the reader's language: its failure, that the process stopped under it,
|
||||
* or its result. */
|
||||
function useOutcome(run: RunRow): { text: string; tone: string } {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
if (run.status === "error")
|
||||
return {
|
||||
text: jobFailureText(t, run.failure, lang) ?? run.error ?? t("scheduler.dot.error"),
|
||||
tone: "text-danger",
|
||||
};
|
||||
if (run.status === "interrupted") return { text: t("scheduler.interrupted"), tone: "text-warn" };
|
||||
if (run.status === "running") return { text: t("scheduler.runningNow"), tone: "text-accent" };
|
||||
// A run moved over from the audit log before its result rode along as data holds only the
|
||||
// server's English one-liner: read back into a result, as the old audit rows were.
|
||||
const result =
|
||||
run.result ??
|
||||
parseSummary(run.summary) ??
|
||||
(run.status === "skipped" && run.summary ? { skipped: run.summary } : null);
|
||||
if (result) return { text: jobResultText(t, result, lang), tone: "text-muted" };
|
||||
return { text: run.summary ?? "", tone: "text-muted" };
|
||||
}
|
||||
|
||||
function RunCells({ run, actor }: { run: RunRow; actor: string | null | undefined }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const outcome = useOutcome(run);
|
||||
const secs = seconds(run);
|
||||
const name = t(`scheduler.jobs.${run.job}`, run.job);
|
||||
return (
|
||||
<>
|
||||
<StatusDot k={run.status} label={t(`scheduler.dot.${run.status}`)} />
|
||||
<span className="truncate font-medium">
|
||||
{name}
|
||||
{run.target_title && <span className="font-normal text-muted"> · {run.target_title}</span>}
|
||||
</span>
|
||||
{/* A phone has room for the name and the time only; the row opens to the rest. */}
|
||||
<RelativeTime at={run.started_at} seconds className="tabular-nums text-muted sm:hidden" />
|
||||
<span className="hidden truncate text-muted sm:block">
|
||||
{t(`scheduler.trigger.${run.trigger}`)}
|
||||
{actor && ` · ${actor}`}
|
||||
</span>
|
||||
<RelativeTime
|
||||
at={run.started_at}
|
||||
seconds
|
||||
className="hidden tabular-nums text-muted sm:block"
|
||||
/>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{secs ? formatRuntime(Math.max(1, secs), lang) : "—"}
|
||||
</span>
|
||||
{/* The spend carries its unit (a bare figure in a row has no meaning); none spent is blank. */}
|
||||
<span className="hidden truncate tabular-nums text-muted sm:block">
|
||||
{run.quota_units > 0 &&
|
||||
t("activity.runs.units", {
|
||||
count: run.quota_units,
|
||||
units: formatNumber(run.quota_units, lang),
|
||||
})}
|
||||
</span>
|
||||
<span className={`col-span-3 truncate sm:col-span-1 ${outcome.tone}`}>{outcome.text}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RunDetail({
|
||||
run,
|
||||
actor,
|
||||
onLogs,
|
||||
onJob,
|
||||
}: {
|
||||
run: RunRow;
|
||||
actor: string | null | undefined;
|
||||
onLogs: () => void;
|
||||
onJob: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const link = "underline decoration-dotted underline-offset-2 hover:decoration-solid";
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
||||
<dt>{t("activity.runs.fields.started")}</dt>
|
||||
<dd>
|
||||
{run.actor_id === null
|
||||
? t(`scheduler.trigger.${run.trigger}`)
|
||||
: `${actor ?? `#${run.actor_id}`} · ${t(`scheduler.trigger.${run.trigger}`)}`}
|
||||
</dd>
|
||||
<dt>{t("activity.runs.fields.when")}</dt>
|
||||
<dd className="tabular-nums">
|
||||
{formatDateTimeSec(run.started_at, lang)}
|
||||
{run.finished_at && ` → ${formatDateTimeSec(run.finished_at, lang)}`}
|
||||
</dd>
|
||||
{run.target && (
|
||||
<>
|
||||
<dt>{t("activity.runs.fields.target")}</dt>
|
||||
<dd>
|
||||
{run.target_title ? `${run.target_title} · ` : ""}
|
||||
<span className="font-mono">{run.target}</span>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t("activity.runs.fields.quota")}</dt>
|
||||
<dd className="tabular-nums">{formatNumber(run.quota_units, lang)}</dd>
|
||||
</dl>
|
||||
{run.error && (
|
||||
<pre className="whitespace-pre-wrap break-words text-danger/90">{run.error}</pre>
|
||||
)}
|
||||
{run.result && (
|
||||
<pre className="overflow-x-auto whitespace-pre font-mono">
|
||||
{JSON.stringify(run.result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{/* Most runs store no line at all; a link there would only open an empty list. */}
|
||||
{run.log_lines > 0 ? (
|
||||
<Button variant="link" className={link} onClick={onLogs}>
|
||||
{t("activity.runs.logLines", { count: run.log_lines })}
|
||||
</Button>
|
||||
) : (
|
||||
<span>{t("activity.runs.noLogLines")}</span>
|
||||
)}
|
||||
<Button variant="link" className={link} onClick={onJob}>
|
||||
{t("activity.runs.jobRuns")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The activity page's server-log tab, rendered through the page (which owns the shared filters and
|
||||
// the pivots). A request or run id narrows the list to that request or run (and says so, in the
|
||||
// filter row and the URL), and Back returns to the view the pivot was made from; the account in use
|
||||
// stays pickable; "Live" watches the newest page only, and stops on a failed read instead of
|
||||
// re-raising it; a failed read is an error, not an empty log; Copy takes every loaded line, oldest
|
||||
// first.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { LogFacets, LogPage, LogQuery, ServerLogRow } from "../../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars?.id != null ? `${key}:${String(vars.id)}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const logsMock = vi.fn<(query: LogQuery, before: string | null) => Promise<LogPage>>();
|
||||
const facetsMock = vi.fn<(query: LogQuery) => Promise<LogFacets>>();
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminLogs: (query: LogQuery, before: string | null) => logsMock(query, before),
|
||||
adminLogFacets: (query: LogQuery) => facetsMock(query),
|
||||
adminUsers: () => Promise.resolve([{ id: 1, email: "admin@example.com" }]),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const copied: string[] = [];
|
||||
vi.mock("../../lib/clipboard", () => ({
|
||||
copyText: (text: string) => {
|
||||
copied.push(text);
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
}));
|
||||
|
||||
import Activity from "./Activity";
|
||||
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
||||
|
||||
// The filter bar lives in the toolbar, which portals into the shell's band: give it one.
|
||||
const slot = document.body.appendChild(document.createElement("div"));
|
||||
|
||||
function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
|
||||
return {
|
||||
id,
|
||||
ts: new Date(Date.UTC(2026, 8, 19, 12, 0, id)).toISOString(),
|
||||
level: "INFO",
|
||||
source: "api",
|
||||
logger: "siftlode.http",
|
||||
event: "http.request",
|
||||
msg: `GET /api/thing/${id} 200`,
|
||||
attrs: null,
|
||||
exc: null,
|
||||
request_id: `req-${String(id).padStart(8, "0")}`,
|
||||
user_id: 1,
|
||||
job_id: null,
|
||||
run_id: null,
|
||||
debug_session_id: null,
|
||||
version: "0.90.0",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const FACETS: LogFacets = {
|
||||
levels: [{ value: "INFO", count: 3 }],
|
||||
sources: [{ value: "api", count: 3 }],
|
||||
events: [{ value: "http.request", count: 3 }],
|
||||
users: [{ value: "1", count: 3 }],
|
||||
accounts: { "1": "admin@example.com" },
|
||||
total: 3,
|
||||
};
|
||||
|
||||
function renderConsole() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PageToolbarSlot.Provider value={slot}>
|
||||
<Activity />
|
||||
</PageToolbarSlot.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// react-query hands its updates to React on a (real) timer tick, after the promise that caused them.
|
||||
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 20)));
|
||||
|
||||
const lines = () =>
|
||||
within(screen.getByTestId("admin-logs-lines"))
|
||||
.queryAllByRole("button")
|
||||
.filter((b) => b.hasAttribute("aria-expanded"));
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=logs");
|
||||
facetsMock.mockResolvedValue(FACETS);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
logsMock.mockReset();
|
||||
facetsMock.mockReset();
|
||||
});
|
||||
|
||||
describe("AdminLogs", () => {
|
||||
it("a dialog closing onto the same address leaves the open line open", async () => {
|
||||
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(1));
|
||||
fireEvent.click(lines()[0]!);
|
||||
expect(lines()[0]!.getAttribute("aria-expanded")).toBe("true");
|
||||
// A closing dialog steps back over its own history entry: a popstate onto the same address.
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
});
|
||||
expect(lines()[0]!.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("a request id narrows the list to that request, with every other filter off", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=logs&lgrp=error&aq=boom");
|
||||
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
|
||||
await waitFor(() => expect(lines()).toHaveLength(1));
|
||||
fireEvent.click(lines()[0]!);
|
||||
fireEvent.click(screen.getByRole("button", { name: "req-00000001" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ request_id: "req-00000001", levels: undefined, q: undefined }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy();
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
expect([url.get("lreq"), url.get("lgrp"), url.get("aq")]).toEqual(["req-00000001", null, null]);
|
||||
});
|
||||
|
||||
it("Back from a request's lines returns to the view the pivot was made from", async () => {
|
||||
window.history.replaceState(
|
||||
{ sfPage: "activity" },
|
||||
"",
|
||||
"/?page=activity&at=logs&lgrp=error&aq=boom",
|
||||
);
|
||||
const depth = window.history.length;
|
||||
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(1));
|
||||
fireEvent.click(lines()[0]!);
|
||||
fireEvent.click(screen.getByRole("button", { name: "req-00000001" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy(),
|
||||
);
|
||||
expect(window.history.length).toBe(depth + 1);
|
||||
|
||||
await act(async () => {
|
||||
const landed = new Promise((resolve) =>
|
||||
window.addEventListener("popstate", resolve, { once: true }),
|
||||
);
|
||||
window.history.back();
|
||||
await landed;
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
request_id: undefined,
|
||||
levels: ["ERROR", "CRITICAL"],
|
||||
q: "boom",
|
||||
}),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(screen.queryByText("adminLogs.pivotRequest:req-00000001")).toBeNull();
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
expect([url.get("lreq"), url.get("lgrp"), url.get("aq")]).toEqual([null, "error", "boom"]);
|
||||
// Neither jump waited out the typing debounce: no read ever mixed one view's filters with the
|
||||
// other's search text (the request with "boom", or the restored view without it).
|
||||
const mixed = logsMock.mock.calls.filter(([q]) => (q.request_id != null) === (q.q === "boom"));
|
||||
expect(mixed).toEqual([]);
|
||||
});
|
||||
|
||||
it("a run id narrows the list to that run's lines", async () => {
|
||||
logsMock.mockResolvedValue({
|
||||
items: [row(1, { run_id: 77 })],
|
||||
accounts: {},
|
||||
next_cursor: null,
|
||||
});
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(1));
|
||||
fireEvent.click(lines()[0]!);
|
||||
fireEvent.click(screen.getByRole("button", { name: "77" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ run_id: 77, request_id: undefined }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("adminLogs.pivotRun:77")).toBeTruthy();
|
||||
expect(new URLSearchParams(window.location.search).get("lrun")).toBe("77");
|
||||
});
|
||||
|
||||
it("keeps the account in use offered when the account list does not have it", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=logs&au=7");
|
||||
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenCalledWith(expect.objectContaining({ user: "7" }), null),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("activity-user"));
|
||||
|
||||
expect(await screen.findByTestId("activity-user-7")).toBeTruthy();
|
||||
expect(screen.getByTestId("activity-user-1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Live watches the newest page only", async () => {
|
||||
logsMock.mockImplementation(async (_q, before) =>
|
||||
before === null
|
||||
? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
|
||||
: { items: [row(1)], accounts: {}, next_cursor: null },
|
||||
);
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(2));
|
||||
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
|
||||
await waitFor(() => expect(lines()).toHaveLength(3));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
|
||||
|
||||
await waitFor(() => expect(lines()).toHaveLength(2));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "adminLogs.live" }).getAttribute("aria-pressed"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("a page that lands after Live went on is dropped at the next tick", async () => {
|
||||
let landOlder!: (page: LogPage) => void;
|
||||
logsMock.mockImplementation((_q, before) =>
|
||||
before === null
|
||||
? Promise.resolve({ items: [row(3), row(2)], accounts: {}, next_cursor: "c1" })
|
||||
: new Promise<LogPage>((resolve) => (landOlder = resolve)),
|
||||
);
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(2));
|
||||
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
|
||||
await waitFor(() => expect(landOlder).toBeTypeOf("function"));
|
||||
|
||||
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
||||
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
|
||||
await act(async () => landOlder({ items: [row(1)], accounts: {}, next_cursor: null }));
|
||||
await settle();
|
||||
expect(lines()).toHaveLength(3);
|
||||
const olderReads = logsMock.mock.calls.filter(([, before]) => before !== null).length;
|
||||
|
||||
await act(async () => vi.advanceTimersByTimeAsync(4000));
|
||||
await settle();
|
||||
|
||||
expect(lines()).toHaveLength(2);
|
||||
expect(logsMock.mock.calls.filter(([, before]) => before !== null)).toHaveLength(olderReads);
|
||||
});
|
||||
|
||||
it("a failed live read stops the polling and says the list may be old", async () => {
|
||||
logsMock.mockResolvedValueOnce({ items: [row(1)], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(1));
|
||||
|
||||
logsMock.mockRejectedValue(new Error("500"));
|
||||
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
||||
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
|
||||
await act(async () => vi.advanceTimersByTimeAsync(4000));
|
||||
await settle();
|
||||
|
||||
expect(screen.getByRole("alert").textContent).toContain("adminLogs.stale");
|
||||
expect(lines()).toHaveLength(1);
|
||||
const reads = logsMock.mock.calls.length;
|
||||
await act(async () => vi.advanceTimersByTimeAsync(12000));
|
||||
expect(logsMock.mock.calls.length).toBe(reads);
|
||||
// The button says polling stopped, and pressing it reads again.
|
||||
const liveButton = screen.getByRole("button", { name: "adminLogs.live" });
|
||||
expect(liveButton.getAttribute("aria-pressed")).toBe("false");
|
||||
fireEvent.click(liveButton);
|
||||
await settle();
|
||||
expect(logsMock.mock.calls.length).toBe(reads + 1);
|
||||
});
|
||||
|
||||
it("a failed older page says so, not that the list is out of date", async () => {
|
||||
logsMock.mockImplementation(async (_q, before) => {
|
||||
if (before !== null) throw new Error("500");
|
||||
return { items: [row(2)], accounts: {}, next_cursor: "c1" };
|
||||
});
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(1));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("adminLogs.olderFailed");
|
||||
});
|
||||
|
||||
it("facets that failed say the filter menus may be short, in the other tabs' words", async () => {
|
||||
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
||||
facetsMock.mockRejectedValue(new Error("500"));
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(1));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("activity.filtersFailed");
|
||||
});
|
||||
|
||||
it("offers no older page while a filter change loads", async () => {
|
||||
let landErrors!: (page: LogPage) => void;
|
||||
logsMock.mockImplementation((q) =>
|
||||
q.levels
|
||||
? new Promise<LogPage>((resolve) => (landErrors = resolve))
|
||||
: Promise.resolve({ items: [row(2)], accounts: {}, next_cursor: "c1" }),
|
||||
);
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(1));
|
||||
expect(screen.queryByRole("button", { name: "adminLogs.older" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.error/ }));
|
||||
await waitFor(() => expect(landErrors).toBeTypeOf("function"));
|
||||
|
||||
// The rows on screen are the previous filter's: their cursor is not this list's.
|
||||
expect(screen.queryByRole("button", { name: "adminLogs.older" })).toBeNull();
|
||||
await act(async () => landErrors({ items: [row(1)], accounts: {}, next_cursor: null }));
|
||||
});
|
||||
|
||||
it("copies every loaded line, oldest first", async () => {
|
||||
logsMock.mockImplementation(async (_q, before) =>
|
||||
before === null
|
||||
? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
|
||||
: { items: [row(1)], accounts: {}, next_cursor: null },
|
||||
);
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(2));
|
||||
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
|
||||
await waitFor(() => expect(lines()).toHaveLength(3));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "adminLogs.copy" }));
|
||||
});
|
||||
|
||||
const ids = copied[copied.length - 1]!.split("\n").map((l) => JSON.parse(l).id);
|
||||
expect(ids).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("the keyboard can walk the lines and open one (NavGrid items)", async () => {
|
||||
logsMock.mockResolvedValue({ items: [row(2), row(1)], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(2));
|
||||
|
||||
const items = lines().map((b) => b.parentElement!);
|
||||
expect(items.map((el) => el.getAttribute("data-nav-index"))).toEqual(["0", "1"]);
|
||||
expect(lines().every((b) => b.hasAttribute("data-nav-open"))).toBe(true);
|
||||
expect(screen.getByTestId("admin-logs-lines").querySelector("[data-nav-list]")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a failed read is an error with a retry, not an empty log", async () => {
|
||||
logsMock.mockRejectedValue(new Error("502"));
|
||||
renderConsole();
|
||||
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
expect(screen.queryByText("adminLogs.empty")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { Copy, Radio } from "lucide-react";
|
||||
|
||||
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { formatDateTimeSec } from "../../lib/format";
|
||||
import { nextPageOnClick } from "../../lib/nextPage";
|
||||
import { LEVEL_GROUPS, type LevelGroup } from "../../lib/log";
|
||||
import { sinceOf } from "../../lib/activityUrlState";
|
||||
import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../../lib/logsUrlState";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { HistoryFrame } from "./HistoryList";
|
||||
import LogLine from "../../features/diagnostics/LogLine";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
||||
import FilterSelect from "../../components/ui/FilterSelect";
|
||||
import { FilterFoldButton } from "../../components/ui/FilterFold";
|
||||
import { useFilterFold } from "../../lib/useFilterFold";
|
||||
import { DismissibleChip } from "../../components/ui/Chip";
|
||||
import type { Shared } from "./ActivityRuns";
|
||||
|
||||
// Activity → Server log: the server's stored log (`log_events`). One list, newest first; a line
|
||||
// opens in place to its full record, and its request / job / run id narrows the list to that
|
||||
// request, job or run (the page does that: a pivot is a new history entry with every other filter
|
||||
// off). The time range, account and text come from the page's shared bar; the rest live in the URL
|
||||
// (./logsUrlState), so a filtered view is a link. "Élő" re-reads the newest page every
|
||||
// few seconds while the tab is visible — the newest page only: live is watching the top, so paging
|
||||
// into older rows turns it off. A failed read stops the polling (the request already reported it
|
||||
// once) and marks the list as possibly stale, instead of re-raising the failure every few seconds.
|
||||
|
||||
const LIVE_MS = 4000;
|
||||
const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
|
||||
function toQuery(state: LogsUrlState, shared: Shared): LogQuery {
|
||||
return {
|
||||
levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group],
|
||||
sources: state.sources,
|
||||
user: shared.user ?? undefined,
|
||||
event: state.event ?? undefined,
|
||||
request_id: state.requestId ?? undefined,
|
||||
job_id: state.jobId ?? undefined,
|
||||
run_id: state.runId ?? undefined,
|
||||
q: shared.q.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** The ids a line can narrow the list to. */
|
||||
export type LogPivot = Pick<LogsUrlState, "requestId" | "jobId" | "runId">;
|
||||
|
||||
export default function AdminLogs({
|
||||
shared,
|
||||
landed,
|
||||
onPivot,
|
||||
}: {
|
||||
shared: Shared;
|
||||
/** The filters of the entry Back/Forward landed on (the page reads the address bar, once). */
|
||||
landed: { logs: LogsUrlState } | null;
|
||||
/** Show only one request's, job's or run's lines (the page clears every other filter). */
|
||||
onPivot: (pivot: LogPivot) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [state, setState] = useState<LogsUrlState>(() =>
|
||||
logsParamsToState(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
const [live, setLiveState] = useState(false);
|
||||
// The facet row folds away when the screen is short.
|
||||
const fold = useFilterFold();
|
||||
const facetFilters = state.sources.length + (state.event ? 1 : 0);
|
||||
// Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
|
||||
// stops after the newest page, however many pages the cache holds — a "Load older" that lands
|
||||
// after Live was switched on, or a filter set revisited with older pages still cached.
|
||||
const liveRef = useRef(false);
|
||||
const setLive = (on: boolean) => {
|
||||
liveRef.current = on;
|
||||
setLiveState(on);
|
||||
};
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
useUrlMirror(logsParamUpdates(state));
|
||||
// Back/Forward lands on another entry's filters; a dialog closing lands on these same ones, and
|
||||
// changes nothing (Live and the open line stay). Adopted during the render that brings it, with
|
||||
// the page's half.
|
||||
const [seen, setSeen] = useState(landed);
|
||||
if (landed !== seen) {
|
||||
setSeen(landed);
|
||||
if (landed && JSON.stringify(landed.logs) !== JSON.stringify(state)) {
|
||||
setOpen(null);
|
||||
setState(landed.logs);
|
||||
}
|
||||
}
|
||||
const update = (patch: Partial<LogsUrlState>) => {
|
||||
setOpen(null);
|
||||
setState((s) => ({ ...s, ...patch }));
|
||||
};
|
||||
|
||||
const query = toQuery(state, shared);
|
||||
const key = qk.adminLogs({ ...query, window: shared.window });
|
||||
const logs = useInfiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.adminLogs({ ...query, since: sinceOf(shared.window) }, pageParam),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => (liveRef.current ? undefined : (last.next_cursor ?? undefined)),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS : false),
|
||||
});
|
||||
const facets = useQuery({
|
||||
queryKey: qk.adminLogFacets({ ...query, window: shared.window }),
|
||||
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(shared.window) }),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false),
|
||||
});
|
||||
// A failed read stops the polling (the intervals above); the button shows that, and pressing it
|
||||
// again reads now.
|
||||
const polling = live && !logs.isError;
|
||||
// What "Load older" continues from — read off the data, since `hasNextPage` is off while live; not
|
||||
// off placeholder rows, which are the previous filter's.
|
||||
const olderCursor = logs.isPlaceholderData
|
||||
? null
|
||||
: (logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null);
|
||||
// A read that failed while lines were on screen: they are kept, and the message says what the
|
||||
// failure means for them. (With no lines, the list's own error state says it.) Without the facets
|
||||
// the Source and Event menus offer only "Any", as on the other tabs, so the same words say it.
|
||||
const failure = !logs.data
|
||||
? null
|
||||
: logs.isFetchNextPageError
|
||||
? "adminLogs.olderFailed"
|
||||
: logs.isError || (facets.isError && facets.data)
|
||||
? "adminLogs.stale"
|
||||
: facets.isError
|
||||
? "activity.filtersFailed"
|
||||
: null;
|
||||
const retryFailed = () => {
|
||||
if (logs.isFetchNextPageError)
|
||||
void logs.fetchNextPage(nextPageOnClick(logs.isFetchingNextPage));
|
||||
else if (logs.isError) void logs.refetch();
|
||||
if (facets.isError) void facets.refetch();
|
||||
};
|
||||
|
||||
const rows = useMemo(() => logs.data?.pages.flatMap((p) => p.items) ?? [], [logs.data]);
|
||||
const accounts = useMemo(
|
||||
() =>
|
||||
Object.assign(
|
||||
{},
|
||||
facets.data?.accounts,
|
||||
...(logs.data?.pages.map((p) => p.accounts) ?? []),
|
||||
) as Record<string, string>,
|
||||
[facets.data, logs.data],
|
||||
);
|
||||
const accountLabel = (id: string) =>
|
||||
id === "none" ? t("adminLogs.noAccount") : (accounts[id] ?? `#${id}`);
|
||||
|
||||
const toggleLive = () => {
|
||||
if (polling) {
|
||||
setLive(false);
|
||||
return;
|
||||
}
|
||||
// Watching the top: drop the older pages now; `liveRef` keeps each tick to the first page.
|
||||
qc.setQueryData<typeof logs.data>(key, (d) =>
|
||||
d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d,
|
||||
);
|
||||
setLive(true);
|
||||
if (logs.isError) void logs.refetch();
|
||||
if (facets.isError) void facets.refetch();
|
||||
};
|
||||
|
||||
const loadOlder = () => {
|
||||
setLive(false);
|
||||
void logs.fetchNextPage(nextPageOnClick(logs.isFetchingNextPage));
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
// Oldest first, as a log reads.
|
||||
const text = [...rows]
|
||||
.reverse()
|
||||
.map((r) => JSON.stringify(r))
|
||||
.join("\n");
|
||||
const ok = await copyText(text);
|
||||
notify(
|
||||
ok
|
||||
? { level: "success", message: t("adminLogs.copied", { count: rows.length }) }
|
||||
: { level: "error", message: t("adminLogs.copyFailed") },
|
||||
);
|
||||
};
|
||||
|
||||
const groupCount = (group: LevelGroup) => {
|
||||
const levels = facets.data?.levels ?? [];
|
||||
const sum = (list: readonly LogFacet[]) => list.reduce((n, f) => n + f.count, 0);
|
||||
if (group === "all") return facets.data?.total;
|
||||
const inGroup: readonly string[] = LEVEL_GROUPS[group];
|
||||
return facets.data ? sum(levels.filter((f) => inGroup.includes(f.value))) : undefined;
|
||||
};
|
||||
|
||||
const pivot = (patch: Partial<LogPivot>) => {
|
||||
setLive(false);
|
||||
onPivot({ requestId: null, jobId: null, runId: null, ...patch });
|
||||
};
|
||||
|
||||
// "Any", then what the facet counted. The value in use stays offered even at zero — under the
|
||||
// other filters it may count nothing, and a menu without it would show no way back.
|
||||
const facetOptions = (
|
||||
list: readonly LogFacet[] | undefined,
|
||||
selected: string | null,
|
||||
label: (v: string) => string,
|
||||
) => {
|
||||
const facets = list ?? [];
|
||||
const kept =
|
||||
selected && !facets.some((f) => f.value === selected) ? [{ value: selected, count: 0 }] : [];
|
||||
return [
|
||||
{ value: "", label: t("adminLogs.any") },
|
||||
...[...facets, ...kept].map((f) => ({
|
||||
value: f.value,
|
||||
label: label(f.value),
|
||||
count: f.count,
|
||||
})),
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<HistoryFrame
|
||||
testId="admin-logs"
|
||||
list={logs}
|
||||
rowCount={rows.length}
|
||||
rowsClassName="font-mono text-2xs"
|
||||
failure={failure && { text: t(failure), onRetry: retryFailed }}
|
||||
older={
|
||||
olderCursor !== null
|
||||
? { label: t("adminLogs.older"), onClick: loadOlder, disabled: logs.isFetchingNextPage }
|
||||
: null
|
||||
}
|
||||
count={t("adminLogs.count", { count: rows.length })}
|
||||
empty={t("adminLogs.empty")}
|
||||
header={
|
||||
// The page's own band carries the shared filters and stays in reach; this one scrolls.
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("adminLogs.levelLabel")}
|
||||
value={state.group}
|
||||
onChange={(group) => update({ group })}
|
||||
options={GROUPS.map((g) => ({
|
||||
value: g,
|
||||
label: t(`settings.myLog.filters.${g}`),
|
||||
count: groupCount(g),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
|
||||
{!fold.folded && (
|
||||
<>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.source")}
|
||||
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
|
||||
selected={[state.sources[0] ?? ""]}
|
||||
onChange={([source]) => update({ sources: source ? [source] : [] })}
|
||||
testId="logs-source"
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.event")}
|
||||
options={facetOptions(facets.data?.events, state.event, (v) =>
|
||||
v === "none" ? t("adminLogs.noEvent") : v,
|
||||
)}
|
||||
selected={[state.event ?? ""]}
|
||||
onChange={([event]) => update({ event: event || null })}
|
||||
testId="logs-event"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto flex gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon={<Radio className="h-3.5 w-3.5" />}
|
||||
pressed={polling}
|
||||
onClick={toggleLive}
|
||||
>
|
||||
{t("adminLogs.live")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
// While a filter change loads, the rows on screen are the previous filter's.
|
||||
disabled={rows.length === 0 || logs.isPlaceholderData}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{t("adminLogs.copy")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(state.requestId || state.jobId !== null || state.runId !== null) && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
|
||||
{state.requestId && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotRequest", { id: state.requestId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ requestId: null })}
|
||||
/>
|
||||
)}
|
||||
{state.jobId !== null && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotJob", { id: state.jobId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ jobId: null })}
|
||||
/>
|
||||
)}
|
||||
{state.runId !== null && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotRun", { id: state.runId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ runId: null })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{rows.map((row, index) => (
|
||||
<LogLine
|
||||
navIndex={index}
|
||||
key={row.id}
|
||||
ts={row.ts}
|
||||
level={row.level}
|
||||
event={row.event ?? "—"}
|
||||
msg={row.msg}
|
||||
title={formatDateTimeSec(row.ts, lang)}
|
||||
open={open === row.id}
|
||||
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||
>
|
||||
<RowDetail
|
||||
row={row}
|
||||
account={row.user_id === null ? null : accountLabel(String(row.user_id))}
|
||||
onRequest={(requestId) => pivot({ requestId })}
|
||||
onJob={(jobId) => pivot({ jobId })}
|
||||
onRun={(runId) => pivot({ runId })}
|
||||
/>
|
||||
</LogLine>
|
||||
))}
|
||||
</HistoryFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PivotChip({
|
||||
label,
|
||||
clearLabel,
|
||||
onClear,
|
||||
}: {
|
||||
label: string;
|
||||
clearLabel: string;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DismissibleChip onDismiss={onClear} dismissLabel={clearLabel} className="font-mono">
|
||||
{label}
|
||||
</DismissibleChip>
|
||||
);
|
||||
}
|
||||
|
||||
function RowDetail({
|
||||
row,
|
||||
account,
|
||||
onRequest,
|
||||
onJob,
|
||||
onRun,
|
||||
}: {
|
||||
row: ServerLogRow;
|
||||
account: string | null;
|
||||
onRequest: (id: string) => void;
|
||||
onJob: (id: number) => void;
|
||||
onRun: (id: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const pivotCls =
|
||||
"font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid";
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
|
||||
<dt>{t("adminLogs.fields.when")}</dt>
|
||||
<dd className="tabular-nums">{formatDateTimeSec(row.ts, lang)}</dd>
|
||||
<dt>{t("adminLogs.fields.source")}</dt>
|
||||
<dd className="font-mono">
|
||||
{row.source} · {row.logger}
|
||||
</dd>
|
||||
<dt>{t("adminLogs.fields.account")}</dt>
|
||||
<dd>{account ?? "—"}</dd>
|
||||
{row.request_id && (
|
||||
<>
|
||||
<dt>{t("adminLogs.fields.request")}</dt>
|
||||
<dd>
|
||||
<Button
|
||||
variant="link"
|
||||
className={pivotCls}
|
||||
title={t("adminLogs.showRequest")}
|
||||
onClick={() => onRequest(row.request_id!)}
|
||||
>
|
||||
{row.request_id}
|
||||
</Button>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{row.job_id !== null && (
|
||||
<>
|
||||
<dt>{t("adminLogs.fields.job")}</dt>
|
||||
<dd>
|
||||
<Button
|
||||
variant="link"
|
||||
className={pivotCls}
|
||||
title={t("adminLogs.showJob")}
|
||||
onClick={() => onJob(row.job_id!)}
|
||||
>
|
||||
{row.job_id}
|
||||
</Button>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{row.run_id !== null && (
|
||||
<>
|
||||
<dt>{t("adminLogs.fields.run")}</dt>
|
||||
<dd>
|
||||
<Button
|
||||
variant="link"
|
||||
className={pivotCls}
|
||||
title={t("adminLogs.showRun")}
|
||||
onClick={() => onRun(row.run_id!)}
|
||||
>
|
||||
{row.run_id}
|
||||
</Button>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{row.version && (
|
||||
<>
|
||||
<dt>{t("adminLogs.fields.version")}</dt>
|
||||
<dd className="font-mono">{row.version}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words">{row.msg}</pre>
|
||||
{row.attrs && (
|
||||
<pre className="overflow-x-auto whitespace-pre">{JSON.stringify(row.attrs, null, 2)}</pre>
|
||||
)}
|
||||
{row.exc && <pre className="overflow-x-auto whitespace-pre text-danger/90">{row.exc}</pre>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { nextPageOnClick } from "../../lib/nextPage";
|
||||
import type { HistoryList as HistoryListState } from "../../lib/useHistoryList";
|
||||
import NavGrid from "../../components/ui/NavGrid";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
|
||||
// The frame of a history list on the activity page — the runs, the admin actions, the server log:
|
||||
// its own bar (if any), the rows or the honest state instead of them (a failed read is an error with a
|
||||
// retry, never an empty list), a read that failed over rows still on screen, the count, and "Older".
|
||||
// `HistoryFrame` draws it; each list says only what its failure and its "Older" are — the server log's
|
||||
// count its filter facets and stop while Live (AdminLogs), the others not (HistoryList, below).
|
||||
|
||||
/** What a read that failed while rows stayed on screen means, and how to try it again. */
|
||||
export interface FrameFailure {
|
||||
text: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function HistoryFrame({
|
||||
list,
|
||||
rowCount,
|
||||
header,
|
||||
failure,
|
||||
older,
|
||||
count,
|
||||
empty,
|
||||
rowsClassName = "",
|
||||
testId,
|
||||
children,
|
||||
}: {
|
||||
/** The list's read. */
|
||||
list: {
|
||||
data?: unknown;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isPlaceholderData: boolean;
|
||||
refetch: () => unknown;
|
||||
};
|
||||
rowCount: number;
|
||||
/** The list's own bar above the rows. */
|
||||
header?: ReactNode;
|
||||
failure: FrameFailure | null;
|
||||
/** "Older", when there is an older page to ask for. */
|
||||
older: { label: string; onClick: () => void; disabled: boolean } | null;
|
||||
/** "N runs shown", for the footer. */
|
||||
count: string;
|
||||
/** Shown when the filters match nothing. */
|
||||
empty: string;
|
||||
rowsClassName?: string;
|
||||
testId?: string;
|
||||
/** The rows. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div data-testid={testId} className="overflow-clip rounded-xl border border-border bg-bg">
|
||||
{header}
|
||||
{failure && (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={failure.onRetry}
|
||||
className="border-b border-border"
|
||||
>
|
||||
{failure.text}
|
||||
</StateMessage>
|
||||
)}
|
||||
<div
|
||||
className={`${rowsClassName} ${list.isPlaceholderData ? "opacity-60" : ""}`}
|
||||
data-testid={testId && `${testId}-lines`}
|
||||
aria-busy={list.isPlaceholderData}
|
||||
>
|
||||
{list.isError && !list.data ? (
|
||||
<StateMessage tone="error" onRetry={() => void list.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : list.isLoading ? (
|
||||
<LoadingState shape="table" />
|
||||
) : rowCount === 0 ? (
|
||||
<StateMessage compact className="font-sans">
|
||||
{empty}
|
||||
</StateMessage>
|
||||
) : (
|
||||
<NavGrid>{children}</NavGrid>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
|
||||
<span>{count}</span>
|
||||
{older && (
|
||||
<Button variant="outline" size="xs" disabled={older.disabled} onClick={older.onClick}>
|
||||
{older.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A history list read through `useHistoryList` (the runs, the admin actions). */
|
||||
export default function HistoryList<T>({
|
||||
state,
|
||||
empty,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
state: HistoryListState<T>;
|
||||
/** Shown when the filters match nothing. */
|
||||
empty: string;
|
||||
/** "N runs shown", for the footer. */
|
||||
count: string;
|
||||
/** The rows, drawn from `state.rows`. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { list, rows, hasOlder } = state;
|
||||
const fetchOlder = () => void list.fetchNextPage(nextPageOnClick(list.isFetchingNextPage));
|
||||
// A read that failed while rows were on screen keeps them, and says what the failure means.
|
||||
const failure: FrameFailure | null = !list.data
|
||||
? null
|
||||
: list.isFetchNextPageError
|
||||
? { text: t("activity.olderFailed"), onRetry: fetchOlder }
|
||||
: list.isError
|
||||
? { text: t("activity.stale"), onRetry: () => void list.refetch() }
|
||||
: null;
|
||||
return (
|
||||
<HistoryFrame
|
||||
list={list}
|
||||
rowCount={rows.length}
|
||||
failure={failure}
|
||||
older={
|
||||
hasOlder
|
||||
? { label: t("activity.older"), onClick: fetchOlder, disabled: list.isFetchingNextPage }
|
||||
: null
|
||||
}
|
||||
count={count}
|
||||
empty={empty}
|
||||
>
|
||||
{children}
|
||||
</HistoryFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// A background run's status as a dot — the Scheduler's rows and the activity page's run list draw
|
||||
// the same one, so a state reads the same on both.
|
||||
|
||||
// Interrupted is a ring, not a fill: the job did not fail, but its work did not finish either — set
|
||||
// apart from a skipped run (the same colour, filled) and from a failure.
|
||||
const DOT_CLASS = {
|
||||
running: "bg-accent motion-safe:animate-pulse",
|
||||
ok: "bg-success",
|
||||
error: "bg-danger",
|
||||
skipped: "bg-warn",
|
||||
interrupted: "border-2 border-warn",
|
||||
idle: "bg-border",
|
||||
} as const;
|
||||
|
||||
export type DotKey = keyof typeof DOT_CLASS;
|
||||
|
||||
/** A status dot. `label` names it for a screen reader (the tooltip shows only on hover, and only with
|
||||
* hints on); a legend's dot has its words beside it and stays unnamed. */
|
||||
export function StatusDot({ k, label }: { k: DotKey; label?: string }) {
|
||||
return (
|
||||
<span
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
data-dot={k}
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { api } from "../../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
/**
|
||||
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||
* (the same content as the detail page's About tab — description, links, country, language, topics,
|
||||
* keywords) in a glass hover card. The detail is fetched ON DEMAND on hover/focus, sharing the
|
||||
* ["channel", id] react-query cache with the channel page, so the list stays light. The card is
|
||||
* interactive (scrollable, links clickable) — the platform's safePolygon lets the cursor travel the
|
||||
* gap, replacing the hand-rolled 160 ms hide-timer bridge. Shared by the subscribed + discovery
|
||||
* tables.
|
||||
*
|
||||
* The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel
|
||||
* properly keyboard-reachable belongs to the focus/a11y work, not to the mechanical migration.
|
||||
*/
|
||||
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
|
||||
const preview = (text ?? "").trim();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open: open && !!preview,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
offset: 6,
|
||||
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
|
||||
hoverDelay: { open: 0, close: 160 },
|
||||
// Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a
|
||||
// long About never runs its top off-screen — at high browser zoom the short viewport made a fixed
|
||||
// max-height overflow above the top edge, where the scroll container can't reach.
|
||||
fitHeight: true,
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
const { data: ch } = useQuery({
|
||||
queryKey: qk.channel(channelId),
|
||||
queryFn: () => api.channelDetail(channelId),
|
||||
enabled: open && !!channelId,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (!preview) return <span className="text-muted">—</span>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
tabIndex={0}
|
||||
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className={`${SURFACE.popover} w-120 overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
|
||||
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
||||
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
||||
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
||||
>
|
||||
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">{preview}</p>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type { ChannelDetail } from "../../lib/api";
|
||||
import { linkify } from "../../lib/linkify";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { languageName, regionName, vocab } from "../../lib/vocab";
|
||||
|
||||
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
||||
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
|
||||
function parseKeywords(s: string): string[] {
|
||||
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
|
||||
}
|
||||
// topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The
|
||||
// slug is the vocabulary word: named in the reader's language, else shown with spaces.
|
||||
function topicSlugs(urls: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const url of urls) {
|
||||
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
|
||||
let slug = seg;
|
||||
try {
|
||||
slug = decodeURIComponent(seg);
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
slug = slug.trim();
|
||||
if (slug) seen.add(slug);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel "About" content — description, external links, and the enriched metadata
|
||||
* (country, language, topics, keywords). Shared by the channel detail page's About tab and the
|
||||
* Channel manager's About-column hover overlay, so both show exactly the same thing.
|
||||
*/
|
||||
export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const topics = ch.topic_categories?.length ? topicSlugs(ch.topic_categories) : [];
|
||||
const keywords = ch.keywords ? parseKeywords(ch.keywords) : [];
|
||||
const hasAboutDetails =
|
||||
!!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ch.description ? (
|
||||
<p className="measure text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{linkify(ch.description)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||||
)}
|
||||
{ch.external_links && ch.external_links.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{ch.external_links.map((l, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
{l.title || l.url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasAboutDetails && (
|
||||
<div className="mt-5 space-y-2.5 text-sm border-t border-border pt-4">
|
||||
{ch.country && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0">{t("channel.country")}</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{/^[A-Za-z]{2}$/.test(ch.country) && (
|
||||
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
|
||||
<span
|
||||
className={`fi fi-${ch.country.toLowerCase()} rounded-xs shrink-0`}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{regionName(t, ch.country, i18n.language)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ch.default_language && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
|
||||
<span>{languageName(ch.default_language, i18n.language, ch.default_language)}</span>
|
||||
</div>
|
||||
)}
|
||||
{topics.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.topics")}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Future search epic: these topic chips become clickable filters. */}
|
||||
{topics.map((tp) => (
|
||||
<Badge key={tp}>{vocab(t, "topic", tp, tp.replace(/_/g, " "))}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{keywords.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((kw, i) => (
|
||||
<Badge key={i}>{kw}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { invalidateSubscription } from "../../lib/subscriptionCache";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { api, type DiscoveredChannel } from "../../lib/api";
|
||||
import { LS, ownKey } from "../../lib/storage";
|
||||
import { formatCountOrDash } from "../../lib/format";
|
||||
import { hydrateSort, sortRows, type SortState } from "../../lib/columnSort";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { useCardPager } from "../../lib/useCardPager";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import Tooltip from "../../components/ui/Tooltip";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import DataTable, { type Column } from "../../components/ui/DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import ColumnSortControl from "../../components/ui/ColumnSortControl";
|
||||
import Pager from "../../components/ui/Pager";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { useChannelLayout } from "./channelsContext";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
|
||||
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
|
||||
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
|
||||
// the channel's metadata — the scheduler pulls its videos on its next run (see backend).
|
||||
export default function ChannelDiscovery({
|
||||
canWrite,
|
||||
search,
|
||||
onOpenWizard,
|
||||
onViewChannel,
|
||||
}: {
|
||||
canWrite: boolean;
|
||||
// Client-side name filter from the shared top SearchBar (matches title + handle).
|
||||
search: string;
|
||||
onOpenWizard: () => void;
|
||||
onViewChannel: (id: string, name: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
|
||||
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
|
||||
const layout = useChannelLayout();
|
||||
const [sort, setSort] = usePref<SortState>("channelDiscoverySort", null, {
|
||||
localKey: LS.channelDiscoverySort,
|
||||
parse: (raw) => hydrateSort(raw == null ? null : JSON.stringify(raw), []),
|
||||
});
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.discoveredChannels(),
|
||||
queryFn: api.discoveredChannels,
|
||||
});
|
||||
|
||||
const subscribe = useMutation({
|
||||
mutationFn: (c: DiscoveredChannel) => api.subscribeChannel(c.id),
|
||||
onSuccess: (_data, c) => {
|
||||
// The channel moves from "discovery" to "subscribed", and its videos will start
|
||||
// arriving — refresh both lists plus the per-user status.
|
||||
invalidateSubscription(qc, c.id);
|
||||
const name = c.title ?? c.id;
|
||||
// Name the channel and ship a typed payload so the inbox can offer "open in the
|
||||
// Channel manager" / "open on YouTube" links — kept after reload, when live
|
||||
// callbacks are gone (see ChannelSubscribedMeta).
|
||||
notify({
|
||||
level: "success",
|
||||
title: t("channels.discovery.subscribedTitle"),
|
||||
message: t("channels.discovery.subscribedBody", { name }),
|
||||
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) =>
|
||||
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
|
||||
});
|
||||
|
||||
// Subscribing changes the user's real YouTube account and spends a little quota — confirm
|
||||
// first (mirrors the unsubscribe guard in the Subscriptions tab).
|
||||
const onSubscribe = async (c: DiscoveredChannel) => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.discovery.subscribe"),
|
||||
message: t("channels.discovery.confirmSubscribe", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.discovery.subscribe"),
|
||||
});
|
||||
if (ok) subscribe.mutate(c);
|
||||
};
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const rows = (query.data ?? []).filter(
|
||||
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
const columns: Column<DiscoveredChannel>[] = [
|
||||
// Subscribe first: it is this table's primary action, the one thing a discovery row is for (the
|
||||
// Subscriptions table has no such button — its row ends in the channel's "…").
|
||||
{
|
||||
key: "actions",
|
||||
header: t("channels.cols.actions"),
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
width: "120px",
|
||||
cardLabel: false,
|
||||
render: (c) => (
|
||||
<Tooltip
|
||||
always={!canWrite}
|
||||
hint={
|
||||
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
icon={<UserPlus />}
|
||||
onClick={() => onSubscribe(c)}
|
||||
aria-disabled={!canWrite}
|
||||
loading={subscribe.isPending}
|
||||
>
|
||||
{t("channels.discovery.subscribe")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "channel",
|
||||
header: t("channels.cols.channel"),
|
||||
sortable: true,
|
||||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||
cardPrimary: true,
|
||||
// Cap the width (a max-w element inside the cell; table-layout:auto ignores a cell max-width) so
|
||||
// a long-titled channel can't stretch the column and overflow the centered table.
|
||||
render: (c) => (
|
||||
<div className="max-w-[18rem]">
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
handle={c.handle}
|
||||
thumbnailUrl={c.thumbnail_url}
|
||||
onView={() => onViewChannel(c.id, c.title ?? c.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "about",
|
||||
header: t("channels.cols.about"),
|
||||
hideInCard: true,
|
||||
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
||||
},
|
||||
subsColumn<DiscoveredChannel>(t, lang),
|
||||
{
|
||||
key: "videos",
|
||||
header: t("channels.discovery.cols.totalVideos"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (c) => c.video_count ?? -1,
|
||||
render: (c) => (
|
||||
<span className="text-muted tabular-nums">{formatCountOrDash(c.video_count, lang)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "inPlaylists",
|
||||
header: t("channels.discovery.cols.inPlaylists"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (c) => c.playlist_video_count,
|
||||
render: (c) => (
|
||||
<Tooltip
|
||||
hint={t("channels.discovery.cols.inPlaylistsHint", {
|
||||
videos: c.playlist_video_count,
|
||||
playlists: c.playlist_count,
|
||||
})}
|
||||
>
|
||||
<span className="text-muted tabular-nums cursor-help">
|
||||
{c.playlist_video_count} / {c.playlist_count}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Card layout paginates like the table (slice the sorted rows; the Pager reports changes). Only
|
||||
// computed for cards — DataTable sorts and slices internally, so doing it in table mode too would
|
||||
// copy+sort the whole list every render for a result nothing reads.
|
||||
const isCards = layout === "cards";
|
||||
const sortedRows = isCards ? sortRows(rows, columns, sort, lang) : rows;
|
||||
const {
|
||||
paged: pagedRows,
|
||||
pagerProps,
|
||||
resetPage,
|
||||
} = useCardPager(sortedRows, LS.channelDiscoveryCardSize, isCards);
|
||||
// Changing what you're looking at starts over at the first page. NOT keyed on the row count:
|
||||
// subscribing drops a row from this tab, and being thrown to page 1 for it would punish the very
|
||||
// action you just took — a list that shrank past your page is handled by the hook's clamp.
|
||||
useEffect(() => {
|
||||
resetPage();
|
||||
}, [search, sort, resetPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The table controls are the page's band (B4); only the rows scroll (sticky header). The intro
|
||||
scrolls with them: in the band it held two lines of a short window for text read once. */}
|
||||
<PageToolbar>
|
||||
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
|
||||
{layout === "cards" && (
|
||||
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-end gap-3">
|
||||
<Pager {...pagerProps} />
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
label={t("channels.sort.label")}
|
||||
defaultLabel={t("channels.sort.default")}
|
||||
ascLabel={t("channels.sort.asc")}
|
||||
descLabel={t("channels.sort.desc")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-4 max-w-384 mx-auto">
|
||||
<p className="text-xs text-muted leading-relaxed pt-2">{t("channels.discovery.intro")}</p>
|
||||
{query.isError && !query.data ? (
|
||||
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : query.isLoading ? (
|
||||
<LoadingState shape="list" label={t("channels.discovery.loading")} />
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={ownKey(LS.channelDiscoveryTable) ?? undefined}
|
||||
controlsPosition="both"
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
emptyText={t("channels.discovery.empty")}
|
||||
keyboardNav
|
||||
narrowedBy={q}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
rows={pagedRows}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
aboutKey="about"
|
||||
actionsKey="actions"
|
||||
emptyText={t("channels.discovery.empty")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel manager's card layout as a keyboard list: each card is an item, and Enter opens
|
||||
// the channel through the name ChannelLink renders. VirtualGrid is replaced by a stand-in that mounts
|
||||
// every item — jsdom has no layout for the virtualizer to measure — and registers with listNav exactly
|
||||
// when the real one would: when it is asked to (`keyboardNav`).
|
||||
import { Fragment, useRef } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import type { VirtualGridProps } from "../../components/ui/VirtualGrid";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../../components/ui/VirtualGrid", async () => {
|
||||
const { useListNav } = await import("../../lib/listNav");
|
||||
return {
|
||||
default: function GridStandIn({
|
||||
items,
|
||||
getKey,
|
||||
renderItem,
|
||||
keyboardNav,
|
||||
}: VirtualGridProps<unknown>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: !!keyboardNav });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{items.map((item, i) => (
|
||||
<Fragment key={getKey(item)}>{renderItem(item, 0, i)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import { type Column } from "../../components/ui/DataTable";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Ch = { id: string; title: string };
|
||||
const rows: Ch[] = [
|
||||
{ id: "UCa", title: "Alpha" },
|
||||
{ id: "UCb", title: "Beta" },
|
||||
];
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const focusedTitle = () =>
|
||||
(document.activeElement as HTMLElement).querySelector("[data-nav-open]")?.textContent;
|
||||
|
||||
describe("ChannelLayoutGrid · keyboard", () => {
|
||||
it("walks the cards with j/k and opens the channel on Enter", () => {
|
||||
const opened: string[] = [];
|
||||
const columns: Column<Ch>[] = [
|
||||
{
|
||||
key: "channel",
|
||||
header: "Channel",
|
||||
cardPrimary: true,
|
||||
render: (c) => (
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
thumbnailUrl={null}
|
||||
onView={() => opened.push(c.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
render(<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} />);
|
||||
press("j");
|
||||
expect(focusedTitle()).toBe("Alpha");
|
||||
press("j");
|
||||
expect(focusedTitle()).toBe("Beta");
|
||||
press("Enter");
|
||||
expect(opened).toEqual(["UCb"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChannelLayoutGrid · the card\'s "…"', () => {
|
||||
it("docks it in the heading row beside the name, not in the meta line", () => {
|
||||
const columns: Column<Ch>[] = [
|
||||
{ key: "channel", header: "Channel", cardPrimary: true, render: (c) => <b>{c.title}</b> },
|
||||
{ key: "stored", header: "Stored", render: () => <i>12</i> },
|
||||
{ key: "actions", header: "", render: (c) => <u data-testid={`more-${c.id}`}>…</u> },
|
||||
];
|
||||
const { getByTestId, getByText } = render(
|
||||
<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} moreKey="actions" />,
|
||||
);
|
||||
const heading = getByText("Alpha").closest("div")!.parentElement!;
|
||||
expect(heading.contains(getByTestId("more-UCa"))).toBe(true);
|
||||
// Exactly once: a slotted column is not also painted in the meta line.
|
||||
expect(document.querySelectorAll('[data-testid="more-UCa"]')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Column } from "../../components/ui/DataTable";
|
||||
import VirtualGrid from "../../components/ui/VirtualGrid";
|
||||
import { CHANNEL_LAYOUT_SPEC } from "../../lib/channelLayout";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The channel manager's `cards` layout, built by reusing the SAME `Column<T>` render closures
|
||||
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
||||
// the card view for free, and a cell's behaviour (priority bump, tag toggle, sync, actions) is defined
|
||||
// once. The `table` layout keeps DataTable; this renders only when the manager's layout is `cards`.
|
||||
//
|
||||
// Named slots place the columns that need bespoke positions; everything else flows in the meta line.
|
||||
// Unlike DataTable's cramped narrow-screen card fallback, the desktop card is roomy, so it shows the
|
||||
// `hideInCard` columns too (About, priority) — the user wants those affordances in every layout.
|
||||
//
|
||||
// Each card is a keyboard item (lib/listNav): `j`/`k` and the arrows walk the cards, and Enter opens
|
||||
// the channel — the name ChannelLink renders is the card's open element.
|
||||
|
||||
interface ChannelLayoutGridProps<T> {
|
||||
rows: T[];
|
||||
columns: Column<T>[];
|
||||
rowKey: (row: T) => string;
|
||||
/** Column key docked at the top-right of the card (the priority stepper). */
|
||||
leadKey?: string;
|
||||
/** Column key rendered as a full-width description line (the About cell + its hover overlay). */
|
||||
aboutKey?: string;
|
||||
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
|
||||
actionsKey?: string;
|
||||
/** Column key docked at the top-right corner after the lead: the card's "…", which
|
||||
* belongs beside the name it acts on, not under the card's last line. */
|
||||
moreKey?: string;
|
||||
rowClassName?: (row: T) => string;
|
||||
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so
|
||||
* a channel answers the right-click in the card layout exactly as it does in the table. */
|
||||
rowProps?: (row: T) => Record<string, unknown>;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export default function ChannelLayoutGrid<T>({
|
||||
rows,
|
||||
columns,
|
||||
rowKey,
|
||||
leadKey,
|
||||
aboutKey,
|
||||
actionsKey,
|
||||
moreKey,
|
||||
rowClassName,
|
||||
rowProps,
|
||||
emptyText,
|
||||
}: ChannelLayoutGridProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const spec = CHANNEL_LAYOUT_SPEC.cards;
|
||||
|
||||
const slotted = new Set([leadKey, aboutKey, actionsKey, moreKey].filter(Boolean) as string[]);
|
||||
const primary = columns.filter((c) => c.cardPrimary);
|
||||
const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined;
|
||||
const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined;
|
||||
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined;
|
||||
const more = moreKey ? columns.find((c) => c.key === moreKey) : undefined;
|
||||
// Everything else flows in the meta line: not the heading, not a slotted column.
|
||||
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
||||
|
||||
const renderCard = (row: T, index: number) => (
|
||||
<div
|
||||
{...rowProps?.(row)}
|
||||
{...navItemAttrs(index)}
|
||||
className={`${SURFACE.card} p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 font-medium">
|
||||
{primary.map((col) => (
|
||||
<Fragment key={col.key}>{col.render(row)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{lead && <div className="shrink-0">{lead.render(row)}</div>}
|
||||
{more && <div className="shrink-0 -my-1">{more.render(row)}</div>}
|
||||
</div>
|
||||
{about && <div className="min-w-0 text-sm">{about.render(row)}</div>}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm min-w-0">
|
||||
{meta.map((col) => (
|
||||
<span key={col.key} className="inline-flex items-center gap-1">
|
||||
{col.cardLabel !== false && <span className="text-muted text-xs">{col.header}</span>}
|
||||
{col.render(row)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{actions && <div className="mt-auto pt-1">{actions.render(row)}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="text-muted py-8 text-center text-sm">{emptyText ?? t("datatable.empty")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualGrid
|
||||
items={rows}
|
||||
getKey={rowKey}
|
||||
minCol={spec.minCol}
|
||||
colMaxWidth={null}
|
||||
rowEst={spec.rowEst}
|
||||
keyboardNav
|
||||
renderItem={(row, _itemWidth, index) => renderCard(row, index)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Avatar from "../../components/ui/Avatar";
|
||||
import { channelYouTubeUrl } from "../../lib/format";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { IconLink } from "../../components/ui/LinkButton";
|
||||
|
||||
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
|
||||
// discovery list (and anywhere else that lists a channel). When `onView` is given the name
|
||||
// is a button that opens the in-app channel view, with middle-click opening YouTube in a new
|
||||
// tab; without it the name is plain text.
|
||||
export default function ChannelLink({
|
||||
id,
|
||||
title,
|
||||
handle,
|
||||
thumbnailUrl,
|
||||
onView,
|
||||
}: {
|
||||
id: string;
|
||||
title: string | null;
|
||||
handle?: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
onView?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ytUrl = channelYouTubeUrl(id, handle);
|
||||
const name = title ?? id;
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
||||
{onView ? (
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={onView}
|
||||
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
|
||||
// card clicks this.
|
||||
data-nav-open=""
|
||||
onAuxClick={(e) => {
|
||||
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps
|
||||
// opening the in-app channel detail.
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
window.open(ytUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
|
||||
}}
|
||||
// title = the full name, so a truncated fluid-column name is still readable on hover.
|
||||
title={name}
|
||||
className="min-h-6 min-w-0 truncate text-sm font-medium hover:text-accent pointer-coarse:min-h-(--touch-target)"
|
||||
>
|
||||
{name}
|
||||
</Pressable>
|
||||
) : (
|
||||
<span title={name} className="text-sm font-medium truncate min-w-0">
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<IconLink
|
||||
href={ytUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
label={t("channels.row.openOnYouTube")}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the catalog-stats hover chip: the compact trigger, the rows it reveals on open (value
|
||||
// formatting + the conditional "left" row), and — the point of a review fix — that each row
|
||||
// still carries its per-stat explanation as a `title`, so the guidance the old inline stats showed is
|
||||
// not lost. i18n is mocked to a key-passthrough so assertions read the stable keys, not translations.
|
||||
// The AnchoredPopover platform is floating-ui based (hover mode), so we stub the observers autoUpdate
|
||||
// wires and wrap in a FloatingTree, and open via focus (hover mode enables useFocus).
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
// Key-passthrough t so assertions read stable keys — but keep the rest of react-i18next intact
|
||||
// (i18n/index.ts calls i18n.use(initReactI18next) at import time).
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import { type MyStatus } from "../../lib/api";
|
||||
import { formatEta, formatNumber } from "../../lib/format";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const status = (over: Partial<MyStatus> = {}): MyStatus =>
|
||||
({
|
||||
channels_total: 10,
|
||||
channels_recent_synced: 3,
|
||||
channels_deep_done: 1,
|
||||
channels_deep_requested: 4,
|
||||
deep_pending_count: 0,
|
||||
deep_eta_seconds: 120,
|
||||
my_videos: 1234,
|
||||
quota_remaining_today: 5000,
|
||||
...over,
|
||||
}) as MyStatus;
|
||||
|
||||
const open = (s: MyStatus) => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelStatsPopover status={s} />
|
||||
</FloatingTree>,
|
||||
);
|
||||
fireEvent.focus(screen.getByRole("button", { name: "channels.stats.label" }));
|
||||
};
|
||||
|
||||
describe("ChannelStatsPopover", () => {
|
||||
it("renders a compact Stats trigger, panel closed until opened", () => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelStatsPopover status={status()} />
|
||||
</FloatingTree>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "channels.stats.label" })).toBeTruthy();
|
||||
expect(screen.queryByText("channels.stats.channels")).toBeNull();
|
||||
});
|
||||
|
||||
it("reveals one row per stat with formatted values", () => {
|
||||
open(status());
|
||||
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
|
||||
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
|
||||
expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio
|
||||
expect(screen.getByText(formatNumber(1234, "en"))).toBeTruthy(); // my_videos, locale-agnostic
|
||||
});
|
||||
|
||||
it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => {
|
||||
open(status({ deep_pending_count: 0 }));
|
||||
expect(screen.queryByText("channels.stats.left")).toBeNull();
|
||||
cleanup();
|
||||
open(status({ deep_pending_count: 2, deep_eta_seconds: 120 }));
|
||||
expect(screen.getByText("channels.stats.left")).toBeTruthy();
|
||||
// The value is the FORMATTED ETA (via formatEta), not raw seconds — asserting it guards the
|
||||
// formatEta wiring, which is untested anywhere else. Using the same formatEta keeps this
|
||||
// locale/impl-agnostic (expected === what the component renders).
|
||||
expect(
|
||||
screen.getByText(formatEta(((k: string) => k) as unknown as TFunction, 120)),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("120")).toBeNull(); // never the raw seconds
|
||||
});
|
||||
|
||||
it("keeps each stat's explanation as a per-row title (hint not lost)", () => {
|
||||
open(status());
|
||||
const row = screen.getByText("channels.stats.channels").closest("div");
|
||||
expect(row?.getAttribute("title")).toBe("channels.stats.channelsHint");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Clock,
|
||||
Film,
|
||||
History,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MyStatus } from "../../lib/api";
|
||||
import { formatEta, formatNumber } from "../../lib/format";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
|
||||
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
|
||||
// with a leading icon. Kept in the manager's own toolbar (the header's SyncStatus is global; these
|
||||
// are channel-catalog figures).
|
||||
export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom-end",
|
||||
offset: 8,
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
// Each row keeps the per-stat explanation the old inline stats carried (as a native `title` on the
|
||||
// row), so hovering a figure still tells the user what it counts — the guidance is not lost.
|
||||
const rows: { icon: LucideIcon; label: string; value: string | number; hint: string }[] = [
|
||||
{
|
||||
icon: Users,
|
||||
label: t("channels.stats.channels"),
|
||||
value: status.channels_total,
|
||||
hint: t("channels.stats.channelsHint"),
|
||||
},
|
||||
{
|
||||
icon: RefreshCw,
|
||||
label: t("channels.stats.recentSynced"),
|
||||
value: `${status.channels_recent_synced}/${status.channels_total}`,
|
||||
hint: t("channels.stats.recentSyncedHint"),
|
||||
},
|
||||
{
|
||||
icon: History,
|
||||
label: t("channels.stats.fullHistory"),
|
||||
value: `${status.channels_deep_done}/${status.channels_deep_requested}`,
|
||||
hint: t("channels.stats.fullHistoryHint"),
|
||||
},
|
||||
...(status.deep_pending_count > 0
|
||||
? [
|
||||
{
|
||||
icon: Clock,
|
||||
label: t("channels.stats.left"),
|
||||
value: formatEta(t, status.deep_eta_seconds),
|
||||
hint: t("channels.stats.leftHint", { count: status.deep_pending_count }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: Film,
|
||||
label: t("channels.stats.myVideos"),
|
||||
value: formatNumber(status.my_videos, lang),
|
||||
hint: t("channels.stats.myVideosHint"),
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
label: t("channels.stats.quotaLeft"),
|
||||
value: formatNumber(status.quota_remaining_today, lang),
|
||||
hint: t("channels.stats.quotaLeftHint"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
icon={<BarChart3 aria-hidden="true" />}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
>
|
||||
{t("channels.stats.label")}
|
||||
</Button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("channels.stats.label")}
|
||||
className={`${SURFACE.popover} w-60 p-2 text-xs animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.label}
|
||||
title={r.hint}
|
||||
className="flex cursor-help items-center gap-2.5 px-1.5 py-1.5"
|
||||
>
|
||||
<r.icon className="h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="flex-1 text-muted">{r.label}</span>
|
||||
<span className="tabular-nums font-medium text-fg">{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel tag editor says a failed load of the tag catalog as one, with a retry — not as "no tags
|
||||
// yet, create one below", which invited a duplicate of a tag that exists. i18n is key-passthrough.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("the channel tag editor", () => {
|
||||
it("says a failed load of the tags as one, with a retry — not as no tags yet", async () => {
|
||||
vi.spyOn(api, "tags").mockRejectedValue(new Error("down"));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<ChannelTagsControl
|
||||
userTags={[]}
|
||||
attachedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onCreate={() => {}}
|
||||
/>
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("channels.row.editTags"));
|
||||
const alert = await screen.findByRole("alert", { hidden: true });
|
||||
expect(alert.textContent).toContain("common.loadError");
|
||||
expect(screen.queryByText("channels.tags.noneYet")).toBeNull();
|
||||
expect(alert.closest("[role=menu]")).toBeNull();
|
||||
expect(await screen.findByText("common.retry")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the suggested-tag ghost chips: on an UNTAGGED channel, ChannelTagsControl renders
|
||||
// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the
|
||||
// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already
|
||||
// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform
|
||||
// and a ResizeObserver-driven fit measurer, so we stub the observer and wrap in a FloatingTree.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
// Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu").
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { name?: string }) => (opts?.name ? `${key}:${opts.name}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import type { Tag } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const suggestions: SuggestedTag[] = [
|
||||
{ tag: docu, confidence: 0.95 },
|
||||
{ tag: history, confidence: 0.6 },
|
||||
];
|
||||
|
||||
function renderControl(props: Partial<Parameters<typeof ChannelTagsControl>[0]> = {}) {
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelTagsControl
|
||||
userTags={[docu, history]}
|
||||
attachedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onCreate={() => {}}
|
||||
suggestions={suggestions}
|
||||
onAcceptSuggestion={onAccept}
|
||||
onDismissSuggestion={onDismiss}
|
||||
{...props}
|
||||
/>
|
||||
</FloatingTree>,
|
||||
);
|
||||
return { onAccept, onDismiss };
|
||||
}
|
||||
|
||||
describe("ChannelTagsControl suggested ghost chips", () => {
|
||||
it("renders a ghost chip per suggestion and accepts on click", () => {
|
||||
const { onAccept } = renderControl();
|
||||
// Role query excludes the aria-hidden fit-measurer copy; the name carries the interpolated tag.
|
||||
fireEvent.click(screen.getByRole("button", { name: "channels.tags.acceptSuggestion:docu" }));
|
||||
expect(onAccept).toHaveBeenCalledWith(docu.id);
|
||||
});
|
||||
|
||||
it("dismisses via the × without accepting", () => {
|
||||
const { onAccept, onDismiss } = renderControl();
|
||||
const dismissButtons = screen.getAllByRole("button", {
|
||||
name: "channels.tags.dismissSuggestion",
|
||||
});
|
||||
// Two visible ghost chips (the measurer's copies are aria-hidden → excluded); order matches the
|
||||
// suggestions array, so the second × is the "history" suggestion.
|
||||
expect(dismissButtons).toHaveLength(2);
|
||||
fireEvent.click(dismissButtons[1]!);
|
||||
expect(onDismiss).toHaveBeenCalledWith(history.id);
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the attached lane (no ghost chips) once the channel carries a user tag", () => {
|
||||
renderControl({ attachedIds: [docu.id] });
|
||||
// A tagged channel is not "untagged" → the ghost lane is not rendered at all (the server excludes
|
||||
// tagged channels from suggestions; the control mirrors that with its attached-XOR-ghost branch).
|
||||
expect(
|
||||
screen.queryAllByRole("button", { name: "channels.tags.dismissSuggestion" }),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Plus, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../../lib/api";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { fitChipsWithPill } from "../../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
|
||||
import { Chip, DismissibleChip } from "../../components/ui/Chip";
|
||||
import { chipClass } from "../../components/ui/chipStyles";
|
||||
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
// 1. Stable edit anchor — the "+" edit trigger LEADS the row (fixed left slot), so toggling tags
|
||||
// (which grows the chip row to its right) never moves it, and the editor popover doesn't drift.
|
||||
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
|
||||
// collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never
|
||||
// wraps to a 2nd line, so it can't widen its table column and reflow every other row.
|
||||
// The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline — tagging is
|
||||
// no longer gated on the top-of-page "Manage tags" dialog.
|
||||
|
||||
interface TagChipProps {
|
||||
tag: Tag;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// `block truncate` on the name, not the chip: the chip is inline-flex, where text-overflow cannot bite.
|
||||
function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
const name = <span className="block max-w-32 truncate">{tag.name}</span>;
|
||||
return onClick ? (
|
||||
<Chip variant="tag" size="xs" onClick={onClick} title={title}>
|
||||
{name}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className={chipClass("tag", "xs")}>{name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// A suggested user tag: a dashed "ghost" chip the user hasn't applied yet, learned from
|
||||
// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing
|
||||
// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the
|
||||
// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.)
|
||||
export interface SuggestedTag {
|
||||
tag: Tag;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
function SuggestedChip({
|
||||
suggestion,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestion: SuggestedTag;
|
||||
onAccept: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.round(suggestion.confidence * 100);
|
||||
return (
|
||||
<DismissibleChip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
className="max-w-36 font-medium"
|
||||
icon={<Sparkles />}
|
||||
onClick={onAccept}
|
||||
bodyTitle={t("channels.tags.suggestedHint", { pct })}
|
||||
bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||
onDismiss={onDismiss}
|
||||
dismissLabel={t("channels.tags.dismissSuggestion")}
|
||||
>
|
||||
{suggestion.tag.name}
|
||||
</DismissibleChip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
|
||||
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
|
||||
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
|
||||
* stops a 2nd suggestion from being clipped mid-word right next to the edit "+". */
|
||||
function SuggestedChipsLane({
|
||||
suggestions,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestions: SuggestedTag[];
|
||||
onAccept: (tagId: number) => void;
|
||||
onDismiss: (tagId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|");
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: key,
|
||||
initial: suggestions.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, suggestions.length - visible);
|
||||
|
||||
// Open only while there is a "+N" trigger to anchor it — derived, not closed by an effect, so the
|
||||
// frame in which the last collapsed chip fits again cannot also hold a menu pointing at a
|
||||
// reference that has gone. Same shape as TagFilterChips and the second row below, INCLUDING the
|
||||
// render-phase reset: deriving hides the card but never clears the flag (a hover card whose pill
|
||||
// unmounts under the cursor gets no mouseleave), so without this it springs back the next time
|
||||
// the lane overflows. Convergent — the write falsifies its own condition.
|
||||
const [overflowOpenRaw, setOverflowOpen] = useState(false);
|
||||
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
|
||||
const overflowOpen = overflowOpenRaw && hidden > 0;
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
const { setReference: setOverflowAnchor } = overflowPop.refs;
|
||||
|
||||
return (
|
||||
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
|
||||
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{suggestions.map((s, i) => (
|
||||
<span
|
||||
key={s.tag.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={chipClass("suggest", "xs")}>
|
||||
+{suggestions.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{suggestions.slice(0, visible).map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAccept(s.tag.id)}
|
||||
onDismiss={() => onDismiss(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Chip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
ref={setOverflowAnchor}
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{suggestions.slice(visible).map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAccept(s.tag.id)}
|
||||
onDismiss={() => onDismiss(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
|
||||
* "create + attach" input. Rendered inside an anchored `menu` popover. The rows are the caller's
|
||||
* `userTags`; whether that list has loaded is read from the catalog query itself (mounted only while
|
||||
* the editor is open), so a failed or pending load is said as such, not as "no tags yet". */
|
||||
function TagEditorMenu({
|
||||
userTags,
|
||||
attachedIds,
|
||||
onToggle,
|
||||
onCreate,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
attachedIds: number[];
|
||||
onToggle: (tagId: number) => void;
|
||||
onCreate: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tags = useTags();
|
||||
const attached = new Set(attachedIds);
|
||||
return (
|
||||
<MembershipMenu
|
||||
dense
|
||||
rows={userTags.map((tg) => ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))}
|
||||
onToggle={(row) => onToggle(row.id as number)}
|
||||
status={tags.isError && !tags.data ? "error" : tags.isPending ? "loading" : "ready"}
|
||||
onRetry={() => void tags.refetch()}
|
||||
emptyText={t("channels.tags.noneYet")}
|
||||
errorText={t("common.loadError")}
|
||||
create={{
|
||||
placeholder: t("channels.tags.createPlaceholder"),
|
||||
maxLength: 64,
|
||||
submitLabel: t("channels.tags.create"),
|
||||
onCreate: (name) => {
|
||||
onCreate(name);
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The full tag control for a channel: a stable leading "+" trigger (opens the shared editor),
|
||||
* followed by a width-bounded chip row with "+N" overflow (hover reveals all). Fills its container
|
||||
* width, so a table cell bounds it via the column width and a card bounds it via the card. */
|
||||
export function ChannelTagsControl({
|
||||
userTags,
|
||||
attachedIds,
|
||||
onToggle,
|
||||
onCreate,
|
||||
onChipClick,
|
||||
suggestions,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
bounded = true,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
attachedIds: number[];
|
||||
onToggle: (tagId: number) => void;
|
||||
onCreate: (name: string) => void;
|
||||
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
|
||||
onChipClick?: (tagId: number, name: string) => void;
|
||||
/** Manager only: suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
|
||||
* chips after the (empty) attached row. Omit on the detail page. */
|
||||
suggestions?: SuggestedTag[];
|
||||
onAcceptSuggestion?: (tagId: number) => void;
|
||||
onDismissSuggestion?: (tagId: number) => void;
|
||||
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
|
||||
* table (the manager). Set false where there is free width (the channel detail header), so tags
|
||||
* fit inline instead of needlessly collapsing into "+N". */
|
||||
bounded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
|
||||
// Suggestions only surface for channels with no user tag yet, but guard anyway: never suggest a tag
|
||||
// the channel already carries (an optimistic accept adds it before the query refetches).
|
||||
const shownSuggestions = (suggestions ?? []).filter((s) => !attachedIds.includes(s.tag.id));
|
||||
|
||||
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
|
||||
// natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem
|
||||
// gap and the pill width off real boxes, and fitChipsWithPill computes EXACTLY how many fit (reserving
|
||||
// the pill whenever any overflow). One pass — the count is right as tags are added/removed/renamed —
|
||||
// and it re-measures on width/scale change via the hook's ResizeObserver.
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
|
||||
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
|
||||
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: tagsKey,
|
||||
initial: attached.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, attached.length - visible);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const editPop = useAnchoredPopover({
|
||||
open: editorOpen,
|
||||
onOpenChange: setEditorOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
// The menu contains a free-text "create tag" input — keep both typed chars and pointer-hover out
|
||||
// of the roving list so neither steals the field's caret.
|
||||
hostsInput: true,
|
||||
});
|
||||
const { setReference: setEditAnchor } = editPop.refs;
|
||||
|
||||
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
|
||||
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render —
|
||||
// and the flag reset with it, or the card returns unasked on the next overflow.
|
||||
const [overflowOpenRaw, setOverflowOpen] = useState(false);
|
||||
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
|
||||
const overflowOpen = overflowOpenRaw && hidden > 0;
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
const { setReference: setOverflowAnchor } = overflowPop.refs;
|
||||
|
||||
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
|
||||
const pillClass = chipClass("filter", "xs");
|
||||
|
||||
return (
|
||||
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
|
||||
// content-driven cell re-solves EVERY column's width as chips are added, visibly shifting the whole
|
||||
// table. The fixed width keeps the table stable and centered; the chip row fits what it can and
|
||||
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
|
||||
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
|
||||
<div
|
||||
className={`${chipLaneClass} flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
|
||||
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
|
||||
bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
|
||||
<Chip
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
ref={setEditAnchor}
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
icon={<Plus className="size-3" />}
|
||||
className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
|
||||
{...editPop.getReferenceProps()}
|
||||
>
|
||||
{!bounded && t("channels.tags.add")}
|
||||
</Chip>
|
||||
|
||||
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
|
||||
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
|
||||
channels only, and accepting one removes the channel from the suggestion set — so a channel
|
||||
never shows both, and each lane gets the full cell width instead of clipping the other. */}
|
||||
{attached.length === 0 && shownSuggestions.length > 0 ? (
|
||||
<SuggestedChipsLane
|
||||
suggestions={shownSuggestions}
|
||||
onAccept={(tagId) => onAcceptSuggestion?.(tagId)}
|
||||
onDismiss={(tagId) => onDismissSuggestion?.(tagId)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths.
|
||||
Each chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its
|
||||
right edge; the pill is the sample the hook reserves room for. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{attached.map((tg, i) => (
|
||||
<span
|
||||
key={tg.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<TagChip tag={tg} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={pillClass}>
|
||||
+{attached.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{attached.slice(0, visible).map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={
|
||||
onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Chip
|
||||
size="xs"
|
||||
ref={setOverflowAnchor}
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{attached.map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
|
||||
/>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
|
||||
{/* Click: the shared editor (toggle existing + create inline). */}
|
||||
<PopoverSurface
|
||||
popover={editPop}
|
||||
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
||||
listInside
|
||||
>
|
||||
<TagEditorMenu
|
||||
userTags={userTags}
|
||||
attachedIds={attachedIds}
|
||||
onToggle={onToggle}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createStore, type Store } from "../../lib/store";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { LS, ownKey } from "../../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import Tooltip from "../../components/ui/Tooltip";
|
||||
import DataTable from "../../components/ui/DataTable";
|
||||
import Tabs, { type TabDef } from "../../components/ui/Tabs";
|
||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { pinChannel, useComfortPin } from "../../features/comfort/comfortPin";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../../lib/useChannelPriority";
|
||||
import { placeByPriority } from "../../lib/channelOrder";
|
||||
import { focusNavItem } from "../../lib/listNav";
|
||||
import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher";
|
||||
import ColumnSortControl from "../../components/ui/ColumnSortControl";
|
||||
import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
||||
import TagFilterChips from "../feed/TagFilterChips";
|
||||
import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "../../components/ui/Pager";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../../lib/channelLayout";
|
||||
import { sortRows } from "../../lib/columnSort";
|
||||
import { filterRows, isActive } from "../../lib/tableColumns";
|
||||
import { useFilterFold } from "../../lib/useFilterFold";
|
||||
import { FilterFoldButton } from "../../components/ui/FilterFold";
|
||||
import { HiddenFilterCue } from "../../components/ui/HiddenFilterCue";
|
||||
import { useCardPager } from "../../lib/useCardPager";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { PageMenu } from "../../components/ui/PageMenu";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||
import { optionsFromColumns } from "../../lib/sortOptions";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext";
|
||||
import { useWizard } from "../../features/wizard/wizardContext";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||
|
||||
// The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again.
|
||||
const SURFACED_FILTERS = ["tags"];
|
||||
|
||||
// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared
|
||||
// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the
|
||||
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
|
||||
// off). fullySynced = recent_synced && backfill_done (matches the cell's own "fully synced" badge).
|
||||
const SYNC_CHIPS: { value: string; labelKey: string }[] = [
|
||||
{ value: "", labelKey: "channels.filters.all" },
|
||||
{ value: "needsFull", labelKey: "channels.filters.needsFull" },
|
||||
{ value: "fully", labelKey: "channels.filters.fullySynced" },
|
||||
];
|
||||
|
||||
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
table: Table,
|
||||
cards: LayoutGrid,
|
||||
};
|
||||
|
||||
// The row menu's identity for a channel. Module scope, as useContextMenu asks: a fresh function per
|
||||
// render rebuilt its `buttonProps` on every render, and with them every column.
|
||||
const channelKey = (c: ManagedChannel) => c.id;
|
||||
|
||||
// Which channel's row menu is open, for its "…" (`aria-expanded`, and staying shown while open). A
|
||||
// tiny store rather than a context: a context change walks the whole table to find its readers (~20
|
||||
// ms on 400 rows, each open and close), while a store calls its subscribers and re-renders only the
|
||||
// two buttons whose answer changed (each reads a boolean, so a change elsewhere renders nothing).
|
||||
type OpenRowStore = Store<string | null>;
|
||||
|
||||
function RowMoreButton({
|
||||
id,
|
||||
label,
|
||||
menuProps,
|
||||
register,
|
||||
openRow,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
menuProps: Record<string, unknown>;
|
||||
register: (id: string, el: HTMLButtonElement | null) => void;
|
||||
openRow: OpenRowStore;
|
||||
}) {
|
||||
const mine = useSyncExternalStore(openRow.subscribe, () => openRow.get() === id);
|
||||
return (
|
||||
<MoreButton
|
||||
ref={(el) => register(id, el)}
|
||||
label={label}
|
||||
expanded={mine}
|
||||
menuProps={menuProps}
|
||||
className={mine ? undefined : "reveal"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Channels() {
|
||||
const me = useMe();
|
||||
const panel = useTabPanel();
|
||||
const canWrite = me.can_write;
|
||||
// "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA).
|
||||
const { openWizard: onOpenWizard } = useWizard();
|
||||
// The manager's name filter, status chip, active tab, reset token, and focus-channel intent live
|
||||
// in ChannelsProvider (shared with the header search box + the cross-module "focus this channel"
|
||||
// callers); this page is their primary editor.
|
||||
const {
|
||||
search,
|
||||
filters,
|
||||
view,
|
||||
sort,
|
||||
filtersResetToken,
|
||||
focusName: focusChannelName,
|
||||
focusToken: focusChannelToken,
|
||||
} = useChannels();
|
||||
const layout = useChannelLayout();
|
||||
const {
|
||||
setSearch,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel: onFocusChannel,
|
||||
consumeFocus,
|
||||
} = useChannelsActions();
|
||||
const { openChannel: onViewChannel, setPage } = useNavigationActions();
|
||||
const feedFilters = useFeedFilters();
|
||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
|
||||
// callback) now that Channels reads the feed filters + navigation straight from their providers.
|
||||
// useCallback (like `onView`): the column memo below names it, and the hook lint cannot see the
|
||||
// compiler's memo of a plain closure.
|
||||
const onFilterByTag = useCallback(
|
||||
(tagId: number, name: string) => {
|
||||
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
|
||||
setPage("feed");
|
||||
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
|
||||
},
|
||||
[setFeedFilters, feedFilters, setPage, t],
|
||||
);
|
||||
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
const tagsQuery = useTags();
|
||||
const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
||||
const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
const unblock = useMutation({
|
||||
mutationFn: (id: string) => api.unblockChannel(id),
|
||||
meta: { failTitle: "errors.writeFailed.unblockChannel" },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
},
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
|
||||
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
};
|
||||
|
||||
// A memo for the same reason as the tag actions below: the column memo names it.
|
||||
const allTags = tagsQuery.data;
|
||||
const userTags = useMemo(() => (allTags ?? []).filter((t) => !t.system), [allTags]);
|
||||
|
||||
// The row's stepper updates priority optimistically in place and does NOT re-sort/refetch, so the
|
||||
// list doesn't jump under a press-and-hold (the server list is priority-sorted; refetching would
|
||||
// reorder rows and lose your scroll position) — its new order shows next time the page loads. A
|
||||
// pick from the row's menu DOES re-sort (`pickPriority` below). Press-and-hold is debounced +
|
||||
// flushed on unmount by the shared hook (same flow the channel detail page uses).
|
||||
const { setPriority, bumpPriority } = useChannelPriority({
|
||||
read: (id) =>
|
||||
qc.getQueryData<ManagedChannel[]>(qk.channels())?.find((c) => c.id === id)?.priority,
|
||||
optimistic: (id, next) =>
|
||||
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
|
||||
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
|
||||
),
|
||||
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
});
|
||||
|
||||
// A priority step picked from the row's menu (the owner's call): the one thing a pick is FOR is
|
||||
// to move the channel up (or down) the list, so the list re-sorts at once — only that row moves
|
||||
// (`placeByPriority`, the server's order) — and the page and the focus follow it (`follow`): the
|
||||
// table/card pager turns to its page, and the effect below hands the focus to its "…" (its node
|
||||
// may have been re-inserted, which drops the focus to <body>, or be a virtualized card not yet
|
||||
// mounted). Picked again from there, the next step is one Enter away.
|
||||
const [follow, setFollow] = useState<{ key: string; seq: number } | null>(null);
|
||||
const moreRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pickPriority = (c: ManagedChannel, next: number) => {
|
||||
setPriority(c.id, next);
|
||||
qc.setQueryData<ManagedChannel[]>(
|
||||
qk.channels(),
|
||||
(old) => old && placeByPriority(old, c.id, me.preferences),
|
||||
);
|
||||
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
|
||||
};
|
||||
|
||||
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
|
||||
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
|
||||
// attach/detach + create-and-attach flow is shared with the channel detail page.
|
||||
const tagActionOpts = useMemo<Parameters<typeof useChannelTagActions>[0]>(
|
||||
() => ({
|
||||
optimisticToggle: (id, tagId, attach) =>
|
||||
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
|
||||
(old ?? []).map((ch) =>
|
||||
ch.id === id
|
||||
? {
|
||||
...ch,
|
||||
tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
|
||||
}
|
||||
: ch,
|
||||
),
|
||||
),
|
||||
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
|
||||
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
|
||||
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
|
||||
// the learned pattern.
|
||||
onSuccess: (id) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(id) });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
},
|
||||
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
|
||||
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
|
||||
// failure the tag never actually attached — so the server still suggests it and the ghost chips
|
||||
// must come back rather than leaving an empty cell (honest states). The hook reports the failure.
|
||||
onError: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
},
|
||||
}),
|
||||
[qc],
|
||||
);
|
||||
// The options are a memo so the hook's toggleTag/createAndAttachTag stay the same functions: the
|
||||
// column memo below names them (see `columns`).
|
||||
const { toggleTag, createAndAttachTag } = useChannelTagActions(tagActionOpts);
|
||||
// --- Suggested tags: learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
|
||||
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The
|
||||
// /tags/suggestions query, the session dismiss set, and the optimistic accept/dismiss/bulk logic all
|
||||
// live in useTagSuggestions so they can be unit-tested apart from this component.
|
||||
const {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
} = useTagSuggestions({ tags: tagsQuery.data ?? [], toggleTag });
|
||||
|
||||
const syncSubs = useMutation({
|
||||
mutationFn: () => api.syncSubscriptions(),
|
||||
onSuccess: (r: { subscriptions?: number }) => {
|
||||
invalidate();
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
|
||||
});
|
||||
const deepAll = useMutation({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
onSuccess: (r: { updated?: number }) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: (e) =>
|
||||
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
|
||||
});
|
||||
|
||||
// The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of
|
||||
// tag ids), the same way the sync chips drive `filters.sync` — so the top chips and the Tags column
|
||||
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
|
||||
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
|
||||
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
|
||||
// B4: on a short window the band's filters — the tag chip lane and the status /
|
||||
// pager row — fold behind one "Filters · n"; the tabs and the TAGS editor stay. n = each picked tag,
|
||||
// plus every other active column filter (the status chips are the Sync column's).
|
||||
const fold = useFilterFold();
|
||||
const foldCount =
|
||||
tagFilter.length +
|
||||
Object.entries(filters).filter(([k, v]) => k !== "tags" && isActive(v)).length;
|
||||
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
|
||||
const toggleTagFilter = (tagId: number) => {
|
||||
const id = String(tagId);
|
||||
const base = tagFilter.filter((x) => x !== NO_TAG_SENTINEL);
|
||||
const next = base.includes(id) ? base.filter((x) => x !== id) : [...base, id];
|
||||
setFilters({ ...filters, tags: next });
|
||||
};
|
||||
// The "No tag" chip: filter the table to channels you haven't personally tagged yet. Exclusive with
|
||||
// real tag chips (clicking it drops them); the Tags column's test reads the sentinel (channelColumns).
|
||||
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
|
||||
const toggleNoTagFilter = () =>
|
||||
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
|
||||
// The tag-filter chips, fed to TagFilterChips which folds any overflow into a "+N" pill so the row
|
||||
// stays one line and the trailing "…" actions can't wrap.
|
||||
const tagChips = tagFilterChips({
|
||||
userTags,
|
||||
tags: tagsQuery,
|
||||
picked: tagFilter,
|
||||
noTagOn,
|
||||
toggle: toggleTagFilter,
|
||||
toggleNoTag: toggleNoTagFilter,
|
||||
t,
|
||||
});
|
||||
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
|
||||
// payload, so it is read through an effect event. Naming it would re-seed the box on any render that
|
||||
// changed it, overwriting whatever the user had since typed.
|
||||
//
|
||||
// `consumeFocus` is what stops this page's OWN remounts from re-applying it: this effect runs on
|
||||
// every mount, and this page unmounts on every navigation away — and on opening a channel page,
|
||||
// which does not even change `page`. The provider then reports `focusName` as null, so a later
|
||||
// visit leaves the box exactly as the user left it.
|
||||
const applyFocus = useEffectEvent(() => {
|
||||
if (!focusChannelName) return;
|
||||
setSearch(focusChannelName);
|
||||
consumeFocus();
|
||||
});
|
||||
useEffect(() => applyFocus(), [focusChannelToken]);
|
||||
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
|
||||
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
|
||||
const resetRef = useRef(filtersResetToken);
|
||||
useEffect(() => {
|
||||
if (filtersResetToken === resetRef.current) return;
|
||||
resetRef.current = filtersResetToken;
|
||||
setSearch("");
|
||||
}, [filtersResetToken, setSearch]);
|
||||
|
||||
const onView = useCallback(
|
||||
(c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")),
|
||||
[onViewChannel, t],
|
||||
);
|
||||
// The channel's ONE action list — ONE menu for the list, shared by the table and the card
|
||||
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
|
||||
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
|
||||
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
|
||||
// Keyed by id: a tag toggled in the menu, a priority picked, a poll — each rebuilds the row object
|
||||
// under the open menu, and its "…" must still know the menu is its own.
|
||||
const rowMenu = useContextMenu<ManagedChannel>({ keyOf: channelKey });
|
||||
const rowPin = useComfortPin(rowMenu.open);
|
||||
// The columns read only the menu's STABLE handles. Which row's menu is open reaches each "…" through
|
||||
// the open-row store instead: read in the columns, it rebuilt every column on each open, close and
|
||||
// reposition, and so re-rendered the whole table — ~0.8 s to open the menu on 400 rows.
|
||||
const { buttonProps: rowButtonProps, triggerProps: rowTriggerProps } = rowMenu;
|
||||
const openRowId = rowMenu.open ? (rowMenu.target?.id ?? null) : null;
|
||||
const [openRow] = useState(() => createStore<string | null>(null));
|
||||
useEffect(() => openRow.set(openRowId), [openRow, openRowId]);
|
||||
const channelActions = useChannelActions({
|
||||
onView,
|
||||
onOpenWizard,
|
||||
tags: userTags,
|
||||
tagsState: tagsQuery.isPending ? "loading" : tagsQuery.isError ? "error" : "ready",
|
||||
onToggleTag: (c, tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId)),
|
||||
onPickPriority: pickPriority,
|
||||
});
|
||||
// The row as the cache holds it NOW, not as it was when the menu opened: the Tags submenu stays
|
||||
// open across toggles, and each tick (and the next toggle's attach-or-detach) reads this.
|
||||
const menuRow =
|
||||
rowMenu.target &&
|
||||
((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target);
|
||||
const menuItems = menuRow
|
||||
? channelActions.items(menuRow, {
|
||||
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
|
||||
})
|
||||
: [];
|
||||
|
||||
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
|
||||
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
|
||||
// column — the retired Sync column became a ✓ charm on Stored — so it's
|
||||
// applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards,
|
||||
// still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the
|
||||
// name search filters at this step; the remaining column funnels run in DataTable / filterRows below.
|
||||
//
|
||||
// The table's rows and columns are explicit memos, not inline. Both later reach calls the React
|
||||
// Compiler must assume may mutate them (`filterRows`, `sortRows`, `optionsFromColumns`) past this
|
||||
// page's hooks, and a compiler memo cannot span a hook call — so inline, the compiler dropped their
|
||||
// memo without a word, and every render (each open, close and reposition of the row menu) handed
|
||||
// DataTable new rows and columns: ~0.9 s to open the menu on 400 rows. A useMemo result is frozen.
|
||||
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
||||
const allChannels = channelsQuery.data;
|
||||
const q = search.trim().toLowerCase();
|
||||
const syncFilterValue = filters.sync;
|
||||
const syncedChannels = useMemo(
|
||||
() =>
|
||||
// Sync-status pre-filter (top chips → `filters.sync`), applied before the table/cards see the rows.
|
||||
applySyncFilter(
|
||||
(allChannels ?? []).filter(
|
||||
(c) =>
|
||||
!c.hidden && (!q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)),
|
||||
),
|
||||
syncFilterValue,
|
||||
),
|
||||
[allChannels, q, syncFilterValue],
|
||||
);
|
||||
const s = statusQuery.data;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const registerMore = (id: string, el: HTMLButtonElement | null) => {
|
||||
if (el) moreRefs.current.set(id, el);
|
||||
else moreRefs.current.delete(id);
|
||||
};
|
||||
return buildChannelColumns({
|
||||
t,
|
||||
lang,
|
||||
// Distinct priority levels present in the catalog → the priority column's filter options.
|
||||
prioOptions: [...new Set((allChannels ?? []).map((c) => c.priority))]
|
||||
.sort((a, b) => b - a)
|
||||
.map((p) => ({ value: String(p), label: String(p) })),
|
||||
userTags,
|
||||
bumpPriority,
|
||||
setPriority,
|
||||
more: (c) => (
|
||||
<RowMoreButton
|
||||
id={c.id}
|
||||
label={t("channels.row.menu")}
|
||||
menuProps={rowButtonProps(c)}
|
||||
register={registerMore}
|
||||
openRow={openRow}
|
||||
/>
|
||||
),
|
||||
onView,
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
onAcceptSuggestion: acceptSuggestion,
|
||||
onDismissSuggestion: dismissSuggestion,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
lang,
|
||||
allChannels,
|
||||
userTags,
|
||||
bumpPriority,
|
||||
setPriority,
|
||||
rowButtonProps,
|
||||
openRow,
|
||||
onView,
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
acceptSuggestion,
|
||||
dismissSuggestion,
|
||||
]);
|
||||
|
||||
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||
// where they act.
|
||||
const syncFilter = typeof filters.sync === "string" ? filters.sync : "";
|
||||
const statusChips = (
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("channels.filters.syncLabel")}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
options={SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }))}
|
||||
value={syncFilter}
|
||||
onChange={(sync) => setFilters({ ...filters, sync })}
|
||||
/>
|
||||
);
|
||||
|
||||
// The catalog-wide stats collapse into a compact "Stats" hover chip (right of the tab row, subscribed
|
||||
// only) instead of a crowded inline row.
|
||||
const statsBlock = s ? <ChannelStatsPopover status={s} /> : null;
|
||||
|
||||
// Catalog-wide, occasional actions — tucked into a shared ⋯ overflow menu so they stop crowding the
|
||||
// tags row. Pending mutations keep the item visible but disabled, with a spinning icon.
|
||||
const catalogActions: ActionMenuItem[] = [
|
||||
{
|
||||
id: "sync",
|
||||
label: t("channels.syncSubscriptions"),
|
||||
title: t("channels.syncSubscriptionsHint"),
|
||||
icon: <RefreshCw className={`h-4 w-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />,
|
||||
onSelect: () => syncSubs.mutate(),
|
||||
disabled: syncSubs.isPending,
|
||||
},
|
||||
{
|
||||
id: "backfill",
|
||||
label: t("channels.backfillEverything"),
|
||||
title: t("channels.backfillEverythingHint"),
|
||||
icon: (
|
||||
<History className={`h-4 w-4 ${deepAll.isPending ? "motion-safe:animate-pulse" : ""}`} />
|
||||
),
|
||||
onSelect: () => deepAll.mutate(),
|
||||
disabled: deepAll.isPending,
|
||||
},
|
||||
];
|
||||
const actionButtons = (
|
||||
<ActionMenu ariaLabel={t("channels.actionsLabel")} items={catalogActions} />
|
||||
);
|
||||
|
||||
// Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no
|
||||
// layout to switch). Rides the same generic ViewSwitcher the feed uses.
|
||||
const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
|
||||
id,
|
||||
label: t("channels.layout." + id),
|
||||
icon: LAYOUT_ICON[id],
|
||||
}));
|
||||
const layoutSwitcher = (
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
options={layoutOptions}
|
||||
onChange={setLayout}
|
||||
label={t("channels.layoutLabel")}
|
||||
labelClassName="hidden lg:inline"
|
||||
/>
|
||||
);
|
||||
const hasLayout = view === "subscribed" || view === "discovery";
|
||||
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
|
||||
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
|
||||
const sortControl = (
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
label={t("channels.sort.label")}
|
||||
defaultLabel={t("channels.sort.default")}
|
||||
ascLabel={t("channels.sort.asc")}
|
||||
descLabel={t("channels.sort.desc")}
|
||||
/>
|
||||
);
|
||||
// The card layout paginates like the table (a page of cards + a pager), rather than one long
|
||||
// infinite scroll. Slice the sorted rows; the Pager reports page/size changes.
|
||||
// Only computed for cards: in table mode DataTable sorts and slices internally, so doing it here
|
||||
// too would copy+sort the whole ~300-row array on every render for a result nothing reads.
|
||||
const isCards = layout === "cards";
|
||||
// Cards have no column headers, so apply the shared column filters (sync + any funnel set in table
|
||||
// mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
|
||||
const sortedChannels = isCards
|
||||
? sortRows(filterRows(syncedChannels, columns, filters), columns, sort, lang)
|
||||
: syncedChannels;
|
||||
const {
|
||||
paged: pagedCards,
|
||||
pagerProps,
|
||||
resetPage,
|
||||
} = useCardPager(
|
||||
sortedChannels,
|
||||
LS.channelCardSize,
|
||||
isCards,
|
||||
follow && { seq: follow.seq, match: (c) => c.id === follow.key },
|
||||
);
|
||||
// The focus half of `follow` (the pagers took the page half during render). A mounted row names
|
||||
// its own place; a card the virtualized grid has not mounted yet is found by its place on the page.
|
||||
const focusFollowed = useEffectEvent((key: string) => {
|
||||
const wrap = listRef.current;
|
||||
if (!wrap) return;
|
||||
const button = () => moreRefs.current.get(key) ?? null;
|
||||
const item = button()?.closest("[data-nav-index]");
|
||||
const at = item
|
||||
? Number(item.getAttribute("data-nav-index"))
|
||||
: isCards
|
||||
? sortedChannels.findIndex((c) => c.id === key) -
|
||||
pagerProps.page * Math.max(0, pagerProps.size)
|
||||
: -1;
|
||||
// Filtered out by its new priority (a Prio column filter): nothing to follow — honest, not a
|
||||
// jump to some other row.
|
||||
if (at >= 0) focusNavItem(wrap, at, button);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (follow) focusFollowed(follow.key);
|
||||
}, [follow]);
|
||||
// Changing what you're looking at starts over at the first page, the way DataTable resets its own
|
||||
// page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query
|
||||
// refetches on window focus and after every sync, so a catalogue that merely gained a channel would
|
||||
// otherwise yank you off page 7 for something you didn't do — a list that shrank past your page is
|
||||
// handled by the hook's clamp instead.
|
||||
useEffect(() => {
|
||||
resetPage();
|
||||
}, [filters, search, sort, resetPage]);
|
||||
const cardPager = <Pager {...pagerProps} />;
|
||||
|
||||
const viewTabs: TabDef[] = useMemo(
|
||||
() =>
|
||||
(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
|
||||
id: v,
|
||||
label: t(`channels.tabs.${v}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
const tabs = (
|
||||
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
tabs={viewTabs}
|
||||
active={view}
|
||||
onChange={(id) => setView(id as ChannelsView)}
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
{view === "subscribed" && statsBlock}
|
||||
{view === "subscribed" && layout === "cards" && sortControl}
|
||||
{hasLayout && layoutSwitcher}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more — the
|
||||
// layout, the cards' sort, the status and tag filters, the tag editor and the catalog sync. Backfill
|
||||
// stays in the "…" only: a long quota-heavy run is not a thing to start from a stray right-click.
|
||||
const syncOptions = SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }));
|
||||
const pageMenuItems = menuGroups(
|
||||
hasLayout
|
||||
? [
|
||||
choiceSubmenu({
|
||||
id: "layout",
|
||||
label: t("channels.layoutLabel"),
|
||||
options: layoutOptions.map((o) => ({ value: o.id, label: o.label })),
|
||||
selected: layout,
|
||||
onPick: setLayout,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed" && isCards
|
||||
? [
|
||||
sortSubmenu({
|
||||
id: "sort",
|
||||
label: t("channels.sort.label"),
|
||||
options: optionsFromColumns(columns),
|
||||
sort,
|
||||
onChange: setSort,
|
||||
ascLabel: t("channels.sort.asc"),
|
||||
descLabel: t("channels.sort.desc"),
|
||||
defaultLabel: t("channels.sort.default"),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed"
|
||||
? [
|
||||
choiceSubmenu({
|
||||
id: "status",
|
||||
label: t("channels.filters.syncLabel"),
|
||||
options: syncOptions,
|
||||
selected: syncFilter,
|
||||
onPick: (sync) => setFilters({ ...filters, sync }),
|
||||
}),
|
||||
{ id: "tags", label: t("channels.tags.edit"), onSelect: () => setTagManagerOpen(true) },
|
||||
...(tagFilter.length > 0
|
||||
? [
|
||||
{
|
||||
id: "clearTags",
|
||||
label: t("channels.tags.clearFilter"),
|
||||
onSelect: () => setFilters({ ...filters, tags: [] }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed" ? catalogActions.filter((a) => a.id === "sync") : [],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{pageMenuItems.length > 0 && (
|
||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||
)}
|
||||
{/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
|
||||
scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
|
||||
with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
|
||||
<PageToolbar>
|
||||
{tabs}
|
||||
{view === "subscribed" && (
|
||||
<div className="@container/tagrow px-4 pt-2 pb-2 max-w-384 mx-auto">
|
||||
{/* One row: the TAGS editor trigger (left) + the tag-filter chip lane (folds overflow into
|
||||
"+N") + Clear, then the catalog "…" actions pinned right, so the "…" menu always stays on the
|
||||
first line. Only the suggestions block wraps: below 40rem it takes a line of its own under
|
||||
the row (the owner's pick) — at 375 it had squeezed the chip lane to 0 px and
|
||||
pushed the "…" off the screen. Channel-name search lives in the shared top SearchBar. */}
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 uppercase tracking-wide"
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
icon={<Pencil className="h-3 w-3" />}
|
||||
>
|
||||
{t("channels.tags.label")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={foldCount} />
|
||||
{!fold.folded && <TagFilterChips chips={tagChips} className="flex-1" />}
|
||||
{!fold.folded && tagFilter.length > 0 && (
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
className="text-xs px-2"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Suggested-tags affordance: a sparkle count of the visible suggestions, with a
|
||||
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
|
||||
rest. Hidden when there's nothing to suggest. */}
|
||||
{visibleSuggestions.length > 0 && (
|
||||
<div
|
||||
data-tag-suggestions
|
||||
className="shrink-0 flex items-center gap-1.5 @max-[40rem]/tagrow:order-last @max-[40rem]/tagrow:basis-full"
|
||||
>
|
||||
<Tooltip hint={t("channels.tags.suggestionsHint")}>
|
||||
<Badge tone="accent">
|
||||
<Sparkles />
|
||||
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
{highConfidence.length > 0 && (
|
||||
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
|
||||
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0">{actionButtons}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
|
||||
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
|
||||
{view === "subscribed" && layout === "cards" && !fold.folded && (
|
||||
<div className="px-4 pb-2 max-w-384 mx-auto flex items-center justify-between gap-3 flex-wrap">
|
||||
{statusChips}
|
||||
{cardPager}
|
||||
</div>
|
||||
)}
|
||||
</PageToolbar>
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
)}
|
||||
|
||||
<div {...panel.panelProps(view)}>
|
||||
{view === "discovery" ? (
|
||||
<ChannelDiscovery
|
||||
canWrite={canWrite}
|
||||
search={search}
|
||||
onOpenWizard={onOpenWizard}
|
||||
onViewChannel={onViewChannel}
|
||||
/>
|
||||
) : view === "blocked" ? (
|
||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
<ExcludedChannelList
|
||||
channels={blockedQuery.data ?? []}
|
||||
icon={<Ban className="w-4 h-4 text-muted" />}
|
||||
title={t("channels.blocked.title")}
|
||||
hint={t("channels.blocked.hint")}
|
||||
emptyText={t("channels.blocked.empty")}
|
||||
actionLabel={t("channel.unblock")}
|
||||
actionIcon={<X className="w-3.5 h-3.5" />}
|
||||
onAction={(id) => unblock.mutate(id)}
|
||||
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
/>
|
||||
</div>
|
||||
) : view === "hidden" ? (
|
||||
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
|
||||
instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
<ExcludedChannelList
|
||||
channels={hiddenChannels}
|
||||
icon={<EyeOff className="w-4 h-4 text-muted" />}
|
||||
title={t("channels.hidden.title")}
|
||||
hint={t("channels.hidden.hint")}
|
||||
emptyText={t("channels.hidden.empty")}
|
||||
actionLabel={t("channels.hidden.unhide")}
|
||||
actionIcon={<Eye className="w-3.5 h-3.5" />}
|
||||
onAction={channelActions.unhide}
|
||||
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
||||
`position: sticky` engages on the first scroll (the band's controls row above gives the
|
||||
visual gap). */
|
||||
<div ref={listRef} className="px-4 pb-4 max-w-384 mx-auto">
|
||||
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
|
||||
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
|
||||
card layout swaps the table for a virtualized grid over the same rows (its pager and
|
||||
status chips ride in the fixed band above instead). */}
|
||||
{channelsQuery.isError && !channelsQuery.data ? (
|
||||
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : channelsQuery.isLoading ? (
|
||||
<LoadingState shape="table" label={t("channels.loading")} />
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={syncedChannels}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={ownKey(LS.channelsTable) ?? undefined}
|
||||
// Folded, the status / pager row leaves the band; the list's own end keeps it.
|
||||
controlsPosition={fold.folded ? "bottom" : "both"}
|
||||
controlsLeading={statusChips}
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
fixedLayout
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={rowTriggerProps}
|
||||
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
|
||||
rowClassName={() => "group"}
|
||||
keyboardNav
|
||||
reveal={follow}
|
||||
surfacedFilters={SURFACED_FILTERS}
|
||||
narrowedBy={q}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Cards have no funnels: a Priority or name filter set in the table would narrow them
|
||||
unseen. The tags have their own chip row. */}
|
||||
<HiddenFilterCue
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
by="cards"
|
||||
surfaced={SURFACED_FILTERS}
|
||||
onFilter={(key, value) => setFilters({ ...filters, [key]: value })}
|
||||
/>
|
||||
<ChannelLayoutGrid
|
||||
rows={pagedCards}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
leadKey="priority"
|
||||
aboutKey="about"
|
||||
moreKey="actions"
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={rowTriggerProps}
|
||||
rowClassName={() => "group"}
|
||||
/>
|
||||
{/* Folded, the status / pager row leaves the band, as the table's does — and the
|
||||
list's own end keeps it here too (the cards had once lost their pager). */}
|
||||
{fold.folded && (
|
||||
<div className="pt-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
{statusChips}
|
||||
{cardPager}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// ChannelsProvider's tab-history behaviour: setView pushes a `_chTab` history entry + persists the tab,
|
||||
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
|
||||
// re-routed through useAccountPersistedState, so it's exercised here rather than only by hand E2E.
|
||||
//
|
||||
// Uses REAL storage (jsdom localStorage/sessionStorage) with account 1's local state claimed, so
|
||||
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
|
||||
// navigationContext is mocked, pinned to the channels page. The layout is an account preference
|
||||
// (usePref), read from the `me` cache BootGate fills before the app mounts.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act, render as renderRoot } from "@testing-library/react";
|
||||
import { useEffect, useEffectEvent, type ReactNode } from "react";
|
||||
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigation: () => ({ page: "channels" }),
|
||||
useNavigationActions: () => ({ setPage: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { ChannelsProvider } from "./ChannelsProvider";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
let qc = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<ChannelsProvider>{children}</ChannelsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const withClient = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
|
||||
const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEach)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(1); // the browser holds account 1's state
|
||||
qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { id: 1, preferences: {} });
|
||||
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
|
||||
// and the URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
|
||||
// this provider's mount-time URL seed and change the starting tab.
|
||||
window.history.replaceState({ sfPage: "channels" }, "", "/");
|
||||
});
|
||||
|
||||
describe("ChannelsProvider tab history", () => {
|
||||
it("setView pushes a _chTab history entry and persists the tab", () => {
|
||||
const { result } = render();
|
||||
expect(result.current.view).toBe("subscribed"); // default
|
||||
|
||||
act(() => result.current.setView("hidden"));
|
||||
|
||||
expect(result.current.view).toBe("hidden");
|
||||
expect(window.history.state).toMatchObject({ sfPage: "channels", _chTab: "hidden" });
|
||||
expect(localStorage.getItem(VIEW_KEY)).toBe("hidden");
|
||||
});
|
||||
|
||||
it("does NOT push a new entry when re-selecting the current tab", () => {
|
||||
const { result } = render();
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
act(() => result.current.setView("subscribed")); // already the current tab
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("a genuine Back (popstate carrying _chTab) restores and re-persists that tab", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("blocked"));
|
||||
expect(result.current.view).toBe("blocked");
|
||||
|
||||
// Browser Back to an entry stamped with a different tab.
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } }));
|
||||
});
|
||||
|
||||
expect(result.current.view).toBe("discovery");
|
||||
expect(localStorage.getItem(VIEW_KEY)).toBe("discovery");
|
||||
});
|
||||
|
||||
it("ignores a popstate for an entry outside the manager (no _chTab)", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("hidden"));
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } }));
|
||||
});
|
||||
expect(result.current.view).toBe("hidden"); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChannelsProvider — shareable URL", () => {
|
||||
it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => {
|
||||
// Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins.
|
||||
localStorage.setItem(VIEW_KEY, "blocked");
|
||||
const cf = encodeURIComponent(JSON.stringify({ sync: "needsFull" }));
|
||||
window.history.replaceState(
|
||||
{ sfPage: "channels" },
|
||||
"",
|
||||
`/?ctab=hidden&csort=priority:desc&cf=${cf}`,
|
||||
);
|
||||
const { result } = render();
|
||||
expect(result.current.view).toBe("hidden");
|
||||
expect(result.current.sort).toEqual({ key: "priority", dir: "desc" });
|
||||
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||
});
|
||||
|
||||
it("mirrors the tab into the address bar and clears it back to the default", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("blocked"));
|
||||
expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked");
|
||||
// Back to the default tab clears the key (only non-defaults are emitted).
|
||||
act(() => result.current.setView("subscribed"));
|
||||
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
|
||||
});
|
||||
|
||||
it("a PARTIAL shared link does not clobber the account's stored sort/filters", () => {
|
||||
// Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and
|
||||
// persisting the seed overwrote the account's real stored sort + filter map.
|
||||
localStorage.setItem(
|
||||
"siftlode.channelSort",
|
||||
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
||||
);
|
||||
localStorage.setItem("siftlode.channelsFilterMap", JSON.stringify({ sync: "needsFull" }));
|
||||
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
|
||||
|
||||
const { result } = render();
|
||||
|
||||
// The present dim (tab) is adopted from the link...
|
||||
expect(result.current.view).toBe("hidden");
|
||||
// ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
|
||||
expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
|
||||
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelSort")!)).toEqual({
|
||||
key: "subscribers",
|
||||
dir: "desc",
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap")!)).toEqual({
|
||||
sync: "needsFull",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- the focus-a-channel intent is consumed, not masked -------------------------------------------
|
||||
// `focusChannel` sends the user to the manager with its name filter seeded. The manager is a page
|
||||
// chunk: it unmounts on every navigation away, and on opening a channel page (which does not even
|
||||
// change `page`), and its seeding effect runs again on each mount. So the "already applied" marker
|
||||
// has to live in the PROVIDER — inside the page it would reset with the page and re-apply an intent
|
||||
// from earlier in the session over whatever the user has typed into the search box since.
|
||||
describe("ChannelsProvider — the focus-a-channel intent", () => {
|
||||
/** Stands in for Channels.tsx's `applyFocus` — the same effect event, the same consume call. */
|
||||
function Manager() {
|
||||
const { focusName, focusToken } = useChannels();
|
||||
const { setSearch, consumeFocus } = useChannelsActions();
|
||||
const applyFocus = useEffectEvent(() => {
|
||||
if (!focusName) return;
|
||||
setSearch(focusName);
|
||||
consumeFocus();
|
||||
});
|
||||
useEffect(() => applyFocus(), [focusToken]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Harness({ mounted }: { mounted: boolean }) {
|
||||
return <>{mounted && <Manager />}</>;
|
||||
}
|
||||
|
||||
it("seeds the search box once, and a later visit does not re-seed it", () => {
|
||||
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
|
||||
function Probe() {
|
||||
api = { ...useChannels(), ...useChannelsActions() };
|
||||
return null;
|
||||
}
|
||||
const { rerender } = renderRoot(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted />
|
||||
</ChannelsProvider>,
|
||||
{ wrapper: withClient },
|
||||
);
|
||||
|
||||
act(() => api.focusChannel("Kurzgesagt"));
|
||||
expect(api.search).toBe("Kurzgesagt");
|
||||
|
||||
// The user looks for something else instead.
|
||||
act(() => api.setSearch("veritasium"));
|
||||
|
||||
// Leave the manager (the page chunk unmounts) and come back.
|
||||
rerender(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted={false} />
|
||||
</ChannelsProvider>,
|
||||
);
|
||||
rerender(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted />
|
||||
</ChannelsProvider>,
|
||||
);
|
||||
|
||||
expect(api.search).toBe("veritasium");
|
||||
});
|
||||
|
||||
it("re-seeds when the channel is focused again — the token is the intent", () => {
|
||||
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
|
||||
function Probe() {
|
||||
api = { ...useChannels(), ...useChannelsActions() };
|
||||
return null;
|
||||
}
|
||||
renderRoot(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted />
|
||||
</ChannelsProvider>,
|
||||
{ wrapper: withClient },
|
||||
);
|
||||
|
||||
act(() => api.focusChannel("Kurzgesagt"));
|
||||
act(() => api.setSearch("veritasium"));
|
||||
act(() => api.focusChannel("Kurzgesagt")); // the same channel, a new intent
|
||||
expect(api.search).toBe("Kurzgesagt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChannelsProvider — the layout", () => {
|
||||
// The table or the cards is a choice about the account, so it follows it to every device: the
|
||||
// server's word wins over what this browser last showed.
|
||||
it("is the account's preference, over this browser's copy", () => {
|
||||
localStorage.setItem("siftlode.channelLayout", "table");
|
||||
qc.setQueryData(qk.me(), { id: 1, preferences: { channelLayout: "cards" } });
|
||||
const { result } = renderHook(useChannelLayout, { wrapper });
|
||||
expect(result.current).toBe("cards");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getAccountRaw, LS, useAccountPersistedState } from "../../lib/storage";
|
||||
import { parseChannelLayout } from "../../lib/channelLayout";
|
||||
import { hydrateSort } from "../../lib/columnSort";
|
||||
import { useLatest } from "../../lib/useLatest";
|
||||
import { useSortState } from "../../lib/useSortState";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { useFilterState } from "../../lib/useFilterState";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { rememberNavPosition } from "../../lib/history";
|
||||
import {
|
||||
channelsParamsToState,
|
||||
channelsParamUpdates,
|
||||
hasChannelsParams,
|
||||
type ChannelsUrlState,
|
||||
} from "../../lib/channelsUrlState";
|
||||
import type { FilterMap } from "../../lib/tableColumns";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
import {
|
||||
ChannelLayoutContext,
|
||||
ChannelsActionsContext,
|
||||
ChannelsStateContext,
|
||||
type ChannelsActions,
|
||||
type ChannelsState,
|
||||
} from "./channelsContext";
|
||||
|
||||
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
|
||||
// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
|
||||
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
|
||||
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
|
||||
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||
//
|
||||
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
|
||||
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
|
||||
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
|
||||
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
|
||||
//
|
||||
// The contexts, their value types and the hooks live in channelsContext.ts.
|
||||
|
||||
function clampView(v: string | null): ChannelsView {
|
||||
return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
|
||||
}
|
||||
|
||||
// One-time carry-forward of the retired single-value sync chip (LS.legacyChannelFilter, vocab
|
||||
// all/needs_full/fully_synced) into the new filter map's `sync` key (vocab ""/needsFull/fully), so a
|
||||
// user who had a chip pinned keeps it. Only consulted when channelsFilterMap is still empty.
|
||||
function migrateSyncChip(): FilterMap | null {
|
||||
const old = getAccountRaw(LS.legacyChannelFilter);
|
||||
if (old === "needs_full") return { sync: "needsFull" };
|
||||
if (old === "fully_synced") return { sync: "fully" };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The manager's stored column-filter map, healed to a plain string/string[] object (same discipline as
|
||||
// useFilterState's hydrate). Used only to build the seed's base — the URL keeps whatever the stored map
|
||||
// has for any dimension a partial shared link omits.
|
||||
function readStoredFilters(): FilterMap {
|
||||
const raw = getAccountRaw(LS.channelsFilterMap);
|
||||
if (raw) {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) return v as FilterMap;
|
||||
} catch {
|
||||
/* fall through to the migrate seed */
|
||||
}
|
||||
}
|
||||
return migrateSyncChip() ?? {};
|
||||
}
|
||||
|
||||
// A shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
|
||||
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so
|
||||
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
|
||||
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasChannelsParams(params)) return null;
|
||||
return channelsParamsToState(params, {
|
||||
view: clampView(getAccountRaw(LS.channelsView)),
|
||||
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
||||
filters: readStoredFilters(),
|
||||
});
|
||||
}
|
||||
|
||||
export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const { page } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
|
||||
const [search, setSearchState] = useState("");
|
||||
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
|
||||
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||
// The view is this browser's (useAccountPersistedState), the layout an account preference
|
||||
// (usePref). Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers
|
||||
// the history logic (below) on top of the raw setter.
|
||||
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
|
||||
const view = clampView(viewRaw);
|
||||
const [layoutRaw, setLayout] = usePref<string>("channelLayout", "table", {
|
||||
localKey: LS.channelLayout,
|
||||
parse: (raw) => (typeof raw === "string" ? raw : "table"),
|
||||
});
|
||||
const layout = parseChannelLayout(layoutRaw);
|
||||
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
|
||||
// it against the live columns for display, so the provider stores it un-healed (no options passed).
|
||||
const [sort, setSort] = useSortState(LS.channelSort);
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusNameRaw, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
|
||||
// Shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
const [urlSeed] = useState(readChannelsUrlSeed);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
|
||||
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
|
||||
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
|
||||
// `view` is frozen at mount for the same reason `urlSeed` is: the comparison is against the tab
|
||||
// this load started on, and the three setters are stable by construction (useFilterState,
|
||||
// useSortState and useAccountPersistedState all return a useCallback). So the dep list can name
|
||||
// every value the body reads without the seed re-arming on a later tab switch.
|
||||
const [mountView] = useState(view);
|
||||
useLayoutEffect(() => {
|
||||
if (!urlSeed || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
if (urlSeed.view !== mountView) setViewRaw(urlSeed.view);
|
||||
setFilters(urlSeed.filters);
|
||||
setSort(urlSeed.sort);
|
||||
}, [urlSeed, mountView, setViewRaw, setFilters, setSort]);
|
||||
|
||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||
// it (a dep would recreate the action on every switch and re-render every actions consumer).
|
||||
const viewRef = useLatest(view);
|
||||
|
||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
|
||||
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
|
||||
// of jumping straight out to the previous module.
|
||||
const setView = useCallback(
|
||||
(v: ChannelsView) => {
|
||||
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
|
||||
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
|
||||
if (v === viewRef.current) return;
|
||||
setViewRaw(v); // persists (via useAccountPersistedState)
|
||||
// Only own an entry while the manager is already on screen. Arriving from elsewhere
|
||||
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
|
||||
// one navigation never costs two Back presses.
|
||||
if (window.history.state?.sfPage === "channels") {
|
||||
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
|
||||
// Back re-open the channel overlay or a spent search on top of the tab.
|
||||
rememberNavPosition();
|
||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||
}
|
||||
},
|
||||
[setViewRaw, viewRef],
|
||||
);
|
||||
const focusChannel = useCallback(
|
||||
(name: string) => {
|
||||
setFocusName(name);
|
||||
setFocusToken((n) => n + 1); // re-seed even when the same channel is re-focused
|
||||
setView("subscribed"); // the name filter applies to the subscriptions table
|
||||
setPage("channels");
|
||||
},
|
||||
[setView, setPage],
|
||||
);
|
||||
const goToFullHistory = useCallback(() => {
|
||||
// One controlled map replaces the old status chip: sync=needsFull, and dropping every other
|
||||
// column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
|
||||
setFilters({ sync: "needsFull" });
|
||||
setView("subscribed"); // the sync filter applies to the subscriptions tab
|
||||
setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips
|
||||
setPage("channels");
|
||||
}, [setFilters, setView, setPage]);
|
||||
|
||||
// The manager focus only means anything ON the Channels page, so it is DERIVED from the page
|
||||
// rather than cleared by an effect when you leave. focusChannel sets the name and navigates in the
|
||||
// same handler, so the value is live by the time channels renders; everywhere else it reads null,
|
||||
// one render earlier than the effect used to make it.
|
||||
//
|
||||
// But an intent has to be CONSUMED, not merely masked, and the consumed marker has to live HERE.
|
||||
// Channels is a page chunk that unmounts on every navigation (and on opening a channel page, which
|
||||
// does not even change `page`), so its seeding effect runs again on each remount. A marker inside
|
||||
// it would reset with it and re-apply an intent from earlier in the session over whatever the user
|
||||
// has since typed into the search box. Keyed on the token rather than the name, because
|
||||
// re-focusing the same channel is a new intent.
|
||||
const [focusApplied, setFocusApplied] = useState(0);
|
||||
const consumeFocus = useCallback(() => setFocusApplied(focusToken), [focusToken]);
|
||||
const focusName = page === "channels" && focusApplied !== focusToken ? focusNameRaw : null;
|
||||
|
||||
// Label the entry the manager was REACHED on (setPage pushes a bare { sfPage }), so stepping back
|
||||
// onto it later restores the tab it belonged to rather than leaving whatever tab is current.
|
||||
useEffect(() => {
|
||||
if (page !== "channels") return;
|
||||
const st = window.history.state || {};
|
||||
if (st._chTab === undefined) window.history.replaceState({ ...st, _chTab: view }, "");
|
||||
}, [page, view]);
|
||||
|
||||
// Back/Forward across tab entries: restore the tab the entry carries.
|
||||
useEffect(() => {
|
||||
function onPop(e: PopStateEvent) {
|
||||
const tab = e.state?._chTab;
|
||||
if (!tab) return; // an entry outside the manager — leave the tab alone
|
||||
setViewRaw(clampView(tab as string)); // persists the restored tab (setViewRaw is stable)
|
||||
}
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, [setViewRaw]);
|
||||
|
||||
const state = useMemo<ChannelsState>(
|
||||
() => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
|
||||
[search, filters, view, sort, filtersResetToken, focusName, focusToken],
|
||||
);
|
||||
const actions = useMemo<ChannelsActions>(
|
||||
() => ({
|
||||
setSearch,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel,
|
||||
consumeFocus,
|
||||
goToFullHistory,
|
||||
}),
|
||||
[
|
||||
setSearch,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel,
|
||||
consumeFocus,
|
||||
goToFullHistory,
|
||||
],
|
||||
);
|
||||
|
||||
// Mirror the content state (tab + sort + column filters) into the address bar under the namespaced
|
||||
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
|
||||
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
|
||||
// deliberately transient rail-search, like the feed's and plex's.
|
||||
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
|
||||
|
||||
return (
|
||||
<ChannelsActionsContext.Provider value={actions}>
|
||||
<ChannelLayoutContext.Provider value={layout}>
|
||||
<ChannelsStateContext.Provider value={state}>{children}</ChannelsStateContext.Provider>
|
||||
</ChannelLayoutContext.Provider>
|
||||
</ChannelsActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Hidden / Blocked channel cards as a keyboard list: `j`/`k` walk the cards, Enter opens
|
||||
// the channel — the ChannelLink name is the card's open element — and the card's own action button
|
||||
// keeps its Enter.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const channels = [
|
||||
{ id: "UCa", title: "Alpha", thumbnail_url: null },
|
||||
{ id: "UCb", title: "Beta", thumbnail_url: null },
|
||||
];
|
||||
|
||||
describe("ExcludedChannelList · keyboard", () => {
|
||||
it("walks the cards with j/k and opens the channel on Enter", () => {
|
||||
const onView = vi.fn();
|
||||
render(
|
||||
<ExcludedChannelList
|
||||
channels={channels}
|
||||
icon={null}
|
||||
title="Hidden"
|
||||
hint=""
|
||||
emptyText=""
|
||||
actionLabel="Unhide"
|
||||
actionIcon={null}
|
||||
onAction={() => {}}
|
||||
onView={onView}
|
||||
/>,
|
||||
);
|
||||
press("j");
|
||||
press("j");
|
||||
expect(document.activeElement?.textContent).toContain("Beta");
|
||||
press("k");
|
||||
expect(document.activeElement?.textContent).toContain("Alpha");
|
||||
press("Enter");
|
||||
expect(onView).toHaveBeenCalledWith(channels[0]);
|
||||
expect(screen.getAllByRole("button", { name: "Unhide" })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { type ReactNode } from "react";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import NavGrid from "../../components/ui/NavGrid";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { SectionLabel } from "../../components/ui/Headings";
|
||||
|
||||
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
|
||||
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
|
||||
// with one reversing action, instead of a bare row of name chips. Both tabs pass the same shape; only
|
||||
// the header icon/label, the action, and the copy differ.
|
||||
export interface ExcludedChannel {
|
||||
id: string;
|
||||
title: string | null;
|
||||
handle?: string | null;
|
||||
thumbnail_url: string | null;
|
||||
}
|
||||
|
||||
export default function ExcludedChannelList({
|
||||
channels,
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
emptyText,
|
||||
actionLabel,
|
||||
actionIcon,
|
||||
onAction,
|
||||
onView,
|
||||
}: {
|
||||
channels: ExcludedChannel[];
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
emptyText: string;
|
||||
actionLabel: string;
|
||||
actionIcon: ReactNode;
|
||||
onAction: (id: string) => void;
|
||||
onView?: (c: ExcludedChannel) => void;
|
||||
}) {
|
||||
if (channels.length === 0) return <p className="text-sm text-muted py-8">{emptyText}</p>;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<SectionLabel>{title}</SectionLabel>
|
||||
</div>
|
||||
<NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((c, i) => (
|
||||
<div
|
||||
key={c.id}
|
||||
{...navItemAttrs(i)}
|
||||
className={`${SURFACE.card} flex items-center gap-2 px-3 py-2`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
handle={c.handle}
|
||||
thumbnailUrl={c.thumbnail_url}
|
||||
onView={onView ? () => onView(c) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="xs" icon={actionIcon} onClick={() => onAction(c.id)}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</NavGrid>
|
||||
<p className="text-2xs text-muted mt-3">{hint}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||
import { api, type MyStatus } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { formatCompact } from "../../lib/format";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// Per-user status (not the global catalog): shows the number of videos available to *this*
|
||||
// user, how many of *their* channels are still being fetched, and how many lack full
|
||||
// history (clickable -> channel manager filtered to those). The pause control is admin-only.
|
||||
export default function SyncStatus({
|
||||
isAdmin,
|
||||
onGoToFullHistory,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
onGoToFullHistory: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
// A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
|
||||
// popover. It portals out of the header, whose glass backdrop-filter would otherwise trap its
|
||||
// stacking and positioning.
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "dialog",
|
||||
role: "dialog",
|
||||
placement: "bottom-end",
|
||||
offset: 8,
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
const { data } = useQuery({
|
||||
queryKey: qk.myStatus(),
|
||||
queryFn: api.myStatus,
|
||||
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and
|
||||
// refresh immediately on tab focus (the global default disables focus refetch), so the
|
||||
// "N without full history" count keeps ticking down without a manual reload. Poll faster
|
||||
// while a job is running or work is pending — so the live "syncing" state is actually
|
||||
// caught — and ease back to a slow idle tick once everything's settled.
|
||||
refetchInterval: (query) => {
|
||||
const d = query.state.data as MyStatus | undefined;
|
||||
const busy =
|
||||
!!d && (d.sync_active || d.channels_recent_pending > 0 || d.channels_deep_pending > 0);
|
||||
return busy ? 8_000 : 30_000;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
||||
onError: (e) =>
|
||||
notifyActionError(
|
||||
e,
|
||||
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
|
||||
),
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const syncing = data.channels_recent_pending;
|
||||
const notFull = data.channels_deep_pending; // your channels without full history yet
|
||||
// Spin only while a sync job is actually running; otherwise pending work shows as a calm,
|
||||
// static state (or, for deep history, just the "N without full history" link below).
|
||||
const active = data.sync_active;
|
||||
const showMain = data.paused || active || syncing > 0 || notFull === 0;
|
||||
|
||||
// Calm one-liner describing the current sync state.
|
||||
const stateNode = data.paused ? (
|
||||
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
|
||||
) : active ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{syncing > 0
|
||||
? t("header.sync.syncing", { count: syncing })
|
||||
: notFull > 0
|
||||
? t("header.sync.backfillingHistory")
|
||||
: t("header.sync.working")}
|
||||
</span>
|
||||
) : syncing > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{t("header.sync.recentQueued", { count: syncing })}
|
||||
</span>
|
||||
) : (
|
||||
// A small check anchors the "all synced" state so it doesn't read as orphaned text.
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span className="text-muted">{t("header.sync.allSynced")}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
|
||||
<IconButton
|
||||
onClick={() => toggle.mutate()}
|
||||
loading={toggle.isPending}
|
||||
label={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
|
||||
>
|
||||
{data.paused ? <Play /> : <Pause />}
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
// Chip: the headline count rides in the header; the state, the history link and pause live in a
|
||||
// popover. An accent dot flags the only two things worth interrupting you for (paused / missing
|
||||
// history) so the collapsed chip still tells you when to look.
|
||||
const needsAttention = data.paused || notFull > 0;
|
||||
const countsText = `${formatCompact(data.my_videos, lang)} ${t("header.sync.yours")} / ${formatCompact(
|
||||
data.total_videos,
|
||||
lang,
|
||||
)} ${t("header.sync.total")}`;
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<Pressable
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// Short label only: the popover already explains the numbers, and the long one overflowed
|
||||
// the viewport edge from here.
|
||||
title={countsText}
|
||||
className={`${SURFACE.floating} glass-hover relative flex h-10 items-center gap-1.5 px-3 text-xs text-muted touch-target`}
|
||||
>
|
||||
{active ? (
|
||||
<Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin text-accent" />
|
||||
) : (
|
||||
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="text-fg font-medium tabular-nums">
|
||||
{formatCompact(data.my_videos, lang)}
|
||||
</span>
|
||||
{needsAttention && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={countsText}
|
||||
className={`${SURFACE.popover} w-64 p-3 text-xs text-muted animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div
|
||||
title={t("header.sync.countTooltip")}
|
||||
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
<span className="text-fg font-medium">{formatCompact(data.my_videos, lang)}</span>{" "}
|
||||
{t("header.sync.yours")}
|
||||
<span className="opacity-40"> / </span>
|
||||
{formatCompact(data.total_videos, lang)} {t("header.sync.total")}
|
||||
</span>
|
||||
</div>
|
||||
{showMain && (
|
||||
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
|
||||
{pauseBtn}
|
||||
</div>
|
||||
)}
|
||||
{notFull > 0 && (
|
||||
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||
<Button
|
||||
variant="link-muted"
|
||||
icon={<History className="size-3.5!" />}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onGoToFullHistory();
|
||||
}}
|
||||
title={t("header.sync.fullHistoryTooltip")}
|
||||
className="gap-1 underline decoration-dotted decoration-muted/40 underline-offset-4"
|
||||
>
|
||||
{t("header.sync.withoutFullHistory", { count: notFull })}
|
||||
</Button>
|
||||
{!showMain && pauseBtn}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, type Tag } from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
|
||||
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
|
||||
// hover popover on the count listing the tagged channels (each a link that focuses it in the
|
||||
// Channel manager). The popover rides the shared platform, so the list's own scroll can't clip it and
|
||||
// the cursor can travel the gap into it (safePolygon) without the old hand-rolled 150 ms timer.
|
||||
function TagRow({
|
||||
tag,
|
||||
channels,
|
||||
onRename,
|
||||
onDelete,
|
||||
onPickChannel,
|
||||
}: {
|
||||
tag: Tag;
|
||||
channels: { id: string; title: string }[];
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
onPickChannel: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(tag.name);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { ref: popFadeRef, style: popFadeStyle } = useScrollFade();
|
||||
const commit = () => {
|
||||
const v = name.trim();
|
||||
if (v && v !== tag.name) onRename(v);
|
||||
else setName(tag.name);
|
||||
};
|
||||
const pop = useAnchoredPopover({
|
||||
open: open && channels.length > 0,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
placement: "right-start",
|
||||
offset: 8,
|
||||
hoverDelay: { open: 0, close: 150 },
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||
/>
|
||||
<span
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className={`text-xs text-muted tabular-nums shrink-0 ${
|
||||
channels.length
|
||||
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{t("tagManager.channels", { count: tag.channel_count })}
|
||||
</span>
|
||||
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}>
|
||||
<div className="text-2xs uppercase tracking-wide text-muted px-2 py-1">
|
||||
{t("tagManager.onChannels")}
|
||||
</div>
|
||||
{/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
|
||||
<div
|
||||
ref={popFadeRef}
|
||||
style={popFadeStyle}
|
||||
className="max-h-72 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{channels.map((ch) => (
|
||||
<Pressable
|
||||
key={ch.id}
|
||||
layer
|
||||
onClick={() => onPickChannel(ch.title)}
|
||||
className="w-full text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg truncate"
|
||||
>
|
||||
{ch.title}
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagManager({
|
||||
onClose,
|
||||
onFocusChannel,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onFocusChannel: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const { ref: listFadeRef, style: listFadeStyle } = useScrollFade();
|
||||
const [newName, setNewName] = useState("");
|
||||
const tagsQuery = useTags();
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
|
||||
const channelsForTag = (tagId: number) =>
|
||||
(channelsQuery.data ?? [])
|
||||
.filter((c) => c.tag_ids.includes(tagId))
|
||||
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
};
|
||||
const create = useMutation({
|
||||
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
|
||||
meta: { failTitle: "channels.tags.createFailed" },
|
||||
onSuccess: () => {
|
||||
setNewName("");
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
|
||||
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
|
||||
const onFailed = (e: unknown, fallback: string) => {
|
||||
notifyActionError(e, fallback);
|
||||
invalidate();
|
||||
};
|
||||
const rename = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
||||
onSuccess: (_d, v) => {
|
||||
invalidate();
|
||||
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
|
||||
},
|
||||
onError: (e) => onFailed(e, t("tagManager.deleteFailed")),
|
||||
});
|
||||
|
||||
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
|
||||
const pickChannel = (name: string) => {
|
||||
onClose();
|
||||
onFocusChannel(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal initialFocus="close" title={t("tagManager.title")} onClose={onClose}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{userTags.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("tagManager.empty")}</p>
|
||||
) : (
|
||||
// Cap at ~8 rows tall, then scroll — the list can grow long.
|
||||
<div
|
||||
ref={listFadeRef}
|
||||
style={listFadeStyle}
|
||||
className="flex flex-col gap-2 max-h-88 overflow-y-auto no-scrollbar pr-1"
|
||||
>
|
||||
{userTags.map((tag) => (
|
||||
<TagRow
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
channels={channelsForTag(tag.id)}
|
||||
onPickChannel={pickChannel}
|
||||
onRename={(name) => rename.mutate({ id: tag.id, name })}
|
||||
onDelete={async () => {
|
||||
// Only confirm when the tag is actually in use; an unused tag deletes outright.
|
||||
if (tag.channel_count > 0) {
|
||||
const ok = await confirm({
|
||||
title: t("tagManager.deleteTitle"),
|
||||
message: t("tagManager.confirmDelete", {
|
||||
name: tag.name,
|
||||
count: tag.channel_count,
|
||||
}),
|
||||
confirmLabel: t("tagManager.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
del.mutate({ id: tag.id, name: tag.name });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newName.trim()) create.mutate(newName.trim());
|
||||
}}
|
||||
className="flex items-center gap-2 mt-2 pt-3 border-t border-border"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-muted shrink-0" />
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("tagManager.newPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={!newName.trim()}
|
||||
>
|
||||
{t("tagManager.add")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check } from "lucide-react";
|
||||
import type { ManagedChannel } from "../../lib/api";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
import Tooltip from "../../components/ui/Tooltip";
|
||||
import { isFullySynced } from "./channelSync";
|
||||
|
||||
// The Channels-manager cells that are components in their own right (they read i18n), split out of
|
||||
// channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
|
||||
|
||||
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
|
||||
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
|
||||
// describes. Full detail stays in the hover tooltip.
|
||||
export function StoredCell({ c }: { c: ManagedChannel }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
return (
|
||||
<span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums">
|
||||
{isFullySynced(c) && (
|
||||
<Tooltip hint={t("channels.row.fullySyncedHint")}>
|
||||
<Check
|
||||
role="img"
|
||||
className="w-3.5 h-3.5 text-accent shrink-0 cursor-help"
|
||||
aria-label={t("channels.row.fullySynced")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{formatNumber(c.stored_videos, lang)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// applySyncFilter is the sync-status pre-filter the top chips drive (the Sync column was retired and
|
||||
// its status folded into a Stored charm). It replaced a column `filter.test`, so it carries the exact
|
||||
// "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change
|
||||
// which channels the top chips show.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applySyncFilter,
|
||||
buildChannelColumns,
|
||||
tagFilterChips,
|
||||
tagsColumnTest,
|
||||
} from "./channelColumns";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
||||
id: "c",
|
||||
title: null,
|
||||
handle: null,
|
||||
thumbnail_url: null,
|
||||
subscriber_count: null,
|
||||
video_count: null,
|
||||
description: null,
|
||||
stored_videos: 0,
|
||||
last_video_at: null,
|
||||
total_duration_seconds: 0,
|
||||
priority: 0,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
deep_in_queue: false,
|
||||
tag_ids: [],
|
||||
details_synced: false,
|
||||
recent_synced: false,
|
||||
backfill_done: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const rows = [
|
||||
ch({ id: "fully", recent_synced: true, backfill_done: true }),
|
||||
ch({ id: "recentOnly", recent_synced: true, backfill_done: false }),
|
||||
ch({ id: "none", recent_synced: false, backfill_done: false }),
|
||||
];
|
||||
|
||||
describe("applySyncFilter", () => {
|
||||
it("returns every row for the empty/all value (and unknown values)", () => {
|
||||
expect(applySyncFilter(rows, "").map((r) => r.id)).toEqual(["fully", "recentOnly", "none"]);
|
||||
expect(applySyncFilter(rows, undefined)).toHaveLength(3);
|
||||
expect(applySyncFilter(rows, "bogus")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("'fully' keeps only channels with recent uploads AND full history in", () => {
|
||||
expect(applySyncFilter(rows, "fully").map((r) => r.id)).toEqual(["fully"]);
|
||||
});
|
||||
|
||||
it("'needsFull' keeps every channel whose back-catalogue is not done", () => {
|
||||
expect(applySyncFilter(rows, "needsFull").map((r) => r.id)).toEqual(["recentOnly", "none"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagsColumnTest (item B — 'No tag')", () => {
|
||||
const tagged = ch({ id: "tagged", tag_ids: [1, 2] });
|
||||
const untagged = ch({ id: "untagged", tag_ids: [] });
|
||||
|
||||
it("the No-tag sentinel keeps only channels with no user tag", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL])).toBe(false);
|
||||
expect(tagsColumnTest(untagged, [NO_TAG_SENTINEL])).toBe(true);
|
||||
});
|
||||
|
||||
it("real tag values match ANY selected id (sentinel absent)", () => {
|
||||
expect(tagsColumnTest(tagged, ["2"])).toBe(true);
|
||||
expect(tagsColumnTest(tagged, ["3"])).toBe(false);
|
||||
expect(tagsColumnTest(untagged, ["1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("the sentinel wins if it somehow co-exists with real ids", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagFilterChips — a pick whose tag is gone", () => {
|
||||
const mine = { id: 1, name: "Music", system: false } as Tag;
|
||||
const t = ((k: string) => k) as unknown as TFunction;
|
||||
type State = "loaded" | "loading" | "failed";
|
||||
const chips = (state: State, picked: string[]) =>
|
||||
tagFilterChips({
|
||||
userTags: state === "loaded" ? [mine] : [],
|
||||
tags: {
|
||||
data: state === "loaded" ? [mine] : undefined,
|
||||
isSuccess: state === "loaded",
|
||||
isPending: state === "loading",
|
||||
},
|
||||
picked,
|
||||
noTagOn: false,
|
||||
toggle: () => {},
|
||||
toggleNoTag: () => {},
|
||||
t,
|
||||
});
|
||||
|
||||
it("stays a visible, active chip instead of an empty table with no reason", () => {
|
||||
expect(chips("loaded", ["1", "77"]).map((c) => [c.key, c.label, c.active])).toEqual([
|
||||
["t77", "common.deletedTag", true], // first: the "+N" overflow must not fold it away
|
||||
["t1", "Music", true],
|
||||
[NO_TAG_SENTINEL, "channels.tags.noTag", false],
|
||||
]);
|
||||
});
|
||||
|
||||
// Before, a pick had NO chip at all until the list loaded — or ever, after a failed
|
||||
// load — while it still narrowed the table.
|
||||
it("is a placeholder while the tag list loads, never called deleted", () => {
|
||||
expect(chips("loading", ["77"]).map((c) => [c.key, c.label, c.pending])).toEqual([
|
||||
["t77", "77", true],
|
||||
[NO_TAG_SENTINEL, "channels.tags.noTag", undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the id after a failed load, still a visible, removable chip", () => {
|
||||
expect(chips("failed", ["77"]).map((c) => [c.key, c.label, c.pending, c.active])).toEqual([
|
||||
["t77", "77", false, true],
|
||||
[NO_TAG_SENTINEL, "channels.tags.noTag", undefined, false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChannelColumns — the row's end", () => {
|
||||
it('ends every row in the channel\'s "…", with no action buttons before it', () => {
|
||||
const more = (c: ManagedChannel) => `more:${c.id}`;
|
||||
const noop = () => {};
|
||||
const cols = buildChannelColumns({
|
||||
t: ((k: string) => k) as never,
|
||||
lang: "en",
|
||||
prioOptions: [],
|
||||
userTags: [],
|
||||
bumpPriority: noop,
|
||||
setPriority: noop,
|
||||
more,
|
||||
onView: noop,
|
||||
toggleTag: noop,
|
||||
createAndAttachTag: noop,
|
||||
onFilterByTag: noop,
|
||||
suggestionsFor: () => [],
|
||||
onAcceptSuggestion: noop,
|
||||
onDismissSuggestion: noop,
|
||||
});
|
||||
expect(cols[cols.length - 1]!.key).toBe("actions");
|
||||
expect(cols.filter((c) => c.key === "actions")).toHaveLength(1);
|
||||
expect(cols[cols.length - 1]!.render(ch({ id: "UCz" }))).toBe("more:UCz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import { tagPickLabel, type TagList } from "../../lib/tagPicks";
|
||||
import { formatCountOrDash, formatTotalHours } from "../../lib/format";
|
||||
import type { Column } from "../../components/ui/DataTable";
|
||||
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import { StoredCell } from "./channelCells";
|
||||
import { isFullySynced } from "./channelSync";
|
||||
import { RelativeTime } from "../../components/ui/RelativeTime";
|
||||
import type { FilterChip } from "../feed/TagFilterChips";
|
||||
|
||||
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
||||
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
|
||||
* channel row shapes. */
|
||||
export function subsColumn<T extends { subscriber_count: number | null }>(
|
||||
t: TFunction,
|
||||
lang: string,
|
||||
): Column<T> {
|
||||
return {
|
||||
key: "subs",
|
||||
header: t("channels.cols.subs"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (c) => c.subscriber_count ?? -1,
|
||||
render: (c) => (
|
||||
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count, lang)}</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Sync-status pre-filter for the subscribed table. The Sync COLUMN is gone — its status folded
|
||||
* into a charm on Stored — so the top chips' `filters.sync` value is applied here instead of via a
|
||||
* column predicate. Same vocab as the old column filter: "fully" | "needsFull" | "" (all). */
|
||||
export function applySyncFilter(rows: ManagedChannel[], value: unknown): ManagedChannel[] {
|
||||
if (value === "fully") return rows.filter(isFullySynced);
|
||||
if (value === "needsFull") return rows.filter((c) => !c.backfill_done);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
|
||||
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
|
||||
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
|
||||
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
|
||||
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
|
||||
return values.some((v) => c.tag_ids.includes(Number(v)));
|
||||
}
|
||||
|
||||
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one
|
||||
* of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its
|
||||
* tag (deleted here or on another device) and still narrows the table to nothing, so it stays a
|
||||
* visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away —
|
||||
* and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule
|
||||
* (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
|
||||
export function tagFilterChips(o: {
|
||||
userTags: Tag[];
|
||||
tags: TagList;
|
||||
picked: string[];
|
||||
noTagOn: boolean;
|
||||
toggle: (tagId: number) => void;
|
||||
toggleNoTag: () => void;
|
||||
t: TFunction;
|
||||
}): FilterChip[] {
|
||||
const mine = new Set(o.userTags.map((tg) => String(tg.id)));
|
||||
const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id));
|
||||
return [
|
||||
...others.map((id) => ({
|
||||
key: `t${id}`,
|
||||
label: tagPickLabel(Number(id), o.tags, o.t),
|
||||
pending: o.tags.isPending,
|
||||
active: true,
|
||||
onToggle: () => o.toggle(Number(id)),
|
||||
})),
|
||||
...o.userTags.map((tg) => ({
|
||||
key: `t${tg.id}`,
|
||||
label: tg.name,
|
||||
active: o.picked.includes(String(tg.id)),
|
||||
onToggle: () => o.toggle(tg.id),
|
||||
})),
|
||||
{
|
||||
key: NO_TAG_SENTINEL,
|
||||
label: o.t("channels.tags.noTag"),
|
||||
active: o.noTagOn,
|
||||
onToggle: o.toggleNoTag,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
|
||||
* this factory just wires them into `Column<ManagedChannel>` render/filter/sort defs so the ~150-line
|
||||
* column block lives outside the page component (its cell components are in channelCells). */
|
||||
export interface ChannelColumnDeps {
|
||||
t: TFunction;
|
||||
/** The UI language, for the numbers — the compiler re-builds the columns when it changes. */
|
||||
lang: string;
|
||||
prioOptions: { value: string; label: string }[];
|
||||
userTags: ComponentProps<typeof ChannelTagsControl>["userTags"];
|
||||
bumpPriority: (id: string, delta: number) => void;
|
||||
setPriority: (id: string, value: number) => void;
|
||||
/** The row's "…" — the door to the channel's one action list (`useChannelActions`). */
|
||||
more: (c: ManagedChannel) => ReactNode;
|
||||
onView: (c: ManagedChannel) => void;
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
createAndAttachTag: (channelId: string, name: string) => void;
|
||||
onFilterByTag: (tagId: number, name: string) => void;
|
||||
// Suggested user tags per channel (resolved to Tag + confidence), plus accept/dismiss.
|
||||
suggestionsFor: (channelId: string) => SuggestedTag[];
|
||||
onAcceptSuggestion: (channelId: string, tagId: number) => void;
|
||||
onDismissSuggestion: (channelId: string, tagId: number) => void;
|
||||
}
|
||||
|
||||
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
|
||||
const {
|
||||
t,
|
||||
lang,
|
||||
prioOptions,
|
||||
userTags,
|
||||
bumpPriority,
|
||||
setPriority,
|
||||
more,
|
||||
onView,
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
} = deps;
|
||||
return [
|
||||
{
|
||||
key: "priority",
|
||||
header: t("channels.cols.priority"),
|
||||
align: "center",
|
||||
// Sized to the longer language's label + the funnel + a sort arrow ("Prioritás ▼ ⧩"): fixed layout
|
||||
// clips a header that outgrows its column instead of widening it.
|
||||
width: "7.5rem",
|
||||
// Off on a tablet-wide table, with About and Length: the row's "…" menu sets the priority too.
|
||||
tabletHidden: true,
|
||||
sortable: true,
|
||||
hideInCard: true,
|
||||
sortValue: (c) => c.priority,
|
||||
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
|
||||
render: (c) => (
|
||||
<PriorityStepper
|
||||
priority={c.priority}
|
||||
onPriority={(d) => bumpPriority(c.id, d)}
|
||||
onSet={(v) => setPriority(c.id, v)}
|
||||
hint={t("channels.row.priorityHint")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "channel",
|
||||
header: t("channels.cols.channel"),
|
||||
sortable: true,
|
||||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||
cardPrimary: true,
|
||||
// A FLUID column: no width cap. Under the manager table's fixed layout this cell takes a
|
||||
// share of the leftover space and ChannelLink truncates the name to fit (full name on hover via
|
||||
// title), so a long name can't push the table wider than its container at any zoom.
|
||||
render: (c) => (
|
||||
<div className="min-w-0">
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
handle={c.handle}
|
||||
thumbnailUrl={c.thumbnail_url}
|
||||
onView={() => onView(c)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "about",
|
||||
header: t("channels.cols.about"),
|
||||
hideInCard: true,
|
||||
tabletHidden: true,
|
||||
// The other FLUID column — AboutCell already truncates to its cell.
|
||||
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
||||
},
|
||||
{
|
||||
key: "stored",
|
||||
header: t("channels.cols.stored"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
width: "5rem",
|
||||
sortable: true,
|
||||
sortValue: (c) => c.stored_videos,
|
||||
// The stored count carries the "fully synced" ✓ charm (the retired Sync column folded in).
|
||||
render: (c) => <StoredCell c={c} />,
|
||||
},
|
||||
{ ...subsColumn<ManagedChannel>(t, lang), width: "6rem" },
|
||||
{
|
||||
key: "lastUpload",
|
||||
header: t("channels.cols.lastUpload"),
|
||||
nowrap: true,
|
||||
width: "8rem", // "Utolsó feltöltés" + a sort arrow, as the priority column above
|
||||
sortable: true,
|
||||
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
|
||||
render: (c) => <RelativeTime at={c.last_video_at} empty="—" className="text-muted" />,
|
||||
},
|
||||
{
|
||||
key: "length",
|
||||
header: t("channels.cols.length"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
width: "4.5rem",
|
||||
tabletHidden: true,
|
||||
sortable: true,
|
||||
sortValue: (c) => c.total_duration_seconds,
|
||||
render: (c) => (
|
||||
<span className="text-muted tabular-nums">
|
||||
{formatTotalHours(c.total_duration_seconds, lang)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
header: t("channels.cols.tags"),
|
||||
cardLabel: false,
|
||||
// Bounded width so the cell can't widen the column and reflow the whole table; the control
|
||||
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
|
||||
width: "9rem",
|
||||
filter: {
|
||||
kind: "multi",
|
||||
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
||||
test: tagsColumnTest,
|
||||
},
|
||||
render: (c) => (
|
||||
<ChannelTagsControl
|
||||
userTags={userTags}
|
||||
attachedIds={c.tag_ids}
|
||||
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
||||
onCreate={(name) => createAndAttachTag(c.id, name)}
|
||||
onChipClick={onFilterByTag}
|
||||
suggestions={suggestionsFor(c.id)}
|
||||
onAcceptSuggestion={(tagId) => onAcceptSuggestion(c.id, tagId)}
|
||||
onDismissSuggestion={(tagId) => onDismissSuggestion(c.id, tagId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
// Room for the "…" at 44px on touch (`.touch-target`), not just its 28px under a mouse.
|
||||
width: "3.25rem",
|
||||
nowrap: true,
|
||||
cardLabel: false,
|
||||
cardRow: true,
|
||||
// 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 "…".
|
||||
render: more,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ManagedChannel } from "../../lib/api";
|
||||
|
||||
// A channel is "fully synced" when its recent uploads AND full back-catalogue are both in — the
|
||||
// predicate the retired Sync column used, now surfaced as a ✓ charm on the Stored count and driven
|
||||
// by the top sync-filter chips.
|
||||
export const isFullySynced = (c: ManagedChannel) => c.recent_synced && c.backfill_done;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ChannelLayout } from "../../lib/channelLayout";
|
||||
import type { SortState } from "../../lib/columnSort";
|
||||
import type { FilterMap } from "../../lib/tableColumns";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
|
||||
// The Channels module's contexts + hooks, split out of ChannelsProvider so that file exports only
|
||||
// components (Fast Refresh). The provider owns the state; this is the read side every consumer imports.
|
||||
|
||||
export type ChannelsState = {
|
||||
search: string;
|
||||
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
|
||||
// column funnel, and the card layout share ONE filter state (the sync chip lives at `filters.sync`).
|
||||
filters: FilterMap;
|
||||
view: ChannelsView;
|
||||
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
|
||||
sort: SortState;
|
||||
// Bumped to tell the manager to drop a stale column filter when an intent sends the user to a
|
||||
// specific set (the header's "without full history" link).
|
||||
filtersResetToken: number;
|
||||
focusName: string | null;
|
||||
// Bumped every time a focus intent is (re-)issued, so re-focusing the same channel re-seeds the
|
||||
// manager's name filter even when the name is unchanged.
|
||||
focusToken: number;
|
||||
};
|
||||
|
||||
export interface ChannelsActions {
|
||||
setSearch: (q: string) => void;
|
||||
setFilters: (f: FilterMap) => void;
|
||||
setView: (v: ChannelsView) => void;
|
||||
setLayout: (l: ChannelLayout) => void;
|
||||
setSort: (s: SortState) => void;
|
||||
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
|
||||
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
|
||||
focusChannel: (name: string) => void;
|
||||
// Mark the current focus intent as applied. The manager calls this the moment it seeds its name
|
||||
// filter, so the intent cannot be re-applied when the page chunk remounts on a later visit and
|
||||
// overwrite a search the user has typed since.
|
||||
consumeFocus: () => void;
|
||||
// Jump to the manager filtered to channels still missing full history (the header sync chip).
|
||||
goToFullHistory: () => void;
|
||||
}
|
||||
|
||||
export const ChannelsStateContext = createContext<ChannelsState>({
|
||||
search: "",
|
||||
filters: {},
|
||||
view: "subscribed",
|
||||
sort: null,
|
||||
filtersResetToken: 0,
|
||||
focusName: null,
|
||||
focusToken: 0,
|
||||
});
|
||||
// The table/cards density gets its OWN context, deliberately narrow: App reads it (to decide the
|
||||
// shell's top fade) and would otherwise re-render the whole app on every keystroke in the manager's
|
||||
// search box, since the state context above changes with it.
|
||||
export const ChannelLayoutContext = createContext<ChannelLayout>("table");
|
||||
export const ChannelsActionsContext = createContext<ChannelsActions>({
|
||||
setSearch: () => {},
|
||||
setFilters: () => {},
|
||||
setView: () => {},
|
||||
setLayout: () => {},
|
||||
setSort: () => {},
|
||||
focusChannel: () => {},
|
||||
consumeFocus: () => {},
|
||||
goToFullHistory: () => {},
|
||||
});
|
||||
|
||||
export function useChannels(): ChannelsState {
|
||||
return useContext(ChannelsStateContext);
|
||||
}
|
||||
/** The manager's table/cards density on its own — subscribe to this instead of the whole state when
|
||||
* the layout is all you need (App does, for the shell's top fade). */
|
||||
export function useChannelLayout(): ChannelLayout {
|
||||
return useContext(ChannelLayoutContext);
|
||||
}
|
||||
export function useChannelsActions(): ChannelsActions {
|
||||
return useContext(ChannelsActionsContext);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// The Channels tag row at 375: the suggestions block squeezed the tag-filter chip lane to
|
||||
// 0 px and pushed the row's "…" off the screen. Below 40rem of its own container it now takes a line of
|
||||
// its own under the row, which keeps the "…" on the first line. Channels.tsx needs the whole provider
|
||||
// tree to render, so this holds the classes the layout rests on; the widths were measured in a browser.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const src = readFileSync(new URL("./Channels.tsx", import.meta.url), "utf8");
|
||||
|
||||
describe("the Channels tag row", () => {
|
||||
it("is a size container whose row may wrap", () => {
|
||||
expect(src).toContain('className="@container/tagrow ');
|
||||
expect(src).toMatch(/className="flex flex-wrap items-center gap-2 min-w-0"/);
|
||||
});
|
||||
|
||||
it("sends only the suggestions block to a line of its own when narrow", () => {
|
||||
const block = src.slice(
|
||||
src.indexOf("data-tag-suggestions"),
|
||||
src.indexOf("data-tag-suggestions") + 400,
|
||||
);
|
||||
expect(block).toContain("@max-[40rem]/tagrow:order-last");
|
||||
expect(block).toContain("@max-[40rem]/tagrow:basis-full");
|
||||
});
|
||||
});
|
||||
|
||||
// Every other page band folds its filter row on a short window (B4, `useFilterFold`);
|
||||
// the Channels band never did — its tag chips and the status / pager row kept a laptop's short window
|
||||
// for themselves. They fold behind one "Filters · n", the tabs and the TAGS editor stay.
|
||||
describe("the Channels band on a short window", () => {
|
||||
it("folds the tag chip lane and the status / pager row behind the fold button", () => {
|
||||
expect(src).toContain("const fold = useFilterFold();");
|
||||
expect(src).toMatch(/<FilterFoldButton\s[^>]*fold=\{fold\}/);
|
||||
expect(src).toMatch(/!fold\.folded && <TagFilterChips/);
|
||||
expect(src).toMatch(/controlsPosition=\{fold\.folded \? "bottom" : "both"\}/);
|
||||
expect(src).toMatch(/layout === "cards" && !fold\.folded/);
|
||||
});
|
||||
|
||||
// Folded, the table's status / pager row moves to the list's end; the cards' one
|
||||
// simply vanished, and with it the only way to page them.
|
||||
it("keeps the cards' status / pager row at the list's end while folded", () => {
|
||||
const grid = src.slice(src.indexOf("<ChannelLayoutGrid"));
|
||||
expect(grid).toMatch(/\{fold\.folded && \(\s*<div[^>]*>\s*\{statusChips\}\s*\{cardPager\}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A subscribed channel's ONE action list: what the manager row's "…" (and its right-click,
|
||||
// long-press and Shift+F10) offers — and the row buttons it replaced, now rows here: full history,
|
||||
// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed) — and the Tags and
|
||||
// Priority submenus.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook, act, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
confirm: vi.fn(async () => true),
|
||||
role: "user",
|
||||
canWrite: true,
|
||||
}));
|
||||
|
||||
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("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||
vi.mock("../../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.canWrite }) }));
|
||||
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
import { api, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import type { ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
h.notify.mockReset();
|
||||
h.confirm.mockReset();
|
||||
h.confirm.mockImplementation(async () => true);
|
||||
h.role = "user";
|
||||
h.canWrite = true;
|
||||
tags = [];
|
||||
tagsState = "ready";
|
||||
onToggleTag.mockReset();
|
||||
onPickPriority.mockReset();
|
||||
});
|
||||
|
||||
const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
|
||||
id: "UC1",
|
||||
title: "Chan",
|
||||
handle: "chan",
|
||||
thumbnail_url: null,
|
||||
subscriber_count: null,
|
||||
video_count: null,
|
||||
description: null,
|
||||
stored_videos: 0,
|
||||
last_video_at: null,
|
||||
total_duration_seconds: 0,
|
||||
priority: 0,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
deep_in_queue: false,
|
||||
tag_ids: [],
|
||||
details_synced: false,
|
||||
recent_synced: false,
|
||||
backfill_done: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const onView = vi.fn();
|
||||
const onToggleTag = vi.fn();
|
||||
const onPickPriority = vi.fn();
|
||||
let tags: Tag[] = [];
|
||||
let tagsState: "loading" | "error" | "ready" = "ready";
|
||||
const tag = (id: number, name: string) => ({ id, name }) as Tag;
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
return renderHook(
|
||||
() =>
|
||||
useChannelActions({
|
||||
onView,
|
||||
onOpenWizard: () => {},
|
||||
tags,
|
||||
tagsState,
|
||||
onToggleTag,
|
||||
onPickPriority,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
}
|
||||
const pin = [{ id: "pin", label: "pin", onSelect: () => {} }];
|
||||
const ids = (items: { id: string }[]) => items.map((i) => i.id);
|
||||
const rowsFor = (c: ManagedChannel) => mount().result.current.items(c, { pin });
|
||||
const row = (c: ManagedChannel, id: string) => rowsFor(c).find((i) => i.id === id)!;
|
||||
|
||||
describe("useChannelActions — the menu", () => {
|
||||
it("lists open, pin, tags + priority, the YouTube + copy group, full history, hide and unsubscribe", () => {
|
||||
const items = rowsFor(ch());
|
||||
expect(ids(items)).toEqual([
|
||||
"open",
|
||||
"pin",
|
||||
"tags",
|
||||
"priority",
|
||||
"open-youtube",
|
||||
"copy-text",
|
||||
"copy-link",
|
||||
"backfill",
|
||||
"hide",
|
||||
"unsubscribe",
|
||||
]);
|
||||
// Groups: tuning, YouTube/copy, the channel's own settings, then the destructive row alone.
|
||||
expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([
|
||||
"tags",
|
||||
"open-youtube",
|
||||
"backfill",
|
||||
"unsubscribe",
|
||||
]);
|
||||
expect(items.find((i) => i.id === "unsubscribe")).toMatchObject({ danger: true });
|
||||
expect(items.find((i) => i.id === "copy-text")!.label).toBe("common.copy.name");
|
||||
});
|
||||
|
||||
it("has no unsubscribe without the YouTube write scope", () => {
|
||||
h.canWrite = false;
|
||||
expect(ids(rowsFor(ch()))).not.toContain("unsubscribe");
|
||||
});
|
||||
|
||||
it("opens the channel page, and YouTube in a new tab inside the click", () => {
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
row(ch(), "open").onSelect!();
|
||||
expect(onView).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }));
|
||||
const yt = row(ch(), "open-youtube");
|
||||
expect("inGesture" in yt && yt.inGesture).toBe(true);
|
||||
yt.onSelect!();
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
"https://www.youtube.com/@chan",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChannelActions — full history", () => {
|
||||
it("asks for it, and takes a pending ask back", async () => {
|
||||
const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never);
|
||||
const ask = row(ch(), "backfill");
|
||||
expect(ask.label).toBe("channels.row.backfillThis");
|
||||
act(() => ask.onSelect!());
|
||||
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: true }));
|
||||
const cancel = row(ch({ deep_requested: true }), "backfill");
|
||||
expect(cancel.label).toBe("channels.row.cancelBackfill");
|
||||
act(() => cancel.onSelect!());
|
||||
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: false }));
|
||||
});
|
||||
|
||||
it("stays in place, off, with the reason, once it is in or on its way for everyone", () => {
|
||||
expect(row(ch({ backfill_done: true }), "backfill")).toMatchObject({
|
||||
disabled: true,
|
||||
note: "channels.row.fullHint",
|
||||
});
|
||||
expect(row(ch({ deep_in_queue: true }), "backfill")).toMatchObject({
|
||||
disabled: true,
|
||||
note: "channels.row.queuedByOtherHint",
|
||||
});
|
||||
});
|
||||
|
||||
it("gives an admin the reset, behind a confirm that names the reset", async () => {
|
||||
h.role = "admin";
|
||||
const reset = vi.spyOn(api, "resetChannelBackfill").mockResolvedValue({} as never);
|
||||
const it_ = row(ch({ backfill_done: true }), "backfill");
|
||||
expect(it_).toMatchObject({ label: "channels.row.resetBackfill" });
|
||||
expect(it_.disabled).toBeFalsy();
|
||||
await act(async () => it_.onSelect!());
|
||||
expect(h.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "channels.row.resetBackfill",
|
||||
confirmLabel: "channels.row.resetBackfill",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(reset).toHaveBeenCalledWith("UC1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChannelActions — hide and unsubscribe", () => {
|
||||
it("hides without a confirm, and the toast's Undo shows the channel again", async () => {
|
||||
const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never);
|
||||
const { result } = mount();
|
||||
act(() => result.current.items(ch(), { pin }).find((i) => i.id === "hide")!.onSelect!());
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalled());
|
||||
expect(h.confirm).not.toHaveBeenCalled();
|
||||
expect(upd).toHaveBeenCalledWith("UC1", { hidden: true });
|
||||
const toast = h.notify.mock.calls[0]![0] as {
|
||||
message: string;
|
||||
action: { onClick: () => void };
|
||||
};
|
||||
expect(toast.message).toBe("channels.notify.hidden");
|
||||
act(() => toast.action.onClick());
|
||||
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false }));
|
||||
});
|
||||
|
||||
it("unsubscribes only after the danger confirm says yes", async () => {
|
||||
const unsub = vi.spyOn(api, "unsubscribeChannel").mockResolvedValue({} as never);
|
||||
const { result } = mount();
|
||||
const unsubRow = () => result.current.items(ch(), { pin }).find((i) => i.id === "unsubscribe")!;
|
||||
h.confirm.mockImplementation(async () => false);
|
||||
await act(async () => unsubRow().onSelect!());
|
||||
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
|
||||
expect(unsub).not.toHaveBeenCalled();
|
||||
h.confirm.mockImplementation(async () => true);
|
||||
await act(async () => unsubRow().onSelect!());
|
||||
await waitFor(() => expect(unsub).toHaveBeenCalledWith("UC1"));
|
||||
});
|
||||
});
|
||||
|
||||
const sub = (it: ActionMenuItem) => (it.items ?? []) as ActionMenuItem[];
|
||||
|
||||
describe("useChannelActions — the Tags submenu", () => {
|
||||
it("lists the reader's tags A–Z, the channel's own ticked, each a toggle that stays open", () => {
|
||||
tags = [tag(3, "rust"), tag(1, "Go"), tag(2, "music")];
|
||||
const items = sub(row(ch({ tag_ids: [2] }), "tags"));
|
||||
expect(items.map((i) => i.label)).toEqual(["Go", "music", "rust"]);
|
||||
expect(items.map((i) => ("check" in i ? i.check : null))).toEqual([
|
||||
{ kind: "many", checked: false },
|
||||
{ kind: "many", checked: true },
|
||||
{ kind: "many", checked: false },
|
||||
]);
|
||||
items[1]!.onSelect!();
|
||||
expect(onToggleTag).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 2);
|
||||
});
|
||||
|
||||
// A failed /tags read said "no tags yet" — an error dressed as an empty list.
|
||||
it("says the tags are loading, or failed to load, instead of 'no tags yet'", () => {
|
||||
tagsState = "loading";
|
||||
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "common.loading" });
|
||||
tagsState = "error";
|
||||
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "common.loadError" });
|
||||
});
|
||||
|
||||
it("stays in place, off, saying where tags are made, with no tag yet", () => {
|
||||
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "channels.row.noTagsYet" });
|
||||
expect(row(ch(), "tags").items).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChannelActions — the Priority submenu", () => {
|
||||
it("offers the fixed steps with the current one ticked, and picks a new one", () => {
|
||||
const items = sub(row(ch({ priority: 3 }), "priority"));
|
||||
expect(items.map((i) => i.label)).toEqual(["10", "5", "3", "2", "1", "0"]);
|
||||
expect(items.filter((i) => "check" in i && i.check?.checked).map((i) => i.label)).toEqual([
|
||||
"3",
|
||||
]);
|
||||
expect(items.every((i) => "check" in i && i.check?.kind === "one")).toBe(true);
|
||||
items[0]!.onSelect!();
|
||||
expect(onPickPriority).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 10);
|
||||
});
|
||||
|
||||
it("shows an off-scale priority ticked in its place, and re-picking the current one is a no-op", () => {
|
||||
const items = sub(row(ch({ priority: 7 }), "priority"));
|
||||
expect(items.map((i) => i.label)).toEqual(["10", "7", "5", "3", "2", "1", "0"]);
|
||||
items[1]!.onSelect!();
|
||||
expect(onPickPriority).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpDown, EyeOff, ExternalLink, History, Tags, Tv, UserMinus } from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { invalidateSubscription } from "../../lib/subscriptionCache";
|
||||
import { api, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import { priorityChoices } from "../../lib/channelOrder";
|
||||
import { compareText } from "../../lib/collate";
|
||||
import { channelYouTubeUrl } from "../../lib/format";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
|
||||
// A subscribed channel's ONE action list: the manager row's "…", its right-click, long-press
|
||||
// and Shift+F10, in the table and in the card layout alike. The row keeps what the reader looks at
|
||||
// or tunes all the time (priority, tags, the stored count's ✓); everything that was a button on the
|
||||
// row — full history, hide, unsubscribe — is here, beside open, the pin and the copies. Tags and
|
||||
// priority are here TOO, as submenus (owner, 2026-09-26): tagging a run of channels, or bubbling one
|
||||
// up the list, from the menu the hand is already in.
|
||||
|
||||
export function useChannelActions({
|
||||
onView,
|
||||
onOpenWizard,
|
||||
tags,
|
||||
tagsState = "ready",
|
||||
onToggleTag,
|
||||
onPickPriority,
|
||||
}: {
|
||||
onView: (c: ManagedChannel) => void;
|
||||
onOpenWizard: () => void;
|
||||
/** The reader's own tags (no system tags) — the Tags submenu lists them A–Z. */
|
||||
tags: readonly Tag[];
|
||||
/** Whether `tags` is the answer yet: loading or failed is said as such, never as "no tags yet". */
|
||||
tagsState?: "loading" | "error" | "ready";
|
||||
onToggleTag: (c: ManagedChannel, tagId: number) => void;
|
||||
/** A priority step picked: the caller re-sorts its list and follows the row. */
|
||||
onPickPriority: (c: ManagedChannel, priority: number) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const confirm = useConfirm();
|
||||
const me = useMe();
|
||||
const isAdmin = me.role === "admin";
|
||||
const copy = useCopyItems();
|
||||
const nameOf = (c: ManagedChannel) => c.title ?? t("channels.row.thisChannel");
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
};
|
||||
|
||||
const patch = useMutation({
|
||||
mutationFn: (v: { id: string; body: { hidden?: boolean; deep_requested?: boolean } }) =>
|
||||
api.updateChannel(v.id, v.body),
|
||||
meta: { failTitle: "errors.writeFailed.channelSettings" },
|
||||
onSuccess: refresh,
|
||||
});
|
||||
// Hiding moves the channel to the Hidden tab, out of sight: the toast's Undo is the way back from
|
||||
// where the reader is, so hiding needs no confirm (the same bargain as a playlist row's Remove).
|
||||
const hide = useMutation({
|
||||
mutationFn: (c: ManagedChannel) => api.updateChannel(c.id, { hidden: true }),
|
||||
meta: { failTitle: "errors.writeFailed.hideChannel" },
|
||||
onSuccess: (_d, c) => {
|
||||
refresh();
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("channels.notify.hidden", { name: nameOf(c) }),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const unsubscribe = useMutation({
|
||||
mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id),
|
||||
onSuccess: (_d, c) => {
|
||||
invalidateSubscription(qc, c.id);
|
||||
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: nameOf(c) }) });
|
||||
},
|
||||
onError: (e) =>
|
||||
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
|
||||
});
|
||||
const resetBackfill = useMutation({
|
||||
mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id),
|
||||
onSuccess: (_d, c) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
notify({ level: "success", message: t("channels.notify.resetDone", { name: nameOf(c) }) });
|
||||
},
|
||||
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
|
||||
});
|
||||
|
||||
async function onUnsubscribe(c: ManagedChannel) {
|
||||
const ok = await confirm({
|
||||
title: t("channels.row.unsubscribeOnYoutube"),
|
||||
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) unsubscribe.mutate(c);
|
||||
}
|
||||
async function onReset(c: ManagedChannel) {
|
||||
const ok = await confirm({
|
||||
title: t("channels.row.resetBackfill"),
|
||||
message: t("channels.confirmReset", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.row.resetBackfill"),
|
||||
});
|
||||
if (ok) resetBackfill.mutate(c);
|
||||
}
|
||||
|
||||
/** The full-history row. An admin re-fetches any channel from scratch (a confirm: it spends
|
||||
* quota). A reader asks for the back-catalogue, or takes the ask back; once it is in, or someone
|
||||
* else's ask already brings it, the row stays in place, off, and says why. */
|
||||
function backfillItem(c: ManagedChannel): ActionMenuItem {
|
||||
const icon = <History className="w-4 h-4" />;
|
||||
if (isAdmin)
|
||||
return {
|
||||
id: "backfill",
|
||||
label: t("channels.row.resetBackfill"),
|
||||
title: t("channels.row.resetHint"),
|
||||
icon,
|
||||
disabled: resetBackfill.isPending,
|
||||
onSelect: () => void onReset(c),
|
||||
};
|
||||
const requested = !c.backfill_done && c.deep_requested;
|
||||
const note = c.backfill_done
|
||||
? t("channels.row.fullHint")
|
||||
: c.deep_in_queue && !requested
|
||||
? t("channels.row.queuedByOtherHint")
|
||||
: undefined;
|
||||
return {
|
||||
id: "backfill",
|
||||
label: requested ? t("channels.row.cancelBackfill") : t("channels.row.backfillThis"),
|
||||
title: requested ? t("channels.row.queuedRequestedHint") : t("channels.row.backfillThisHint"),
|
||||
icon,
|
||||
disabled: !!note || patch.isPending,
|
||||
note,
|
||||
onSelect: () => patch.mutate({ id: c.id, body: { deep_requested: !requested } }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tags A–Z, one check each, toggled in place (the menu stays open for the next one). With no tag
|
||||
* yet the row stays, off, and says where tags are made. */
|
||||
function tagsItem(c: ManagedChannel): ActionMenuItem {
|
||||
const base = {
|
||||
id: "tags",
|
||||
label: t("channels.tags.label"),
|
||||
icon: <Tags className="w-4 h-4" />,
|
||||
};
|
||||
if (tags.length === 0) {
|
||||
const note =
|
||||
tagsState === "loading"
|
||||
? t("common.loading")
|
||||
: tagsState === "error"
|
||||
? t("common.loadError")
|
||||
: t("channels.row.noTagsYet");
|
||||
return { ...base, disabled: true, note, onSelect: () => {} };
|
||||
}
|
||||
const on = new Set(c.tag_ids);
|
||||
return {
|
||||
...base,
|
||||
items: [...tags]
|
||||
.sort((a, b) => compareText(a.name, b.name, lang))
|
||||
.map((tg) => ({
|
||||
id: `tag-${tg.id}`,
|
||||
label: tg.name,
|
||||
check: { kind: "many" as const, checked: on.has(tg.id) },
|
||||
onSelect: () => onToggleTag(c, tg.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The fixed priority steps, the current one ticked; a pick re-sorts the list (the caller). */
|
||||
function priorityItem(c: ManagedChannel): ActionMenuItem {
|
||||
return {
|
||||
id: "priority",
|
||||
label: t("channels.row.priority"),
|
||||
icon: <ArrowUpDown className="w-4 h-4" />,
|
||||
items: priorityChoices(c.priority).map((p) => ({
|
||||
id: `priority-${p}`,
|
||||
label: String(p),
|
||||
check: { kind: "one" as const, checked: p === c.priority },
|
||||
onSelect: () => {
|
||||
if (p !== c.priority) onPickPriority(c, p);
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The channel's menu rows; `pin` is the caller's comfort-shelf row (its "is it pinned?" read
|
||||
* follows the caller's own menu). */
|
||||
function items(c: ManagedChannel, { pin }: { pin: ActionMenuItem[] }): ActionMenuItem[] {
|
||||
const url = channelYouTubeUrl(c.id, c.handle);
|
||||
return [
|
||||
{
|
||||
id: "open",
|
||||
label: t("card.menu.openChannel"),
|
||||
icon: <Tv className="w-4 h-4" />,
|
||||
onSelect: () => onView(c),
|
||||
},
|
||||
...pin,
|
||||
{ ...tagsItem(c), separatorBefore: true },
|
||||
priorityItem(c),
|
||||
...[
|
||||
{
|
||||
id: "open-youtube",
|
||||
label: t("channels.row.openOnYouTube"),
|
||||
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(url, "_blank", "noopener,noreferrer"),
|
||||
},
|
||||
...copy(c.title, url, "name"),
|
||||
].map((it, i) => ({ ...it, separatorBefore: i === 0 })),
|
||||
{ ...backfillItem(c), separatorBefore: true },
|
||||
{
|
||||
id: "hide",
|
||||
label: t("channels.row.hideFromFeed"),
|
||||
title: t("channels.row.hideHint"),
|
||||
icon: <EyeOff className="w-4 h-4" />,
|
||||
disabled: hide.isPending,
|
||||
onSelect: () => hide.mutate(c),
|
||||
},
|
||||
...(me.can_write
|
||||
? [
|
||||
{
|
||||
id: "unsubscribe",
|
||||
label: t("channels.row.unsubscribeOnYoutube"),
|
||||
title: t("channels.row.unsubscribeHint"),
|
||||
icon: <UserMinus className="w-4 h-4" />,
|
||||
danger: true,
|
||||
separatorBefore: true,
|
||||
onSelect: () => void onUnsubscribe(c),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
/** Unhide from the Hidden tab — the same flag, the same refresh. */
|
||||
const unhide = (id: string) => patch.mutate({ id, body: { hidden: false } });
|
||||
|
||||
return { items, unhide };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Container-logic contract of the tag-suggestion hook, the wiring the presentational
|
||||
// ChannelTagsControl test can't reach: suggestionsFor resolves tag ids to Tags and DROPS a suggestion
|
||||
// whose tag was deleted since the last compute; accept optimistically removes the WHOLE channel from
|
||||
// the /tags/suggestions cache and toggles the tag on; dismiss is session-only and scoped to one
|
||||
// (channel, tag); visible/high-confidence derive correctly. Runs against a real QueryClient so the
|
||||
// optimistic cache write is the actual code path (not a re-implementation).
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
// The confirm dialog's answer is controllable per test (the bulk-accept path gates on it).
|
||||
const confirmState = vi.hoisted(() => ({ result: true }));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({
|
||||
useConfirm: () => () => Promise.resolve(confirmState.result),
|
||||
}));
|
||||
|
||||
import { api, type Tag } from "../../lib/api";
|
||||
import type { TagSuggestions } from "../../lib/api/tags";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const tags = [docu, history];
|
||||
|
||||
// UC_a: two suggestions (one high-confidence). UC_b: one. UC_ghost: a suggestion for a tag id that no
|
||||
// longer exists in the catalog (deleted since compute) → must be filtered out everywhere.
|
||||
const seed = (): TagSuggestions => ({
|
||||
UC_a: [
|
||||
{ tag_id: docu.id, confidence: 0.95 },
|
||||
{ tag_id: history.id, confidence: 0.6 },
|
||||
],
|
||||
UC_b: [{ tag_id: history.id, confidence: 0.7 }],
|
||||
UC_ghost: [{ tag_id: 999, confidence: 0.99 }],
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const qc = new QueryClient({
|
||||
// Freeze the seeded cache so no refetch clobbers the optimistic writes under test.
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnMount: false } },
|
||||
});
|
||||
qc.setQueryData(qk.tagSuggestions(), seed());
|
||||
vi.spyOn(api, "tagSuggestions").mockResolvedValue(seed());
|
||||
const toggleTag = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useTagSuggestions({ tags, toggleTag }), { wrapper });
|
||||
return { qc, toggleTag, result };
|
||||
}
|
||||
|
||||
describe("useTagSuggestions", () => {
|
||||
it("resolves tag ids to Tags and drops suggestions whose tag was deleted", () => {
|
||||
const { result } = setup();
|
||||
const forA = result.current.suggestionsFor("UC_a");
|
||||
expect(forA.map((s) => s.tag.id)).toEqual([docu.id, history.id]);
|
||||
// The suggestion for the missing tag id 999 is dropped, not rendered as a broken chip.
|
||||
expect(result.current.suggestionsFor("UC_ghost")).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives visible and high-confidence suggestions, excluding the deleted-tag one", () => {
|
||||
const { result } = setup();
|
||||
// UC_a(docu 0.95) + UC_a(history 0.6) + UC_b(history 0.7) = 3; UC_ghost excluded.
|
||||
expect(result.current.visibleSuggestions).toHaveLength(3);
|
||||
// Only the ≥0.9 one (UC_a/docu) is high-confidence.
|
||||
expect(result.current.highConfidence).toEqual([
|
||||
{ cid: "UC_a", tagId: docu.id, confidence: 0.95 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accept optimistically removes the whole channel from the cache and toggles the tag on", () => {
|
||||
const { qc, toggleTag, result } = setup();
|
||||
act(() => result.current.acceptSuggestion("UC_a", docu.id));
|
||||
// The real tag is attached (hasTag=false → attach).
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
// The whole channel is dropped from the suggestions cache (attached lane XOR ghost lane), so its
|
||||
// OTHER ghost chip (history) goes too — not just the accepted one.
|
||||
const cache = qc.getQueryData<TagSuggestions>(qk.tagSuggestions());
|
||||
expect(cache?.UC_a).toBeUndefined();
|
||||
expect(cache?.UC_b).toBeDefined(); // other channels untouched
|
||||
});
|
||||
|
||||
it("dismiss hides only that one (channel, tag) for the session", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.dismissSuggestion("UC_a", history.id));
|
||||
// UC_a loses only the history suggestion; docu stays.
|
||||
expect(result.current.suggestionsFor("UC_a").map((s) => s.tag.id)).toEqual([docu.id]);
|
||||
// The same tag on a DIFFERENT channel is not dismissed (dismiss is scoped to the pair).
|
||||
expect(result.current.suggestionsFor("UC_b").map((s) => s.tag.id)).toEqual([history.id]);
|
||||
});
|
||||
|
||||
it("bulk-accept applies ONLY the high-confidence suggestions after a confirm", async () => {
|
||||
confirmState.result = true;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
// Only UC_a/docu (0.95 ≥ 0.9) is accepted — not history (0.6) or UC_b/history (0.7).
|
||||
expect(toggleTag).toHaveBeenCalledTimes(1);
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
});
|
||||
|
||||
it("bulk-accept does nothing when the confirm is cancelled", async () => {
|
||||
confirmState.result = false;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
expect(toggleTag).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type Tag, type TagSuggestions } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import type { SuggestedTag } from "./ChannelTags";
|
||||
|
||||
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
|
||||
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
|
||||
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
|
||||
const HIGH_CONFIDENCE = 0.9;
|
||||
|
||||
type VisibleSuggestion = { cid: string; tagId: number; confidence: number };
|
||||
|
||||
/** Suggested-tag state for the Channel Manager: the /tags/suggestions query, a session-only
|
||||
* dismiss set, and the accept / dismiss / bulk-accept actions. Suggestions are learned from the
|
||||
* user's own manual-tag pattern and shown as ghost chips on UNTAGGED channels. Extracted as a hook
|
||||
* (like useChannelTagActions) so the optimistic-cache and dismiss logic is unit-testable apart from
|
||||
* the large Channels component. */
|
||||
export function useTagSuggestions({
|
||||
tags,
|
||||
toggleTag,
|
||||
}: {
|
||||
/** The full tag catalog, to resolve a suggestion's tag_id → Tag (and drop tags deleted since compute). */
|
||||
tags: Tag[];
|
||||
/** The shared attach/detach toggle. Its onError must also refetch suggestions (see Channels) so a
|
||||
* failed accept restores the ghost chips this hook optimistically removed. */
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const suggestionsQuery = useQuery({ queryKey: qk.tagSuggestions(), queryFn: api.tagSuggestions });
|
||||
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
|
||||
const tagById = useMemo(() => new Map(tags.map((tg) => [tg.id, tg])), [tags]);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
|
||||
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
|
||||
|
||||
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
|
||||
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
|
||||
const suggestionsFor = (cid: string): SuggestedTag[] =>
|
||||
(suggestions[cid] ?? []).flatMap((s) => {
|
||||
const tag = tagById.get(s.tag_id);
|
||||
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
|
||||
});
|
||||
|
||||
const dismissSuggestion = (cid: string, tagId: number) =>
|
||||
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
|
||||
|
||||
const acceptSuggestion = (cid: string, tagId: number) => {
|
||||
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
|
||||
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
|
||||
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
|
||||
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; on success
|
||||
// its onSuccess re-invalidates suggestions, and on failure its onError refetches them — so a
|
||||
// failed accept restores these ghost chips rather than leaving an empty cell (see Channels' onError).
|
||||
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
|
||||
if (!old?.[cid]) return old;
|
||||
const next = { ...old };
|
||||
delete next[cid];
|
||||
return next;
|
||||
});
|
||||
toggleTag(cid, tagId, false);
|
||||
};
|
||||
|
||||
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
|
||||
// the bulk action accepts.
|
||||
const visibleSuggestions: VisibleSuggestion[] = Object.keys(suggestions).flatMap((cid) =>
|
||||
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
|
||||
);
|
||||
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
|
||||
const acceptAllHighConfidence = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.tags.acceptAllTitle"),
|
||||
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
|
||||
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
|
||||
});
|
||||
if (!ok) return;
|
||||
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
|
||||
};
|
||||
|
||||
return {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A closed-set config key is picked, not typed: a short list renders as a segmented bar, a long one as
|
||||
// a dropdown, and the pick is what Save sends. A cleared draft (reset to the default) shows the default
|
||||
// as the active option rather than no option at all.
|
||||
//
|
||||
// i18n is a key-passthrough, except for an option's label: a full key would distort the
|
||||
// bar-or-dropdown choice, which weighs the real label length, so a choice id resolves to its value
|
||||
// under a one-character marker. The marker is what makes a lost lookup visible — rendering the bare
|
||||
// value instead of the id would be indistinguishable from `label: c`. The admin config API is stubbed.
|
||||
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 { ConfigItem } from "../../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
// Only an id the fixture really defines resolves: a dropped lookup renders the bare value and
|
||||
// a malformed id renders the key (long enough to turn every bar into a dropdown), so the
|
||||
// assertions below see both.
|
||||
t: (key: string) => CHOICE_LABELS[key] ?? key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const choice = (key: string, choices: string[], value: string, is_set = false): ConfigItem => ({
|
||||
key,
|
||||
type: "choice",
|
||||
group: "g",
|
||||
secret: false,
|
||||
min: null,
|
||||
max: null,
|
||||
is_set,
|
||||
allow_empty: false,
|
||||
choices,
|
||||
value,
|
||||
default: choices[0],
|
||||
});
|
||||
|
||||
const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"];
|
||||
const LEVELS = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"];
|
||||
const CHOICE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
(
|
||||
[
|
||||
["plex_transcode_hw", ["auto", "on", "off"]],
|
||||
["plex_transcode_preset", PRESETS],
|
||||
["log_level", LEVELS],
|
||||
] as const
|
||||
).flatMap(([key, choices]) =>
|
||||
choices.map((c) => [`config.choices.${key}.${c}`, `~${c}`] as const),
|
||||
),
|
||||
);
|
||||
const setConfig = vi.fn((_key: string, _value: unknown) => Promise.resolve({}));
|
||||
const resetConfig = vi.fn((_key: string) => Promise.resolve({}));
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminConfig: () =>
|
||||
Promise.resolve({
|
||||
groups: {
|
||||
g: [
|
||||
choice("plex_transcode_hw", ["auto", "on", "off"], "on", true),
|
||||
choice("plex_transcode_preset", PRESETS, "veryfast"),
|
||||
choice("log_level", LEVELS, "INFO"),
|
||||
],
|
||||
},
|
||||
sections: [{ id: "s", groups: ["g"] }],
|
||||
secrets_manageable: true,
|
||||
}),
|
||||
setConfig: (key: string, value: unknown) => setConfig(key, value),
|
||||
resetConfig: (key: string) => resetConfig(key),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
|
||||
|
||||
import ConfigPanel from "./ConfigPanel";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mount = () =>
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ConfigPanel />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("ConfigPanel choice keys", () => {
|
||||
it("renders a short list as a segmented bar and saves the pick", async () => {
|
||||
mount();
|
||||
const bar = await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
|
||||
const pressed = () =>
|
||||
[...bar.querySelectorAll('[aria-pressed="true"]')].map((b) => b.textContent);
|
||||
expect(pressed()).toEqual(["~on"]);
|
||||
expect(screen.queryByRole("textbox")).toBeNull(); // no free-text field for a choice key
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "~off" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
|
||||
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_hw", "off"));
|
||||
});
|
||||
|
||||
it("shows the default as active once the override is reset, and Save resets it", async () => {
|
||||
mount();
|
||||
await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
|
||||
fireEvent.click(screen.getByRole("button", { name: /config\.reset$/ }));
|
||||
expect(screen.getByRole("button", { name: "~auto" }).getAttribute("aria-pressed")).toBe("true");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
|
||||
await waitFor(() => expect(resetConfig).toHaveBeenCalledWith("plex_transcode_hw"));
|
||||
});
|
||||
|
||||
it("renders a long list as a dropdown showing only the value", async () => {
|
||||
mount();
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "config.fields.plex_transcode_preset.label: ~veryfast",
|
||||
});
|
||||
expect(trigger.textContent).toBe("~veryfast");
|
||||
fireEvent.click(trigger);
|
||||
// The popover rows are queried as FilterSelect.dom.test does (`hidden: true`, picked by text).
|
||||
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
|
||||
fireEvent.click(rows.find((r) => r.textContent === "~slow")!);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
|
||||
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_preset", "slow"));
|
||||
});
|
||||
|
||||
it("a few options with long labels are a dropdown too: a bar that wide does not fit a phone", async () => {
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "config.fields.log_level.label: ~INFO" });
|
||||
expect(screen.queryByRole("group", { name: "config.fields.log_level.label" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Locks the stacking that makes an admin config row usable on a phone. The
|
||||
// control column cannot shrink, so beside a wide control it left the label and its hint a sliver of
|
||||
// the ~250 px a phone leaves next to the navigation rail. The sibling fix — a long-labelled choice
|
||||
// becoming a dropdown — is covered in ConfigPanel.choice.dom.test.tsx, but that one does nothing for
|
||||
// a text, number or secret key, whose `w-52` input is the wide control: this is their only fix.
|
||||
//
|
||||
// jsdom does no layout, so what is asserted is the class contract those rules rest on, the same
|
||||
// trade SearchBar.dom.test.tsx already makes for the narrow-header rules.
|
||||
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 type { ConfigItem } from "../../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const text = (key: string): ConfigItem => ({
|
||||
key,
|
||||
type: "str",
|
||||
group: "g",
|
||||
secret: false,
|
||||
min: null,
|
||||
max: null,
|
||||
is_set: false,
|
||||
allow_empty: true,
|
||||
choices: [],
|
||||
value: "",
|
||||
default: "",
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminConfig: () =>
|
||||
Promise.resolve({
|
||||
groups: {
|
||||
g: [text("site_name"), { ...text("signups_open"), type: "bool", value: "false" }],
|
||||
},
|
||||
sections: [{ id: "s", groups: ["g"] }],
|
||||
secrets_manageable: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
|
||||
|
||||
import ConfigPanel from "./ConfigPanel";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConfigPanel field row", () => {
|
||||
it("stacks the label above the control below `sm`, and lets the control fit the screen", async () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ConfigPanel />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const label = await screen.findByText("config.fields.site_name.label");
|
||||
const row = label.parentElement!.parentElement!; // label → the label/hint column → the row
|
||||
// One column on a phone, the two-column row only from `sm` up:
|
||||
expect(row.className).toContain("flex-col");
|
||||
expect(row.className).toContain("sm:flex-row");
|
||||
// The control column may not out-width the screen, and only stops shrinking from `sm` up:
|
||||
const control = row.lastElementChild as HTMLElement;
|
||||
expect(control.className).toContain("max-w-full");
|
||||
expect(control.className).toContain("sm:shrink-0");
|
||||
expect(control.className).not.toMatch(/(^|\s)shrink-0(\s|$)/);
|
||||
});
|
||||
|
||||
it("toggles a boolean key from its title and its hint, not only from the switch (F12)", async () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ConfigPanel />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const sw = await screen.findByRole("switch", { name: "config.fields.signups_open.label" });
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
fireEvent.click(screen.getByText("config.fields.signups_open.label"));
|
||||
expect(sw.getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(screen.getByText("config.fields.signups_open.hint"));
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
// The switch itself still toggles exactly once, not once more through its label.
|
||||
fireEvent.click(sw);
|
||||
expect(sw.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The owner's pick: Configuration's sixteen group tabs wrapped to three rows at 133% zoom, so they
|
||||
// are a side list under four headings, as Settings has. The registry owns the order: the groups follow
|
||||
// its sections, not the order the groups object happens to arrive in.
|
||||
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 type { ConfigItem } from "../../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const text = (key: string, group: string): ConfigItem => ({
|
||||
key,
|
||||
type: "str",
|
||||
group,
|
||||
secret: false,
|
||||
min: null,
|
||||
max: null,
|
||||
is_set: false,
|
||||
allow_empty: true,
|
||||
choices: [],
|
||||
value: "",
|
||||
default: "",
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminConfig: () =>
|
||||
Promise.resolve({
|
||||
groups: { b: [text("kb", "b")], c: [text("kc", "c")], a: [text("ka", "a")] },
|
||||
sections: [
|
||||
{ id: "one", groups: ["a", "b"] },
|
||||
{ id: "two", groups: ["c"] },
|
||||
],
|
||||
secrets_manageable: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
|
||||
|
||||
import ConfigPanel from "./ConfigPanel";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const renderPanel = () =>
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ConfigPanel />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("Configuration sections", () => {
|
||||
it("lists the groups in the registry's sections, each heading once above its run", async () => {
|
||||
renderPanel();
|
||||
const list = await screen.findByRole("tablist");
|
||||
expect(list.getAttribute("aria-orientation")).toBe("vertical");
|
||||
// The list's own lines, top to bottom: headings and tabs.
|
||||
const lines = Array.from(list.children).map((el) =>
|
||||
el.getAttribute("role") === "tab" ? `tab:${el.textContent}` : `head:${el.textContent}`,
|
||||
);
|
||||
expect(lines).toEqual([
|
||||
"head:config.sections.one",
|
||||
"tab:config.groups.a",
|
||||
"tab:config.groups.b",
|
||||
"head:config.sections.two",
|
||||
"tab:config.groups.c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens on the first group and switches to the one picked", async () => {
|
||||
renderPanel();
|
||||
expect(await screen.findByText("config.fields.ka.label")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "config.groups.c" }));
|
||||
expect(screen.getByText("config.fields.kc.label")).toBeTruthy();
|
||||
expect(screen.queryByText("config.fields.ka.label")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plug, RotateCcw, Send } from "lucide-react";
|
||||
import { api, type ConfigItem, type PlexTestResult } from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import Tooltip from "../../components/ui/Tooltip";
|
||||
import InlineNotice from "../../components/ui/InlineNotice";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import Tabs from "../../components/ui/Tabs";
|
||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { Switch } from "../../components/ui/form";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import FilterSelect from "../../components/ui/FilterSelect";
|
||||
import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar";
|
||||
import { LS, useTabPersistedState } from "../../lib/storage";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { CheckRow } from "../../components/ui/NativeControls";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
||||
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
||||
// applied together via one Save/Discard bar; nothing hits the server until Save. The tabs follow the
|
||||
// registry's own order (`sysconfig.SPECS`), so a new group lands where the backend puts it instead of
|
||||
// needing a second, hand-kept order list here.
|
||||
|
||||
/** The admin's draft, plus the secrets marked to reset-to-default on the next Save (that can't be
|
||||
* expressed by clearing the field: an empty secret field means "leave unchanged"). Re-seeded from
|
||||
* `baseline` DURING render when `seedKey` changes, never on every new baseline: an unchanged refetch
|
||||
* produces a new object, and re-seeding on that is exactly the clobbering of an in-progress draft
|
||||
* that CB2 was about. (It was an effect: the first frame after a load painted the empty draft.) */
|
||||
function useSeededDraft(baseline: Record<string, string>, seedKey: string) {
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
|
||||
const [seededFor, setSeededFor] = useState<string | null>(null);
|
||||
if (seededFor !== seedKey) {
|
||||
setSeededFor(seedKey);
|
||||
setDraft(baseline);
|
||||
setSecretReset({});
|
||||
}
|
||||
return { draft, setDraft, secretReset, setSecretReset };
|
||||
}
|
||||
|
||||
/** The dirty keys' writes, one after another. Apart from ConfigPanel's save() so the loop and its
|
||||
* branches sit outside the try (the React Compiler cannot lower either inside one yet, and skipped
|
||||
* the whole panel). */
|
||||
async function writeConfigItems(
|
||||
dirty: ConfigItem[],
|
||||
draft: Record<string, string>,
|
||||
secretReset: Record<string, boolean>,
|
||||
) {
|
||||
for (const it of dirty) {
|
||||
const key = it.key;
|
||||
const raw = draft[key] ?? "";
|
||||
if (it.secret) {
|
||||
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
|
||||
else if (raw.trim()) await api.setConfig(key, raw);
|
||||
} else if (it.type === "bool") {
|
||||
await api.setConfig(key, raw === "true");
|
||||
} else if (raw === "") {
|
||||
// allow_empty keys treat a blank as an explicit empty value (e.g. disable smtp_user /
|
||||
// youtube_api_proxy) rather than resetting to the env default (AB3).
|
||||
if (it.allow_empty) await api.setConfig(key, "");
|
||||
else if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
|
||||
} else {
|
||||
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const panel = useTabPanel();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
|
||||
const data = q.data;
|
||||
|
||||
const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]);
|
||||
// Baseline draft from the server: non-secrets show their effective value; secrets start empty
|
||||
// (write-only — we never load them back).
|
||||
const baseline = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const it of items) m[it.key] = it.secret ? "" : String(it.value ?? "");
|
||||
return m;
|
||||
}, [items]);
|
||||
|
||||
const [saveState, setSaveState] = useState<SaveState>("idle");
|
||||
// Bumped after our own save so the draft re-seeds from fresh server values; a plain mid-edit
|
||||
// refetch does NOT (see the seed below).
|
||||
const [reseedToken, setReseedToken] = useState(0);
|
||||
// One tab per config group (persisted). Called before the loading guard so hook order is
|
||||
// stable; the active id is clamped to a real group at render time once data has loaded.
|
||||
const [tab, setTab] = useTabPersistedState(LS.configTab);
|
||||
|
||||
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
|
||||
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
|
||||
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
|
||||
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
|
||||
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
|
||||
const { draft, setDraft, secretReset, setSecretReset } = useSeededDraft(
|
||||
baseline,
|
||||
`${reseedToken}:${dataVersion}`,
|
||||
);
|
||||
|
||||
const isDirty = (it: ConfigItem): boolean => {
|
||||
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
|
||||
return (draft[it.key] ?? "") !== (baseline[it.key] ?? "");
|
||||
};
|
||||
const dirtyItems = items.filter(isDirty);
|
||||
const dirty = dirtyItems.length > 0;
|
||||
|
||||
async function save() {
|
||||
setSaveState("saving");
|
||||
try {
|
||||
await writeConfigItems(dirtyItems, draft, secretReset);
|
||||
await qc.invalidateQueries({ queryKey: qk.adminConfig() });
|
||||
// The email-not-configured notice reads me.email_configured, which is otherwise only
|
||||
// refetched on its own 60s interval — without this it keeps warning about fields the admin
|
||||
// has just filled in, which reads as the save not having taken.
|
||||
await qc.invalidateQueries({ queryKey: qk.me() });
|
||||
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
|
||||
setSaveState("saved");
|
||||
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
||||
} catch (e) {
|
||||
setSaveState("error");
|
||||
notifyActionError(e, t("config.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function discard() {
|
||||
setDraft(baseline);
|
||||
setSecretReset({});
|
||||
setSaveState("idle");
|
||||
}
|
||||
|
||||
const testEmail = useMutation({
|
||||
mutationFn: () => api.testEmail(),
|
||||
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
|
||||
onError: (e) => notifyActionError(e, t("config.testFailed")),
|
||||
});
|
||||
|
||||
// Plex "Test connection": verifies the server URL + token and returns the library sections,
|
||||
// which drive the library-picker below (checked sections are written into the plex_libraries
|
||||
// draft as a comma-separated list of section keys, applied on Save).
|
||||
const [plexResult, setPlexResult] = useState<PlexTestResult | null>(null);
|
||||
const testPlex = useMutation({
|
||||
mutationFn: () => api.testPlex(),
|
||||
onSuccess: (r) => {
|
||||
setPlexResult(r);
|
||||
notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) });
|
||||
},
|
||||
onError: (e) => {
|
||||
setPlexResult(null);
|
||||
notifyActionError(e, t("config.plexTestFailed"));
|
||||
},
|
||||
});
|
||||
// Bug reports' "Test connection": the repository answers, takes issues, is private.
|
||||
const testIssues = useMutation({
|
||||
mutationFn: () => api.testIssues(),
|
||||
onSuccess: (r) =>
|
||||
notify({ level: "success", message: t("config.issuesTestOk", { repo: r.repo }) }),
|
||||
onError: (e) => notifyActionError(e, t("config.issuesTestFailed")),
|
||||
});
|
||||
|
||||
const plexLibs = (draft["plex_libraries"] ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const togglePlexLib = (key: string) => {
|
||||
// An empty list means "all libraries" — every box renders checked. Unchecking one from that
|
||||
// state must select all-except-this, so expand "all" to the explicit section set first;
|
||||
// otherwise the toggle would ADD the key and leave it as the ONLY selected library.
|
||||
const allKeys = plexResult?.sections?.map((s) => s.key) ?? [];
|
||||
const current = plexLibs.length === 0 ? allKeys : plexLibs;
|
||||
const next = current.includes(key) ? current.filter((k) => k !== key) : [...current, key];
|
||||
setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
|
||||
};
|
||||
|
||||
if (q.isError && !data) {
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
}
|
||||
if (q.isLoading || !data) {
|
||||
return <LoadingState label={t("config.loading")} />;
|
||||
}
|
||||
|
||||
// The groups in the registry's sections, in order (sixteen tabs wrapped to three rows at 133%
|
||||
// zoom, so they are a side list under four headings, as Settings has).
|
||||
const groupKeys = data.sections.flatMap((s) => s.groups);
|
||||
// Clamp the persisted tab to a real group (the registry can change between sessions). The
|
||||
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
|
||||
// never silently lost behind the global Save bar.
|
||||
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
|
||||
const dirtyByGroup = new Set(dirtyItems.map((i) => i.group).filter(Boolean));
|
||||
const groupTabs = data.sections.flatMap((s) =>
|
||||
s.groups.map((g) => ({
|
||||
id: g,
|
||||
group: t(`config.sections.${s.id}`, s.id),
|
||||
label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`,
|
||||
testId: `config-tab-${g}`,
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 pb-24 pt-3 max-w-4xl w-full mx-auto">
|
||||
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
|
||||
{/* The groups as a side list under their headings, the fields beside it: Settings' own
|
||||
layout, one pattern for both settings pages. */}
|
||||
<div className={`${SURFACE.panel} mb-4 flex flex-col overflow-hidden sm:flex-row`}>
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
orientation="vertical"
|
||||
className="flex gap-1 overflow-x-auto border-b border-border/60 p-2 sm:w-52 sm:shrink-0 sm:flex-col sm:border-b-0 sm:border-r"
|
||||
tabs={groupTabs}
|
||||
active={activeGroup ?? ""}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{activeGroup && (
|
||||
<div {...panel.panelProps(activeGroup ?? "")} className="min-w-0 flex-1 p-4">
|
||||
<div className="divide-y divide-border/60">
|
||||
{(data.groups[activeGroup] ?? []).map((item) => (
|
||||
<Field
|
||||
key={item.key}
|
||||
item={item}
|
||||
value={draft[item.key] ?? ""}
|
||||
secretsManageable={data.secrets_manageable}
|
||||
pendingSecretReset={!!secretReset[item.key]}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
|
||||
onResetField={() => {
|
||||
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
|
||||
else setDraft((d) => ({ ...d, [item.key]: "" }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeGroup === "email" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60 space-y-3">
|
||||
{/* The one place an operator can see that mail is off. Sending is fail-soft, so a
|
||||
half-filled config costs verification links, resets and access-request notices
|
||||
with no error anywhere — say so next to the fields that fix it. */}
|
||||
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
|
||||
<Tooltip hint={t("config.testEmailHint")}>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<Send />}
|
||||
loading={testEmail.isPending}
|
||||
aria-disabled={dirty}
|
||||
onClick={() => testEmail.mutate()}
|
||||
>
|
||||
{t("config.testEmail")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-2xs text-muted">{t("config.testDirty")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeGroup === "issues" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<Tooltip hint={t("config.issuesTestHint")}>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<Plug />}
|
||||
loading={testIssues.isPending}
|
||||
aria-disabled={dirty}
|
||||
onClick={() => testIssues.mutate()}
|
||||
>
|
||||
{t("config.issuesTest")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeGroup === "plex" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<Tooltip hint={t("config.plexTestHint")}>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<Plug />}
|
||||
loading={testPlex.isPending}
|
||||
aria-disabled={dirty}
|
||||
onClick={() => testPlex.mutate()}
|
||||
>
|
||||
{t("config.plexTest")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
|
||||
{plexResult && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-muted mb-2">
|
||||
{t("config.plexConnected", {
|
||||
name: plexResult.server_name,
|
||||
version: plexResult.version ?? "",
|
||||
})}
|
||||
</p>
|
||||
{plexResult.media_check?.checked &&
|
||||
(plexResult.media_check.ok ? (
|
||||
<p className="text-2xs text-success mb-2">{t("config.plexMediaOk")}</p>
|
||||
) : (
|
||||
<p className="text-2xs text-danger mb-2">
|
||||
{t("config.plexMediaMissing", {
|
||||
path: plexResult.media_check.local_path ?? "",
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
<p className="text-2xs text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{plexResult.sections.map((s) => (
|
||||
<CheckRow
|
||||
key={s.key}
|
||||
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
|
||||
onChange={() => togglePlexLib(s.key)}
|
||||
className="text-sm"
|
||||
>
|
||||
<span>{s.title}</span>
|
||||
<span className="text-2xs text-muted">
|
||||
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
|
||||
{s.count != null ? ` · ${s.count}` : ""}
|
||||
</span>
|
||||
</CheckRow>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-2xs text-muted mt-2">{t("config.plexPickHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DraftSaveBar
|
||||
variant="floating"
|
||||
dirty={dirty}
|
||||
state={saveState}
|
||||
onSave={save}
|
||||
onDiscard={discard}
|
||||
labels={{
|
||||
saved: t("config.saved"),
|
||||
failed: t("config.saveFailed"),
|
||||
unsaved: t("config.unsaved"),
|
||||
discard: t("config.discard"),
|
||||
save: t("config.save"),
|
||||
saving: t("config.saving"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
item,
|
||||
value,
|
||||
secretsManageable,
|
||||
pendingSecretReset,
|
||||
onChange,
|
||||
onResetField,
|
||||
}: {
|
||||
item: ConfigItem;
|
||||
value: string;
|
||||
secretsManageable: boolean;
|
||||
pendingSecretReset: boolean;
|
||||
onChange: (v: string) => void;
|
||||
onResetField: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isNum = item.type === "int";
|
||||
const isBool = item.type === "bool";
|
||||
const label = t(`config.fields.${item.key}.label`, item.key);
|
||||
const secretDisabled = item.secret && !secretsManageable;
|
||||
|
||||
// Status line: never reveal a secret's value.
|
||||
let status: string;
|
||||
if (item.secret) {
|
||||
status = secretDisabled
|
||||
? t("config.secretsDisabled")
|
||||
: pendingSecretReset
|
||||
? t("config.willReset")
|
||||
: item.is_set
|
||||
? t("config.secretSet")
|
||||
: item.default_is_set
|
||||
? t("config.secretEnv")
|
||||
: t("config.secretUnset");
|
||||
} else {
|
||||
status = item.is_set ? t("config.overridden") : t("config.usingDefault");
|
||||
}
|
||||
|
||||
// Reset affordance shows when a DB override exists: clears a non-secret to its default, or
|
||||
// toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
|
||||
// its own control — storing its value is equivalent to the default, so no reset link.
|
||||
const showReset = item.is_set && !isBool;
|
||||
const switchId = useId();
|
||||
const Text = isBool ? "label" : "div";
|
||||
|
||||
return (
|
||||
// Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the
|
||||
// label and hint a sliver of a phone's width.
|
||||
<div className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
{/* A switch's whole text column is its label, so the title or the hint toggles it (F12); a
|
||||
label may only hold phrasing content, hence the block spans. */}
|
||||
<Text
|
||||
{...(isBool && { htmlFor: switchId })}
|
||||
className={`min-w-0 flex-1 ${isBool ? "cursor-pointer select-none" : ""}`}
|
||||
>
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="block text-xs text-muted leading-relaxed mt-0.5">
|
||||
{t(`config.fields.${item.key}.hint`, "")}
|
||||
</span>
|
||||
<span className="block text-2xs text-muted mt-1">{status}</span>
|
||||
</Text>
|
||||
|
||||
<div className="flex max-w-full flex-col items-start gap-1.5 sm:shrink-0 sm:items-end">
|
||||
{isBool ? (
|
||||
<Switch
|
||||
id={switchId}
|
||||
label={label}
|
||||
checked={value === "true"}
|
||||
onChange={(v) => onChange(v ? "true" : "false")}
|
||||
/>
|
||||
) : item.type === "choice" ? (
|
||||
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
|
||||
) : (
|
||||
<Input
|
||||
className="w-52"
|
||||
type={item.secret ? "password" : isNum ? "number" : "text"}
|
||||
value={value}
|
||||
disabled={secretDisabled || (item.secret && pendingSecretReset)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
min={isNum && item.min != null ? item.min : undefined}
|
||||
max={isNum && item.max != null ? item.max : undefined}
|
||||
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
{showReset && !secretDisabled && (
|
||||
<Tooltip hint={t("config.resetHint")}>
|
||||
<Button
|
||||
variant={item.secret && pendingSecretReset ? "link" : "link-muted"}
|
||||
icon={<RotateCcw className="size-3.5!" />}
|
||||
onClick={onResetField}
|
||||
className="text-xs gap-1"
|
||||
>
|
||||
{t("config.reset")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A closed-set key picks from its options instead of taking free text: a segmented bar for a short
|
||||
// list, a dropdown past six (the house rule for chip vs. select). A cleared draft ("" = reset to the
|
||||
// default on Save) shows the default as the active option, as a text field shows it as placeholder.
|
||||
//
|
||||
// "Short" is also about the words, not just their number: a bar cannot shrink or wrap, and five level
|
||||
// names (TRACE … WARNING, ~300 px) are wider than a phone leaves beside the navigation rail (~250 px).
|
||||
// Past MAX_SEGMENT_CHARS of labels, in the language shown, it is a dropdown too.
|
||||
const MAX_SEGMENTS = 6;
|
||||
const MAX_SEGMENT_CHARS = 22;
|
||||
|
||||
function ChoiceField({
|
||||
item,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
item: ConfigItem;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = item.choices.map((c) => ({
|
||||
value: c,
|
||||
label: t(`config.choices.${item.key}.${c}`, c),
|
||||
}));
|
||||
const current = value || String(item.default ?? "");
|
||||
const labelChars = options.reduce((sum, o) => sum + o.label.length, 0);
|
||||
if (options.length <= MAX_SEGMENTS && labelChars <= MAX_SEGMENT_CHARS) {
|
||||
return (
|
||||
<SegmentedToggle
|
||||
options={options}
|
||||
value={current}
|
||||
onChange={onChange}
|
||||
ariaLabel={label}
|
||||
size="md"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FilterSelect
|
||||
label={label}
|
||||
hideLabel
|
||||
options={options}
|
||||
selected={[current]}
|
||||
onChange={([next]) => next && onChange(next)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel page's video grid follows a running full-history backfill (bug hunt 2026-09, item 6).
|
||||
// The rule itself — when a poll reading should refresh the grid — is pinned on the hook in
|
||||
// lib/channelDetailPoll.backfill.dom.test.tsx. This pins that the PAGE runs it: deleting the hook's
|
||||
// one call site left every lane green, knip included (the hook test's import kept the export "used").
|
||||
//
|
||||
// The page's own poll is what feeds the hook; a newer reading is handed to it here through the same
|
||||
// query key the poll writes, rather than waiting out the 4 s interval. The Feed itself is stubbed —
|
||||
// what matters is whether its queries are told they are stale.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type FeedFilters, type Me } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
||||
const reading = (stored_videos: number): ChannelDetail =>
|
||||
({
|
||||
id: CH,
|
||||
title: "LEMMiNO",
|
||||
subscribed: true,
|
||||
deep_requested: true,
|
||||
backfill_done: false,
|
||||
stored_videos,
|
||||
}) as unknown as ChannelDetail;
|
||||
const feedKey = qk.feed({ q: "", channel: CH } as unknown as FeedFilters);
|
||||
|
||||
describe("ChannelPage — the grid follows a running backfill", () => {
|
||||
it("marks the channel's video grid stale when the poll sees more stored videos", async () => {
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue(reading(19));
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(feedKey, { pages: [{ items: [], next_cursor: null }], pageParams: [null] });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(api.channelDetail).toHaveBeenCalled());
|
||||
await waitFor(() => expect(qc.getQueryData(qk.channel(CH))).toBeTruthy());
|
||||
expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false);
|
||||
|
||||
// The next poll tick: the background job has stored 60 more.
|
||||
act(() => {
|
||||
qc.setQueryData(qk.channel(CH), reading(79));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel banner is served from Google's image CDN (yt3.googleusercontent.com), which refuses a
|
||||
// request that carries a Referer from our origin — every time, not only under load (measured
|
||||
// 2026-09-26: 6/6 refused with the page's referrer or its bare origin, 8/8 served without one). So the
|
||||
// banner <img> must send none, as ui/Avatar does for the same CDN; with a referrer the banner never
|
||||
// showed and the page fell back to the blank bar.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type Me } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
|
||||
|
||||
describe("ChannelPage — the banner", () => {
|
||||
it("is requested without a referrer, which Google's image CDN refuses", async () => {
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||
id: CH,
|
||||
title: "StarTalk",
|
||||
banner_url: "https://yt3.googleusercontent.com/banner",
|
||||
subscribed: true,
|
||||
blocked: false,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
backfill_done: true,
|
||||
stored_videos: 10,
|
||||
} as unknown as ChannelDetail);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const { container } = render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="StarTalk"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const banner = await waitFor(() => {
|
||||
const img = container.querySelector<HTMLImageElement>('img[src^="https://yt3."]');
|
||||
expect(img).not.toBeNull();
|
||||
return img!;
|
||||
});
|
||||
expect(banner.getAttribute("referrerpolicy")).toBe("no-referrer");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel page's Block and Hide toggles and its Subscribe / Unsubscribe button (the same row,
|
||||
// missed by the first fix) while their request runs. A
|
||||
// focused button that turns natively `disabled` drops the keyboard to <body> in Chrome (measured for
|
||||
// ui/Button), so a busy toggle blocks the click with aria-disabled instead — focusable, and
|
||||
// still pressed only once.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach, onTestFinished } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type Me } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
||||
|
||||
describe("ChannelPage — a busy toggle keeps the keyboard", () => {
|
||||
it.each([
|
||||
["channel.block", "blockChannel", true],
|
||||
["channel.hide", "updateChannel", true],
|
||||
["channel.subscribe", "subscribeChannel", false],
|
||||
["channel.subscribedState", "unsubscribeChannel", true],
|
||||
] as const)(
|
||||
"%s stays focusable and fires once while its request runs",
|
||||
async (label, call, subscribed) => {
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||
id: CH,
|
||||
title: "LEMMiNO",
|
||||
subscribed,
|
||||
blocked: false,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
backfill_done: true,
|
||||
stored_videos: 10,
|
||||
} as unknown as ChannelDetail);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const pending = vi.spyOn(api, call).mockReturnValue(new Promise(() => {}) as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const button = await screen.findByRole("button", { name: label });
|
||||
act(() => button.focus());
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
|
||||
expect(button.hasAttribute("disabled")).toBe(false);
|
||||
expect(document.activeElement).toBe(button);
|
||||
fireEvent.click(button);
|
||||
expect(pending).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
|
||||
// next waits, a visible <img> showed the browser's broken-image glyph (seen live). While a retry
|
||||
// waits there is no <img> at all — the bar's own ground — and the retry asks past the cache.
|
||||
describe("ChannelPage — the banner", () => {
|
||||
it("leaves the bar empty while a failed attempt waits, then asks again past the cache", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
onTestFinished(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||
id: CH,
|
||||
title: "LEMMiNO",
|
||||
subscribed: true,
|
||||
blocked: false,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
backfill_done: true,
|
||||
stored_videos: 10,
|
||||
banner_url: "https://yt3.example/banner",
|
||||
} as unknown as ChannelDetail);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const { container } = render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const banner = () =>
|
||||
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]');
|
||||
await waitFor(() => expect(banner()).toBeTruthy());
|
||||
expect(banner()!.getAttribute("referrerpolicy")).toBe("no-referrer");
|
||||
fireEvent.error(banner()!);
|
||||
expect(banner()).toBeNull();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
expect(banner()!.getAttribute("src")).toContain("?r=1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The owner's pick: the channel page's chrome is fixed, and its banner + identity block kept ~40% of a
|
||||
// 133% screen before the first video for as long as the page was open. Once the grid scrolls they fold
|
||||
// away and the name rides in the tab row; back at the top they return. The Back button is one element in
|
||||
// both states, so a focused Back keeps its focus through the fold.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { CONTENT_ID } from "../../components/ui/landmarks";
|
||||
import { api, type ChannelDetail, type Me } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.getElementById(CONTENT_ID)?.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
|
||||
const BANNER = 'img[src^="https://yt3."]';
|
||||
|
||||
// The page's one scroller (PageScroller's <main>), with a scroll position jsdom does not lay out.
|
||||
function scroller() {
|
||||
const main = document.createElement("main");
|
||||
main.id = CONTENT_ID;
|
||||
let top = 0;
|
||||
Object.defineProperty(main, "scrollTop", { get: () => top, configurable: true });
|
||||
Object.defineProperty(main, "scrollHeight", { value: 6000, configurable: true });
|
||||
Object.defineProperty(main, "clientHeight", { value: 600, configurable: true });
|
||||
document.body.appendChild(main);
|
||||
return (to: number) =>
|
||||
act(() => {
|
||||
top = to;
|
||||
main.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
}
|
||||
|
||||
// Renders the page and waits for the detail to land (the banner shows).
|
||||
async function renderPage() {
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||
id: CH,
|
||||
title: "StarTalk",
|
||||
banner_url: "https://yt3.googleusercontent.com/banner",
|
||||
subscribed: true,
|
||||
blocked: false,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
backfill_done: true,
|
||||
stored_videos: 10,
|
||||
tag_ids: [],
|
||||
} as unknown as ChannelDetail);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const view = render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="StarTalk"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(view.container.querySelector(BANNER)).not.toBeNull());
|
||||
return view;
|
||||
}
|
||||
|
||||
describe("ChannelPage — the header folds while the grid scrolls", () => {
|
||||
it("drops the banner and puts the name in the tab row once scrolled, and brings them back at the top", async () => {
|
||||
const scrollTo = scroller();
|
||||
const { container } = await renderPage();
|
||||
const banner = () => container.querySelector(BANNER);
|
||||
expect(banner()).not.toBeNull();
|
||||
expect(screen.queryByTestId("channel-folded")).toBeNull();
|
||||
|
||||
scrollTo(400);
|
||||
expect(banner()).toBeNull();
|
||||
expect(screen.getByTestId("channel-folded").textContent).toContain("StarTalk");
|
||||
|
||||
scrollTo(10);
|
||||
expect(screen.queryByTestId("channel-folded")).not.toBeNull();
|
||||
scrollTo(0);
|
||||
expect(banner()).not.toBeNull();
|
||||
expect(screen.queryByTestId("channel-folded")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a focused Back focused through the fold", async () => {
|
||||
const scrollTo = scroller();
|
||||
await renderPage();
|
||||
const back = screen.getByTestId("channel-back");
|
||||
back.focus();
|
||||
scrollTo(400);
|
||||
expect(screen.getByTestId("channel-folded")).toBeTruthy();
|
||||
expect(document.activeElement).toBe(back);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,626 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
import { channelDetailPollMs, useBackfillFeedRefresh } from "../../lib/channelDetailPoll";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { invalidateSubscription } from "../../lib/subscriptionCache";
|
||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
||||
import { useChannelPriority } from "../../lib/useChannelPriority";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Ban,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
History,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import Avatar from "../../components/ui/Avatar";
|
||||
import Tabs from "../../components/ui/Tabs";
|
||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||
import { useScrollFold } from "../../components/ui/scrollFold";
|
||||
import Feed from "./Feed";
|
||||
import ChannelAboutContent from "../channels/ChannelAbout";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
||||
import { useFeedFilters } from "./feedFiltersContext";
|
||||
import { SHOW_IDS } from "./feedFiltersInit";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import {
|
||||
api,
|
||||
DEFAULT_TAG_MODES,
|
||||
type FeedFilters,
|
||||
type Me,
|
||||
type ChannelDetail,
|
||||
} from "../../lib/api";
|
||||
import type { FeedView } from "../../lib/feedView";
|
||||
import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format";
|
||||
import { ChannelTagsControl } from "../channels/ChannelTags";
|
||||
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { IconLink } from "../../components/ui/LinkButton";
|
||||
import { PageHeading } from "../../components/ui/Headings";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||
// auto-ingests recent uploads in the background ("explore") so they're browsable immediately,
|
||||
// and offers "load more" to page deeper — without permanently polluting everyone's library
|
||||
// (the backend keeps explored videos private to this user until they subscribe).
|
||||
export default function ChannelPage({
|
||||
channelId,
|
||||
initialName,
|
||||
me,
|
||||
view,
|
||||
setView,
|
||||
onShowInFeed,
|
||||
}: {
|
||||
channelId: string;
|
||||
initialName?: string;
|
||||
me: Me;
|
||||
// The feed's view pref, passed straight through to the Feed this page renders — so its
|
||||
// toolbar carries the same switcher and a change here is the same account-wide choice.
|
||||
view: FeedView;
|
||||
setView: (v: FeedView) => void;
|
||||
// Narrow the real feed to this channel and go there. Offered only for a channel you're
|
||||
// subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't
|
||||
// follow would just land you on an empty page.
|
||||
onShowInFeed: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { closeChannel } = useNavigationActions();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// Seed the channel view's content-type prefs from the global feed (read once at mount). The
|
||||
// channel page is its own filter scope, but WHICH content types the user wants to see (normal /
|
||||
// shorts / live-upcoming) is a genuine preference, not part of "this channel's catalog" — so we
|
||||
// inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these.
|
||||
const globalFilters = useFeedFilters();
|
||||
const [tab, setTab] = useState<"videos" | "about">("videos");
|
||||
const panel = useTabPanel();
|
||||
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
|
||||
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
|
||||
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
|
||||
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
|
||||
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
|
||||
// The banner and the identity block fold away once the grid scrolls (owner's pick): the chrome
|
||||
// is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row.
|
||||
const foldable = useRef<HTMLDivElement>(null);
|
||||
const folded = useScrollFold(foldable);
|
||||
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
|
||||
// the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the
|
||||
// scheduler tops up any tail on its ~10-minute ticks — then stop polling once the back-catalogue is
|
||||
// in. Same queryKey as before, so mutation invalidations still hit.
|
||||
const detail = useLiveQuery(qk.channel(channelId), () => api.channelDetail(channelId), {
|
||||
intervalMs: channelDetailPollMs,
|
||||
});
|
||||
const ch = detail.data;
|
||||
// The poll above is also the ONLY signal that the running backfill has written videos, so it
|
||||
// drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on
|
||||
// the PATCH response, before the background job has stored anything).
|
||||
useBackfillFeedRefresh(ch, qc);
|
||||
|
||||
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
|
||||
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
|
||||
const [exploring, setExploring] = useState(false);
|
||||
const autoTried = useRef<string | null>(null);
|
||||
|
||||
const runExplore = useCallback(
|
||||
(token?: string | null) => {
|
||||
setExploring(true);
|
||||
return (
|
||||
api
|
||||
.exploreChannel(channelId, token ?? undefined)
|
||||
.then((r) => {
|
||||
setNextToken(r.next_page_token);
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
|
||||
// first visit (the GET that drove this render predated the explore that just ran).
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
})
|
||||
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
|
||||
// channel page that looked like a channel with no videos.
|
||||
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
|
||||
.finally(() => setExploring(false))
|
||||
);
|
||||
},
|
||||
[channelId, qc, t],
|
||||
);
|
||||
|
||||
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are
|
||||
// already present (a revisit, or a subscribed channel whose uploads the scheduler backfills).
|
||||
useEffect(() => {
|
||||
if (!ch) return;
|
||||
if (autoTried.current === channelId) return;
|
||||
if (ch.subscribed || ch.blocked) return;
|
||||
if (ch.explored && ch.stored_videos > 0) return;
|
||||
autoTried.current = channelId;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- a one-off INGEST of an unsubscribed channel's uploads on first visit — a network side effect, not state. The autoTried ref keyed on the channel id is what makes it once per channel.
|
||||
void runExplore();
|
||||
}, [ch, channelId, runExplore]);
|
||||
|
||||
const block = useMutation({
|
||||
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
||||
},
|
||||
// No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide
|
||||
// reporter names the failure the error dialog does not cover (a 404 for a channel gone).
|
||||
meta: { failTitle: "errors.writeFailed.blockChannel" },
|
||||
});
|
||||
|
||||
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}).
|
||||
// Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
|
||||
const toggleHidden = useMutation({
|
||||
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
|
||||
meta: { failTitle: "errors.writeFailed.hideChannel" },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
},
|
||||
});
|
||||
|
||||
const subscribe = useMutation({
|
||||
mutationFn: () => api.subscribeChannel(channelId),
|
||||
onSuccess: () => {
|
||||
invalidateSubscription(qc, channelId);
|
||||
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
|
||||
},
|
||||
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
|
||||
// here, so no Connect button — but the user at least sees why it didn't work).
|
||||
onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")),
|
||||
});
|
||||
const unsubscribe = useMutation({
|
||||
mutationFn: () => api.unsubscribeChannel(channelId),
|
||||
onSuccess: () => invalidateSubscription(qc, channelId),
|
||||
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
|
||||
});
|
||||
// "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's
|
||||
// uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full
|
||||
// back-catalog pager. Same trigger the channel manager uses.
|
||||
const deepBackfill = useMutation({
|
||||
mutationFn: () => api.updateChannel(channelId, { deep_requested: true }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
notify({ level: "info", message: t("channel.fullHistoryQueued") });
|
||||
},
|
||||
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")),
|
||||
});
|
||||
|
||||
// Feed priority, editable here with the same stepper + debounced-flush the manager uses (shared
|
||||
// hook so the two can't drift). Optimistic on this channel's detail cache; a success re-sorts the
|
||||
// manager list + feed, a failure reverts by refetching the detail.
|
||||
const { setPriority, bumpPriority } = useChannelPriority({
|
||||
read: (id) => qc.getQueryData<ChannelDetail>(qk.channel(id))?.priority,
|
||||
optimistic: (id, next) =>
|
||||
qc.setQueryData<ChannelDetail>(qk.channel(id), (old) =>
|
||||
old ? { ...old, priority: next } : old,
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
},
|
||||
onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
|
||||
});
|
||||
|
||||
// Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic
|
||||
// on this channel's detail cache; a failed attach/detach reverts by refetching, and a success
|
||||
// invalidates the manager list so a tag added here shows there too.
|
||||
// Only fetch the tag catalog when the editor will actually render (subscribed) — an unsubscribed
|
||||
// channel page never shows the control, so the request would be thrown away.
|
||||
const tagsQuery = useTags({ enabled: !!ch?.subscribed });
|
||||
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
|
||||
const { toggleTag, createAndAttachTag } = useChannelTagActions({
|
||||
optimisticToggle: (id, tagId, attach) =>
|
||||
qc.setQueryData<ChannelDetail>(qk.channel(id), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
tag_ids: attach ? [...old.tag_ids, tagId] : old.tag_ids.filter((x) => x !== tagId),
|
||||
}
|
||||
: old,
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
|
||||
});
|
||||
|
||||
const onUnsubscribe = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channel.unsubTitle"),
|
||||
message: t("channel.unsubBody", { name: ch?.title ?? channelId }),
|
||||
confirmLabel: t("channel.unsubscribe"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) unsubscribe.mutate();
|
||||
};
|
||||
|
||||
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
|
||||
// the explored, per-user-private videos show — the backend gates them to this explorer).
|
||||
// The watch state is the one filter a channel page remembers (owner's call): a fresh page shows
|
||||
// what is still unwatched, and a change sticks — across channels, reloads and devices.
|
||||
const [show, setShow] = usePref<string>("channelShow", "unwatched", {
|
||||
localKey: LS.channelShow,
|
||||
parse: (v) => (typeof v === "string" && SHOW_IDS.includes(v) ? v : "unwatched"),
|
||||
});
|
||||
const [ownFilters, setOwnFilters] = useState<FeedFilters>(() => ({
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "all",
|
||||
librarySource: "all",
|
||||
// Content-type prefs inherited from the global feed (seeded once) — no longer forced.
|
||||
includeNormal: globalFilters.includeNormal,
|
||||
includeShorts: globalFilters.includeShorts,
|
||||
includeLive: globalFilters.includeLive,
|
||||
show: "unwatched",
|
||||
channelIds: [channelId],
|
||||
}));
|
||||
const filters: FeedFilters = { ...ownFilters, show };
|
||||
const setFilters = (next: FeedFilters) => {
|
||||
if (next.show !== show) setShow(next.show);
|
||||
setOwnFilters(next);
|
||||
};
|
||||
|
||||
const name = ch?.title ?? initialName ?? channelId;
|
||||
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
|
||||
const joined = ch?.published_at ? formatDate(ch.published_at, i18n.language) : null;
|
||||
// Handle + stats on one compact meta line under the name (saves the separate stats row).
|
||||
const metaParts = [
|
||||
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
|
||||
ch?.subscriber_count != null
|
||||
? t("channel.subscribers", { formatted: formatCompact(ch.subscriber_count, lang) })
|
||||
: null,
|
||||
// Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the
|
||||
// gap between the browsable videos and YouTube's raw count is honest rather than looking like
|
||||
// missing data (the "get full history" button pulls the rest).
|
||||
ch?.video_count != null
|
||||
? ch.subscribed && !ch.backfill_done && ch.stored_videos < ch.video_count
|
||||
? t("channel.videoCountStored", { stored: ch.stored_videos, total: ch.video_count })
|
||||
: t("channel.videoCount", { count: ch.video_count })
|
||||
: null,
|
||||
ch?.total_view_count != null
|
||||
? t("channel.totalViews", { formatted: formatCompact(ch.total_view_count, lang) })
|
||||
: null,
|
||||
// Per-type breakdown (moved here off the manager table's old N/S/L column). Only meaningful
|
||||
// once we actually hold some of the channel's videos.
|
||||
ch && ch.stored_videos > 0
|
||||
? t("channel.videoTypes", {
|
||||
normal: ch.count_normal,
|
||||
short: ch.count_short,
|
||||
live: ch.count_live,
|
||||
})
|
||||
: null,
|
||||
joined ? t("channel.joined", { date: joined }) : null,
|
||||
].filter(Boolean) as string[];
|
||||
// The page's name and its state, shown in the identity block or, folded, in the tab row.
|
||||
const heading = (
|
||||
<PageHeading
|
||||
data-testid="channel-title"
|
||||
className={`${folded ? "text-base" : "text-xl"} font-semibold truncate`}
|
||||
>
|
||||
{name}
|
||||
</PageHeading>
|
||||
);
|
||||
const stateBadge = ch?.blocked ? (
|
||||
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
||||
{t("channel.blockedBadge")}
|
||||
</span>
|
||||
) : (
|
||||
ch?.explored &&
|
||||
!ch?.subscribed && (
|
||||
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-warn/15 text-warn">
|
||||
{t("channel.exploringBadge")}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
|
||||
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
|
||||
// tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile.
|
||||
const bannerSrc = ch?.banner_url ? `${ch.banner_url}=w1707` : null;
|
||||
return (
|
||||
<>
|
||||
<PageToolbar>
|
||||
<div className="relative">
|
||||
{!folded && (
|
||||
<div ref={foldable}>
|
||||
{/* Banner — OPTION C: inset, rounded card (not full-bleed) */}
|
||||
<div className="px-4 sm:px-6 pt-3">
|
||||
{/* Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
|
||||
low default size, so request a crisp wide version (=w1707) and object-cover the
|
||||
desktop "safe area" — the centre 2560×423 (~6:1) band. With no banner (or a dead one)
|
||||
the bar keeps the SAME height, so the overlapping avatar and the Back button keep
|
||||
their room (a short bar let the -mt-8 avatar ride up into the Back button). */}
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-2xl bg-surface"
|
||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||
>
|
||||
<RemoteImage
|
||||
src={bannerSrc}
|
||||
alt=""
|
||||
className="w-full h-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-6">
|
||||
{/* Identity. relative+z-base so the avatar that overlaps up into the banner paints
|
||||
ABOVE it (the banner's positioned container would otherwise cover it). */}
|
||||
<div className="relative z-base flex items-end gap-4 -mt-8">
|
||||
<Avatar
|
||||
src={ch?.thumbnail_url ?? null}
|
||||
fallback={name}
|
||||
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{heading}
|
||||
{stateBadge}
|
||||
</div>
|
||||
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
||||
{metaParts.map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="mr-1.5">·</span>}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 sm:px-6">
|
||||
{exploring && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted mt-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t("channel.loadingVideos")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
|
||||
corner: there are only ever two tabs, so there's room, and it keeps the things you click
|
||||
within a few pixels of each other instead of across the page. */}
|
||||
{/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and
|
||||
the whole page scrolled sideways. The tag lane takes what is left and folds into "+N". */}
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-2 border-b border-border pb-2 ${folded ? "pt-3" : "mt-3"}`}
|
||||
>
|
||||
{/* One Back button in both states, so a focused Back keeps its focus when the header folds:
|
||||
over the banner's corner (absolute to the chrome) while unfolded, first in this row folded. */}
|
||||
<Button
|
||||
variant={folded ? "outline" : "media"}
|
||||
data-testid="channel-back"
|
||||
icon={<ArrowLeft />}
|
||||
onClick={closeChannel}
|
||||
className={folded ? "" : "absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"}
|
||||
>
|
||||
{t("channel.back")}
|
||||
</Button>
|
||||
{folded && (
|
||||
<div
|
||||
className="flex min-w-0 max-w-[40%] items-center gap-2"
|
||||
data-testid="channel-folded"
|
||||
>
|
||||
<Avatar
|
||||
src={ch?.thumbnail_url ?? null}
|
||||
fallback={name}
|
||||
className="w-8 h-8 rounded-full shrink-0"
|
||||
/>
|
||||
{heading}
|
||||
{stateBadge}
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
className="flex gap-1"
|
||||
active={tab}
|
||||
onChange={(id) => setTab(id as "videos" | "about")}
|
||||
tabs={[
|
||||
{ id: "videos", label: t("channel.tabVideos") },
|
||||
{ id: "about", label: t("channel.tabAbout") },
|
||||
]}
|
||||
/>
|
||||
{/* Unfolded, the actions share the tabs' line and the tag lane gives way ("+N"). Folded, Back
|
||||
and the name take that room, so at a tablet width the actions wrap onto a line of their
|
||||
own rather than push the page sideways (min-w-0 let them overflow: 1072 px at 1024). */}
|
||||
<div className={`ml-2 flex flex-1 items-center gap-2 ${folded ? "" : "min-w-0"}`}>
|
||||
<IconLink
|
||||
href={ytUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
label={t("channels.row.openOnYouTube")}
|
||||
size="md"
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
{ch?.subscribed && (
|
||||
<IconButton
|
||||
label={t("channel.showInFeed")}
|
||||
size="md"
|
||||
variant="outline"
|
||||
onClick={onShowInFeed}
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
{ch?.subscribed && (
|
||||
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
|
||||
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
|
||||
<PriorityStepper
|
||||
priority={ch.priority}
|
||||
onPriority={(d) => bumpPriority(channelId, d)}
|
||||
onSet={(v) => setPriority(channelId, v)}
|
||||
size="md"
|
||||
hint={t("channels.row.priorityHint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Only the recent subset is stored → offer to pull the channel's full back-catalogue.
|
||||
Once queued (deep_requested) it shows as loading; hidden once the backfill is done.
|
||||
While loading, the label carries the live stored/total count (the detail poll refreshes
|
||||
it) so a large channel's paced tail reads as visible progress rather than a stuck
|
||||
spinner. */}
|
||||
{ch?.subscribed && !ch.backfill_done && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
||||
disabled={ch.deep_requested || deepBackfill.isPending}
|
||||
loading={ch.deep_requested}
|
||||
icon={<History className="w-4 h-4" />}
|
||||
title={
|
||||
ch.deep_requested
|
||||
? t("channel.fullHistoryLoadingHint")
|
||||
: t("channel.fullHistory")
|
||||
}
|
||||
>
|
||||
<span className="hidden sm:inline whitespace-nowrap">
|
||||
{!ch.deep_requested
|
||||
? t("channel.fullHistory")
|
||||
: ch.video_count != null && ch.stored_videos < ch.video_count
|
||||
? t("channel.fullHistoryProgress", {
|
||||
stored: ch.stored_videos,
|
||||
total: ch.video_count,
|
||||
})
|
||||
: t("channel.fullHistoryLoading")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
|
||||
// light up on hover, as if a click would do something.
|
||||
size="md"
|
||||
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
|
||||
onClick={() => block.mutate()}
|
||||
loading={block.isPending}
|
||||
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
>
|
||||
<Ban />
|
||||
</IconButton>
|
||||
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
|
||||
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
|
||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||
{ch?.subscribed && (
|
||||
<IconButton
|
||||
size="md"
|
||||
variant={ch.hidden ? "outline-accent" : "outline"}
|
||||
onClick={() => toggleHidden.mutate()}
|
||||
loading={toggleHidden.isPending}
|
||||
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
||||
>
|
||||
{ch.hidden ? <EyeOff /> : <Eye />}
|
||||
</IconButton>
|
||||
)}
|
||||
{!ch?.blocked &&
|
||||
(ch?.subscribed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUnsubscribe}
|
||||
loading={unsubscribe.isPending}
|
||||
title={t("channel.unsubscribe")}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
{t("channel.subscribedState")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => subscribe.mutate()}
|
||||
loading={subscribe.isPending}
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
{t("channel.subscribe")}
|
||||
</Button>
|
||||
))}
|
||||
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
|
||||
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
||||
channel you actually follow. */}
|
||||
{ch?.subscribed && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChannelTagsControl
|
||||
userTags={userTags}
|
||||
attachedIds={ch.tag_ids}
|
||||
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
|
||||
onCreate={(name) => createAndAttachTag(channelId, name)}
|
||||
// The header row's leftover width: tags inline, "+N" only when they do not fit.
|
||||
bounded={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
|
||||
just under the tabs. */}
|
||||
<div ref={setChromeSlot} />
|
||||
</PageToolbar>
|
||||
|
||||
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
|
||||
only the grid (and About) render here, in the scroller. */}
|
||||
<PageToolbarSlot.Provider value={chromeSlot}>
|
||||
<div {...panel.panelProps(tab)}>
|
||||
{tab === "videos" ? (
|
||||
<>
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
channelScoped
|
||||
/>
|
||||
{!ch?.subscribed && nextToken && (
|
||||
<div className="flex justify-center pb-6">
|
||||
<Button
|
||||
// Busy blocks the click, not the focus (see Block above).
|
||||
variant="outline"
|
||||
icon={<RefreshCw />}
|
||||
loading={exploring}
|
||||
onClick={() => runExplore(nextToken)}
|
||||
>
|
||||
{t("channel.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbarSlot.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The owner's call: a freshly opened channel page shows what is still unwatched, and a watch state the
|
||||
// viewer picks there sticks — a reload (a fresh mount, seeded from the stored preference) keeps it.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { FeedFilters } from "../../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
const feed = vi.hoisted(() => ({
|
||||
last: null as null | { filters: FeedFilters; setFilters: (f: FeedFilters) => void },
|
||||
}));
|
||||
vi.mock("./Feed", () => ({
|
||||
default: (p: { filters: FeedFilters; setFilters: (f: FeedFilters) => void }) => {
|
||||
feed.last = p;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type Me } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
feed.last = null;
|
||||
});
|
||||
|
||||
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
|
||||
|
||||
function mount(preferences: Record<string, unknown> = {}) {
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||
id: CH,
|
||||
title: "StarTalk",
|
||||
subscribed: true,
|
||||
stored_videos: 10,
|
||||
} as unknown as ChannelDetail);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const save = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, preferences } as unknown as Me);
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="StarTalk"
|
||||
me={{ id: 1, can_write: true, can_read: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return save;
|
||||
}
|
||||
|
||||
describe("ChannelPage — the watch state", () => {
|
||||
it("shows only the unwatched videos on a freshly opened page", () => {
|
||||
mount();
|
||||
expect(feed.last?.filters.show).toBe("unwatched");
|
||||
});
|
||||
|
||||
it("stores a picked watch state", () => {
|
||||
const save = mount();
|
||||
act(() => feed.last!.setFilters({ ...feed.last!.filters, show: "all" }));
|
||||
expect(feed.last?.filters.show).toBe("all");
|
||||
expect(save).toHaveBeenCalledWith({ channelShow: "all" });
|
||||
});
|
||||
|
||||
it("keeps the stored watch state after a reload", () => {
|
||||
mount({ channelShow: "watched" });
|
||||
expect(feed.last?.filters.show).toBe("watched");
|
||||
});
|
||||
|
||||
it("falls back to unwatched for a stored value it does not know", () => {
|
||||
mount({ channelShow: "bogus" });
|
||||
expect(feed.last?.filters.show).toBe("unwatched");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FeedFilters } from "../../lib/api";
|
||||
import { useChannelNames } from "../../lib/useChannelNames";
|
||||
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
|
||||
|
||||
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
|
||||
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
|
||||
// the panel had a remove button for something nothing could apply.
|
||||
//
|
||||
// It owns its channel query rather than letting the Sidebar hold it: the Sidebar is mounted for the
|
||||
// whole feed page, but SidePanel only renders its children once the panel is open, so keeping the
|
||||
// fetch here means a subscription list of a few hundred loads when the picker is actually looked at.
|
||||
export default function ChannelPicker({
|
||||
filters,
|
||||
setFilters,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { query: channelsQuery, names, pending } = useChannelNames();
|
||||
|
||||
const all = channelsQuery.data ?? [];
|
||||
// The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the
|
||||
// options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later
|
||||
// narrowing ever drops it from the visible rows, and names a reloaded filter before the list lands.
|
||||
const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id }));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.channelSearch")}
|
||||
placeholder={t("sidebar.channelSearch")}
|
||||
options={options}
|
||||
selected={filters.channelIds}
|
||||
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
|
||||
loading={channelsQuery.isLoading}
|
||||
error={channelsQuery.isError && !channelsQuery.data ? t("common.loadError") : undefined}
|
||||
onRetry={() => void channelsQuery.refetch()}
|
||||
emptyText={t("sidebar.noChannelMatch")}
|
||||
selectedLabels={names}
|
||||
labelsPending={pending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A native date field reports a complete, valid date on every keystroke of its year, so
|
||||
// typing 2026 applied 0002, 0020, 0202 and 2026 — four feed refetches for one year.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import DateFilterSelect from "./DateFilterSelect";
|
||||
import { type FeedFilters } from "../../lib/api";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function mount(filters: Partial<FeedFilters>) {
|
||||
const setFilters = vi.fn();
|
||||
render(
|
||||
<DateFilterSelect
|
||||
filters={filters as FeedFilters}
|
||||
setFilters={setFilters}
|
||||
presets={[{ days: 7, key: "1week" }]}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /sidebar\.datePresetLabel/ }));
|
||||
const [from] = document.querySelectorAll<HTMLInputElement>('input[type="date"]');
|
||||
return { setFilters, from: from! };
|
||||
}
|
||||
|
||||
describe("a custom range bound", () => {
|
||||
it("is applied once, when the typed year becomes a plausible upload year", () => {
|
||||
const { setFilters, from } = mount({ dateTo: "2026-09-01" });
|
||||
for (const day of ["0002-01-15", "0020-01-15", "0202-01-15", "2026-01-15"]) {
|
||||
fireEvent.change(from, { target: { value: day } });
|
||||
}
|
||||
expect(setFilters).toHaveBeenCalledTimes(1);
|
||||
expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: "2026-01-15" });
|
||||
});
|
||||
|
||||
it("is cleared when the field is left empty, and kept when it is left half-typed", () => {
|
||||
const { setFilters, from } = mount({ dateFrom: "2026-01-15" });
|
||||
fireEvent.change(from, { target: { value: "0202-01-15" } });
|
||||
fireEvent.blur(from);
|
||||
expect(setFilters).not.toHaveBeenCalled();
|
||||
expect(from.value).toBe("2026-01-15");
|
||||
|
||||
fireEvent.change(from, { target: { value: "" } });
|
||||
expect(setFilters).not.toHaveBeenCalled();
|
||||
fireEvent.blur(from);
|
||||
expect(setFilters).toHaveBeenCalledTimes(1);
|
||||
expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { MenuCheck } from "../../components/ui/MenuCheck";
|
||||
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { type FeedFilters } from "../../lib/api";
|
||||
import {
|
||||
deriveDateKey,
|
||||
isCommittableDay,
|
||||
selectDateChip,
|
||||
type DatePreset,
|
||||
} from "../../lib/dateFilter";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { SelectTrigger } from "../../components/ui/SelectTrigger";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
|
||||
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
|
||||
// reveals the from/to fields in the popover footer (reveal-on-select) and the trigger then reads the
|
||||
// chosen range; the presets and Any time close on pick. Selection logic is the pure lib/dateFilter so
|
||||
// it stays testable without mounting this. The popover hosts free-text (date) inputs, so it opens as a
|
||||
// `hostsInput` menu and the inputs stop non-Escape keys from reaching the list navigation.
|
||||
|
||||
// yyyy-mm-dd -> a local Date at midnight (split so it isn't parsed as UTC, which would shift a day).
|
||||
function parseDay(s: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
}
|
||||
|
||||
export default function DateFilterSelect({
|
||||
filters,
|
||||
setFilters,
|
||||
presets,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
presets: readonly DatePreset[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [customOpen, setCustomOpen] = useState<boolean>(!!(filters.dateFrom || filters.dateTo));
|
||||
|
||||
// If the range is cleared elsewhere (chip-row Clear all / removing the date chip) while this popover
|
||||
// is closed, drop the custom reveal so the trigger reverts to "Any time" instead of lingering on
|
||||
// "Custom range" with empty fields. Guarded on !open so it never closes a freshly-picked Custom the
|
||||
// user hasn't filled in yet — that stays open until they close it (handled by onOpenChange below).
|
||||
// useLayoutEffect (not useEffect) so the reset commits BEFORE paint — otherwise the trigger flashes
|
||||
// one stale "Custom range" frame between the external clear and this reset.
|
||||
useLayoutEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the reveal is NOT derivable from the range — the user opens it on an empty range and fills it in afterwards. This undoes it only when the range is cleared from OUTSIDE while the popover is shut, and it is a layout effect so the trigger never paints a stale "Custom range".
|
||||
if (!open && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
|
||||
}, [open, customOpen, filters.dateFrom, filters.dateTo]);
|
||||
|
||||
const key = deriveDateKey(filters, customOpen, presets);
|
||||
|
||||
const fmt = (s: string) => {
|
||||
return parseDay(s) ? formatDate(s, i18n.language) : s;
|
||||
};
|
||||
const rangeSummary = () => {
|
||||
const { dateFrom: f, dateTo: to } = filters;
|
||||
if (f && to) return `${fmt(f)} – ${fmt(to)}`;
|
||||
if (f) return t("sidebar.date.fromShort", { date: fmt(f) });
|
||||
if (to) return t("sidebar.date.untilShort", { date: fmt(to) });
|
||||
return t("sidebar.date.customRange");
|
||||
};
|
||||
const summary =
|
||||
key === ""
|
||||
? t("sidebar.date.anyTime")
|
||||
: key === "custom"
|
||||
? rangeSummary()
|
||||
: t("sidebar.datePreset." + presets.find((p) => String(p.days) === key)!.key);
|
||||
|
||||
const options = [
|
||||
{ value: "any", label: t("sidebar.date.anyTime") },
|
||||
...presets.map((p) => ({ value: String(p.days), label: t("sidebar.datePreset." + p.key) })),
|
||||
{ value: "custom", label: t("sidebar.date.customRange") },
|
||||
];
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: (next) => {
|
||||
setOpen(next);
|
||||
// Opened Custom but committed no range → drop the reveal on close so it doesn't stick.
|
||||
if (!next && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
|
||||
},
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
hostsInput: true,
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
const pick = (value: string) => {
|
||||
const change = selectDateChip(value, filters);
|
||||
setCustomOpen(change.customOpen);
|
||||
setFilters(change.filters);
|
||||
if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectTrigger
|
||||
context="form"
|
||||
open={open}
|
||||
icon={<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
|
||||
>
|
||||
{summary}
|
||||
</SelectTrigger>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("sidebar.datePresetLabel")}
|
||||
className={`${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
|
||||
{options.map((o) => {
|
||||
const on = o.value === key || (o.value === "any" && key === "");
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => pick(o.value)}
|
||||
label={o.label}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<MenuCheck kind="one" on={on} />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. */}
|
||||
{key === "custom" && (
|
||||
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{t("sidebar.from")}</span>
|
||||
<DayField
|
||||
value={filters.dateFrom}
|
||||
onCommit={(v) =>
|
||||
setFilters({ ...filters, dateFrom: v, maxAgeDays: undefined, year: undefined })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{t("sidebar.to")}</span>
|
||||
<DayField
|
||||
value={filters.dateTo}
|
||||
onCommit={(v) =>
|
||||
setFilters({ ...filters, dateTo: v, maxAgeDays: undefined, year: undefined })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{(filters.dateFrom || filters.dateTo) && (
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
|
||||
className="text-2xs self-end"
|
||||
>
|
||||
{t("sidebar.clearDates")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// One bound of the custom range. What the field shows is a draft while it has focus (`draft ?? value`):
|
||||
// the bound is applied only once the draft is a plausible day (`isCommittableDay` says why), so typing a
|
||||
// year no longer refetches the feed per digit; a date picked from the calendar applies at once. Leaving
|
||||
// the field settles it: emptied, the bound is cleared; half-typed or implausible, it shows the bound again.
|
||||
// It stops non-Escape keys so the date-segment arrows act on the field, not the menu's list navigation.
|
||||
function DayField({
|
||||
value,
|
||||
onCommit,
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onCommit: (day: string | undefined) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string>();
|
||||
return (
|
||||
<Input
|
||||
size="sm"
|
||||
type="date"
|
||||
value={draft ?? value ?? ""}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const day = e.target.value;
|
||||
setDraft(day);
|
||||
if (isCommittableDay(day) && day !== value) onCommit(day);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (draft === "" && value) onCommit(undefined);
|
||||
setDraft(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The count pill while a new search's count loads. keepPreviousData holds the last filter's count, and a
|
||||
// broad word's count takes seconds ("a": ~3 s on the prod-sized library): the pill showed the old
|
||||
// number as if it were the new search's. It now dims and is aria-busy until the new count lands.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/wizard/wizardContext", () => ({
|
||||
useWizard: () => ({ openWizard: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./VirtualFeed", () => ({ default: () => null }));
|
||||
vi.mock("../../features/filters/SelectedViewChip", () => ({ default: () => null }));
|
||||
vi.mock("../../features/filters/ActiveFilterChips", () => ({ default: () => null }));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import type { ReactElement } from "react";
|
||||
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
// The pill lives in the toolbar, which portals into the shell's band: give it one.
|
||||
const slot = document.body.appendChild(document.createElement("div"));
|
||||
const feed = (q: string, qc: QueryClient): ReactElement => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<PageToolbarSlot.Provider value={slot}>
|
||||
<Feed
|
||||
filters={{ ...FILTERS, q }}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch=""
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</PageToolbarSlot.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe("Feed — the count while a new search's count loads", () => {
|
||||
it("dims the previous count and marks it busy until the new one lands", async () => {
|
||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
|
||||
let settle: (v: unknown) => void = () => {};
|
||||
const summary = vi
|
||||
.spyOn(api, "feedSummary")
|
||||
.mockImplementation(((f: FeedFilters) =>
|
||||
f.q === "kitchen"
|
||||
? Promise.resolve({ count: 1937, facets: {} })
|
||||
: new Promise((r) => (settle = r))) as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
const pill = () => screen.getByTestId("item-count");
|
||||
|
||||
const view = render(feed("kitchen", qc));
|
||||
await waitFor(() => expect(pill().getAttribute("title")).toBe("1,937"));
|
||||
expect(pill().getAttribute("aria-busy")).toBeNull();
|
||||
|
||||
view.rerender(feed("a", qc));
|
||||
await waitFor(() => expect(pill().getAttribute("aria-busy")).toBe("true"));
|
||||
expect(pill().getAttribute("title")).toBe("1,937"); // still the old figure, now marked as such
|
||||
|
||||
// The pill turns busy in the render that changes the search, and the count is asked for in an
|
||||
// effect after it: answer only once it has been asked, or the answer goes to no one.
|
||||
await waitFor(() => expect(summary).toHaveBeenCalledWith(expect.objectContaining({ q: "a" })));
|
||||
settle({ count: 178991, facets: {} });
|
||||
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
|
||||
expect(pill().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
|
||||
it("says the count failed instead of showing the empty box of a first load", async () => {
|
||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
|
||||
vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) =>
|
||||
f.q === "kitchen"
|
||||
? Promise.resolve({ count: 1937, facets: {} })
|
||||
: Promise.reject(new Error("boom"))) as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
const pill = () => screen.getByTestId("item-count");
|
||||
|
||||
const view = render(feed("kitchen", qc));
|
||||
await waitFor(() => expect(pill().getAttribute("title")).toBe("1,937"));
|
||||
view.rerender(feed("a", qc));
|
||||
await waitFor(() => expect(pill().getAttribute("title")).toBe("common.countFailed"));
|
||||
expect(pill().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A next page that fails. Retry is off and the read-ahead re-runs whenever a fetch settles, so a failed
|
||||
// page was requested again once a round-trip, forever, while the end of the list was in view, and
|
||||
// nothing said it had failed. The read-ahead now stops at a failed page, and a row says
|
||||
// so and retries on a click. VirtualFeed is replaced by what Feed hands it: the read-ahead's gate and a
|
||||
// button standing in for the read-ahead itself.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/wizard/wizardContext", () => ({
|
||||
useWizard: () => ({ openWizard: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
}: {
|
||||
hasNextPage: boolean;
|
||||
fetchNextPage: (o: { cancelRefetch: boolean }) => unknown;
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="has-next">{String(hasNextPage)}</span>
|
||||
<button onClick={() => void fetchNextPage({ cancelRefetch: false })}>read ahead</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../../features/filters/SelectedViewChip", () => ({ default: () => null }));
|
||||
vi.mock("../../features/filters/ActiveFilterChips", () => ({ default: () => null }));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
describe("Feed — a next page that fails", () => {
|
||||
it("stops the read-ahead, says so, and loads the page on a retry", async () => {
|
||||
const page = (n: number, next: string | null) => ({
|
||||
items: [
|
||||
{ id: `v${n}`, title: `v${n}`, channel_id: "UCx", published_at: "2026-01-01T00:00:00Z" },
|
||||
],
|
||||
next_cursor: next,
|
||||
});
|
||||
let failNext = true;
|
||||
const feed = vi
|
||||
.spyOn(api, "feed")
|
||||
.mockImplementation(((_f: FeedFilters, cursor: string | null) =>
|
||||
cursor === null
|
||||
? Promise.resolve(page(1, "c2"))
|
||||
: failNext
|
||||
? Promise.reject(new Error("boom"))
|
||||
: Promise.resolve(page(2, null))) as never);
|
||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 2, facets: {} } as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch=""
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("has-next").textContent).toBe("true"));
|
||||
|
||||
fireEvent.click(screen.getByText("read ahead"));
|
||||
await waitFor(() => expect(screen.getByText("common.loadMoreFailed")).toBeTruthy());
|
||||
expect(screen.getByTestId("has-next").textContent).toBe("false"); // the read-ahead stands down
|
||||
const calls = feed.mock.calls.length;
|
||||
|
||||
failNext = false;
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBe(calls + 1));
|
||||
await waitFor(() => expect(screen.queryByText("common.loadMoreFailed")).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The feed's optimistic layer: a card shows what was just written to it until the server's own answer
|
||||
// lands. Which answers count is the rule pinned here: a refetch that LANDS (for what was written
|
||||
// before it set out), a filter change; never an append of the next page. A mark made while a refetch
|
||||
// was in flight is newer than its answer, and dropping it put a card marked watched back into an
|
||||
// Unwatched view. The grid is a stub that shows what Feed hands it.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/wizard/wizardContext", () => ({
|
||||
useWizard: () => ({ openWizard: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({
|
||||
items,
|
||||
onState,
|
||||
fetchNextPage,
|
||||
}: {
|
||||
items: { id: string; status: string }[];
|
||||
onState: (id: string, status: string) => void;
|
||||
fetchNextPage: (o: object) => unknown;
|
||||
}) => (
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>
|
||||
<span>{`${v.id}:${v.status}`}</span>
|
||||
<button onClick={() => onState(v.id, "watched")}>watch {v.id}</button>
|
||||
<button onClick={() => onState(v.id, "hidden")}>hide {v.id}</button>
|
||||
</li>
|
||||
))}
|
||||
<button onClick={() => void fetchNextPage({})}>more</button>
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "all",
|
||||
};
|
||||
|
||||
const video = (id: string) =>
|
||||
({
|
||||
id,
|
||||
title: id,
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
duration_seconds: 600,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
|
||||
// The server never takes the mark in these tests: whatever a list shows as "watched" is the override.
|
||||
const page = (ids: string[], next: string | null = null) =>
|
||||
({ items: ids.map(video), next_cursor: next }) as never;
|
||||
|
||||
function mount(feed: ReturnType<typeof vi.fn>, setState?: () => Promise<unknown>) {
|
||||
vi.spyOn(api, "feed").mockImplementation(feed as never);
|
||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||
if (setState) vi.spyOn(api, "setState").mockImplementation(setState as never);
|
||||
else vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
const ui = (filters: FeedFilters) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const r = render(ui(FILTERS));
|
||||
return { qc, rerender: (f: FeedFilters) => r.rerender(ui(f)) };
|
||||
}
|
||||
|
||||
const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
|
||||
describe("Feed — optimistic overrides", () => {
|
||||
it("give way to a refetch that lands after them", async () => {
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
const feed = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page(["v1"]))
|
||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||
const { qc } = mount(feed);
|
||||
fireEvent.click(await screen.findByText("watch v1"));
|
||||
await screen.findByText("v1:watched");
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||
await settle();
|
||||
await act(async () => answer(page(["v1"])));
|
||||
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("keep a mark made while the refetch was in flight", async () => {
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
const feed = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page(["v1"]))
|
||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||
const { qc } = mount(feed);
|
||||
await screen.findByText("v1:new");
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||
await settle();
|
||||
fireEvent.click(screen.getByText("watch v1"));
|
||||
await screen.findByText("v1:watched");
|
||||
// The answer the server gave before it heard of the mark.
|
||||
await act(async () => answer(page(["v1"])));
|
||||
await settle();
|
||||
expect(screen.getByText("v1:watched")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keep a mark sent before the refetch set out but not answered by then", async () => {
|
||||
// The server may read the list before it commits the mark: that answer is older than the mark.
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
let commit: (v: unknown) => void = () => {};
|
||||
const feed = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page(["v1"]))
|
||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||
const { qc } = mount(feed, () => new Promise((r) => (commit = r)));
|
||||
fireEvent.click(await screen.findByText("watch v1"));
|
||||
await screen.findByText("v1:watched");
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||
await settle();
|
||||
await act(async () => answer(page(["v1"])));
|
||||
await settle();
|
||||
expect(screen.getByText("v1:watched")).toBeTruthy();
|
||||
await act(async () => commit({ ok: true }));
|
||||
});
|
||||
|
||||
it("are taken back by a failed write only while no later write stands", async () => {
|
||||
// Marked watched, then hidden before the first answer came back: the refused mark must not take
|
||||
// the hide with it and put the card back in the view.
|
||||
const writes: { resolve: (v: unknown) => void; reject: (e: unknown) => void }[] = [];
|
||||
mount(
|
||||
vi.fn().mockResolvedValue(page(["v1", "v2"])),
|
||||
() => new Promise((resolve, reject) => writes.push({ resolve, reject })),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("watch v1"));
|
||||
fireEvent.click(await screen.findByText("hide v1"));
|
||||
await waitFor(() => expect(screen.queryByText(/^v1:/)).toBeNull());
|
||||
await act(async () => writes[0]?.reject(new Error("refused")));
|
||||
await settle();
|
||||
expect(screen.queryByText(/^v1:/)).toBeNull();
|
||||
await act(async () => writes[1]?.reject(new Error("refused")));
|
||||
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("survive an append of the next page", async () => {
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
const feed = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page(["v1"], "c1"))
|
||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||
mount(feed);
|
||||
fireEvent.click(await screen.findByText("watch v1"));
|
||||
await screen.findByText("v1:watched");
|
||||
fireEvent.click(screen.getByText("more"));
|
||||
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||
await settle();
|
||||
await act(async () => answer(page(["v2"])));
|
||||
await screen.findByText("v2:new");
|
||||
await settle();
|
||||
expect(screen.getByText("v1:watched")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("are dropped by a change of the filters", async () => {
|
||||
const { rerender } = mount(vi.fn().mockResolvedValue(page(["v1"])));
|
||||
fireEvent.click(await screen.findByText("watch v1"));
|
||||
await screen.findByText("v1:watched");
|
||||
rerender({ ...FILTERS });
|
||||
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A video marked watched in the player (by hand, or by the player at its end) left an Unwatched view
|
||||
// at once, then came back as "new" when the player closed, until a reload (owner, on a channel page).
|
||||
// The player's closing position patch writes into the cached feed, which bumped `dataUpdatedAt`; the
|
||||
// feed took that for fresh server data and dropped its optimistic overrides, and nothing refetched.
|
||||
// The grid and the player are stubs that show what Feed hands them.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/wizard/wizardContext", () => ({
|
||||
useWizard: () => ({ openWizard: vi.fn() }),
|
||||
}));
|
||||
// What the grid was handed for its card menu's channel-filter row.
|
||||
const grid: { channelFilter?: { ids: string[]; set: (ids: string[]) => void } } = {};
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({
|
||||
items,
|
||||
onOpen,
|
||||
channelFilter,
|
||||
}: {
|
||||
items: { id: string }[];
|
||||
onOpen: (v: unknown) => void;
|
||||
channelFilter?: { ids: string[]; set: (ids: string[]) => void };
|
||||
}) => (
|
||||
(grid.channelFilter = channelFilter),
|
||||
(
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>
|
||||
{v.id}
|
||||
<button onClick={() => onOpen(v)}>open {v.id}</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
),
|
||||
}));
|
||||
// The player's progress hook marks a clip closed near its end watched from its unmount cleanup:
|
||||
// `watchOnUnmount` stands in for that, and `flush` for its closing checkpoint, which it tracks.
|
||||
const player = vi.hoisted(() => ({
|
||||
watchOnUnmount: false,
|
||||
flush: null as Promise<unknown> | null,
|
||||
}));
|
||||
vi.mock("../../features/player/PlayerModal", async () => {
|
||||
const { useEffect } = await import("react");
|
||||
const { trackProgressFlush } = await import("../../lib/progressCache");
|
||||
return {
|
||||
default: function Player({
|
||||
video,
|
||||
onState,
|
||||
onClose,
|
||||
}: {
|
||||
video: { id: string };
|
||||
onState: (id: string, status: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (player.watchOnUnmount) onState(video.id, "watched");
|
||||
if (player.flush) trackProgressFlush(player.flush);
|
||||
},
|
||||
[video.id, onState],
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => onState(video.id, "watched")}>mark</button>
|
||||
<button onClick={onClose}>close</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { patchCachedProgress } from "../../lib/progressCache";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
player.watchOnUnmount = false;
|
||||
player.flush = null;
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: ["UCx"],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
const video = (status: string, position = 0) =>
|
||||
({
|
||||
id: "v1",
|
||||
title: "v1",
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status,
|
||||
position_seconds: position,
|
||||
duration_seconds: 600,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: "https://www.youtube.com/watch?v=v1",
|
||||
}) as unknown as Video;
|
||||
|
||||
function mount({
|
||||
channelScoped = true,
|
||||
setFilters = () => {},
|
||||
}: { channelScoped?: boolean; setFilters?: (f: FeedFilters) => void } = {}) {
|
||||
const feed = vi
|
||||
.spyOn(api, "feed")
|
||||
.mockResolvedValueOnce({ items: [video("new")], next_cursor: null } as never)
|
||||
// The server after the mark: watched, so an Unwatched view leaves it out.
|
||||
.mockResolvedValue({ items: [], next_cursor: null } as never);
|
||||
const summary = vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={setFilters}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
channelScoped={channelScoped}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { qc, feed, summary };
|
||||
}
|
||||
|
||||
describe("Feed — a video marked watched in the player", () => {
|
||||
it("stays out of an Unwatched view when the player writes its position into the cache", async () => {
|
||||
const { qc } = mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("mark"));
|
||||
await waitFor(() => expect(screen.queryByText("open v1")).toBeNull());
|
||||
// What the player does as it closes: the server's stored position, written into the cached list.
|
||||
act(() => patchCachedProgress(qc, new Map([["v1", 10]])));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByText("open v1")).toBeNull();
|
||||
});
|
||||
|
||||
it("refetches the list and its count by the view's filters when the player closes", async () => {
|
||||
const { feed, summary } = mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("mark"));
|
||||
const before = { feed: feed.mock.calls.length, summary: summary.mock.calls.length };
|
||||
fireEvent.click(screen.getByText("close"));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before.feed));
|
||||
await waitFor(() => expect(summary.mock.calls.length).toBeGreaterThan(before.summary));
|
||||
// The refetch is the channel's Unwatched list, and the server's answer keeps the card out.
|
||||
expect(feed.mock.calls[feed.mock.calls.length - 1]?.[0]).toMatchObject({
|
||||
channelIds: ["UCx"],
|
||||
show: "unwatched",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByText("open v1")).toBeNull();
|
||||
});
|
||||
|
||||
it("refetches when the player marks the clip watched as it closes", async () => {
|
||||
const { feed } = mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
player.watchOnUnmount = true;
|
||||
const before = feed.mock.calls.length;
|
||||
fireEvent.click(await screen.findByText("close"));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||
});
|
||||
|
||||
it("waits for the session's writes to land before it refetches", async () => {
|
||||
const { feed } = mount();
|
||||
let land: (v: unknown) => void = () => {};
|
||||
vi.mocked(api.setState).mockReturnValue(new Promise((r) => (land = r)) as never);
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("mark"));
|
||||
const before = feed.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("close"));
|
||||
await act(() => new Promise((r) => setTimeout(r, 30)));
|
||||
// A refetch now would ask a server that has not taken the mark yet.
|
||||
expect(feed.mock.calls.length).toBe(before);
|
||||
await act(async () => land({ ok: true }));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||
});
|
||||
|
||||
it("waits for the player's closing checkpoint before it refetches", async () => {
|
||||
// Refetched before that write landed, the list read the older position and, landing after the
|
||||
// player's patch, put it back: a reopen resumed early.
|
||||
const { feed } = mount();
|
||||
let land: (v: unknown) => void = () => {};
|
||||
player.flush = new Promise((r) => (land = r));
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("mark"));
|
||||
const before = feed.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("close"));
|
||||
await act(() => new Promise((r) => setTimeout(r, 30)));
|
||||
expect(feed.mock.calls.length).toBe(before);
|
||||
await act(async () => land(undefined));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||
});
|
||||
|
||||
it("does not refetch on a close with nothing changed", async () => {
|
||||
const { feed } = mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
const before = feed.mock.calls.length;
|
||||
fireEvent.click(await screen.findByText("close"));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(feed.mock.calls.length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feed — the card menu's channel filter", () => {
|
||||
it("is offered on the feed, as its own channel list, and sets it", async () => {
|
||||
const setFilters = vi.fn();
|
||||
mount({ channelScoped: false, setFilters });
|
||||
await screen.findByText("open v1");
|
||||
expect(grid.channelFilter?.ids).toEqual(["UCx"]);
|
||||
grid.channelFilter?.set(["UCx", "UCy"]);
|
||||
expect(setFilters).toHaveBeenCalledWith({ ...FILTERS, channelIds: ["UCx", "UCy"] });
|
||||
});
|
||||
|
||||
it("is not offered on a channel page, whose view is that channel already", async () => {
|
||||
mount({ channelScoped: true });
|
||||
await screen.findByText("open v1");
|
||||
expect(grid.channelFilter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// "Reset — clear watch progress/status" on a half-watched video. The optimistic layer overrode only
|
||||
// the STATUS, so the card kept its resume position — its progress bar and its Continue button — until
|
||||
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
|
||||
// hands it: each video's position, and a button that fires the reset.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/wizard/wizardContext", () => ({
|
||||
useWizard: () => ({ openWizard: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({
|
||||
items,
|
||||
onResetState,
|
||||
}: {
|
||||
items: { id: string; position_seconds: number }[];
|
||||
onResetState: (id: string) => void;
|
||||
}) => (
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>
|
||||
{v.id}@{v.position_seconds}
|
||||
<button onClick={() => onResetState(v.id)}>reset {v.id}</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { patchCachedProgress } from "../../lib/progressCache";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
const halfWatched = {
|
||||
id: "v1",
|
||||
title: "v1",
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds: 95,
|
||||
duration_seconds: 600,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: "https://www.youtube.com/watch?v=v1",
|
||||
} as unknown as Video;
|
||||
|
||||
function mount(clear: () => Promise<unknown>) {
|
||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
|
||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||
vi.spyOn(api, "clearState").mockImplementation(clear as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch=""
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
|
||||
describe("Feed — resetting a half-watched video", () => {
|
||||
it("clears its resume position at once, not only its status", async () => {
|
||||
// Never settles: what the card shows before the server answers is the optimistic layer alone.
|
||||
mount(() => new Promise(() => {}));
|
||||
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
||||
fireEvent.click(screen.getByText("reset v1"));
|
||||
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("shows the position a replay after it saved, so a reopen resumes there", async () => {
|
||||
// Reset, played to 40 s and closed: the player writes what the server stored into the cache.
|
||||
// A reset mask over the cache kept showing 0, a reopen resumed at 0, and its first checkpoint
|
||||
// wrote over the 40.
|
||||
const qc = mount(() => Promise.resolve({ ok: true }));
|
||||
fireEvent.click(await screen.findByText("reset v1"));
|
||||
await screen.findByText("v1@0");
|
||||
act(() => patchCachedProgress(qc, new Map([["v1", 40]])));
|
||||
await waitFor(() => expect(screen.getByText("v1@40")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("keeps the position at 0 through a refetch that set out before the reset", async () => {
|
||||
// That refetch read the row the reset deletes: its answer put the old position back under a
|
||||
// card the reset had made new, and a reopen resumed there.
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
const qc = mount(() => new Promise(() => {}));
|
||||
await screen.findByText("v1@95");
|
||||
vi.mocked(api.feed).mockImplementationOnce(() => new Promise((r) => (answer = r)) as never);
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||
await waitFor(() => expect(api.feed).toHaveBeenCalledTimes(2));
|
||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
fireEvent.click(screen.getByText("reset v1"));
|
||||
await screen.findByText("v1@0");
|
||||
await act(async () => answer({ items: [halfWatched], next_cursor: null }));
|
||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
expect(screen.getByText("v1@0")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("puts the position back when the server refuses the reset", async () => {
|
||||
mount(() => Promise.reject(new Error("down")));
|
||||
fireEvent.click(await screen.findByText("reset v1"));
|
||||
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,991 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { nextPageOnClick } from "../../lib/nextPage";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Columns2,
|
||||
Grid3x3,
|
||||
Info,
|
||||
LayoutGrid,
|
||||
Library,
|
||||
List,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../../lib/api";
|
||||
import i18n from "../../i18n";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify, resolveVideo } from "../../lib/notifications";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
|
||||
import {
|
||||
buildSort,
|
||||
feedSortOptions,
|
||||
parseSort,
|
||||
SORT_KEYS,
|
||||
sortYtResults,
|
||||
YT_SORT_KEYS,
|
||||
type SortKey,
|
||||
} from "../../lib/feedSort";
|
||||
import { type SortOption, type SortState } from "../../lib/columnSort";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { SHOW_IDS } from "./feedFiltersInit";
|
||||
import { FEED_VIEWS, type FeedView } from "../../lib/feedView";
|
||||
import { clearedFilters, useActiveFilters } from "../../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
|
||||
import SelectedViewChip from "../../features/filters/SelectedViewChip";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { PageMenu } from "../../components/ui/PageMenu";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { choiceSubmenu, multiSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||
import { useWizard } from "../../features/wizard/wizardContext";
|
||||
import SortControl from "../../components/ui/SortControl";
|
||||
import FilterSelect from "../../components/ui/FilterSelect";
|
||||
import ItemCount from "../../components/ui/ItemCount";
|
||||
import { FilterFoldButton } from "../../components/ui/FilterFold";
|
||||
import { useFilterFold } from "../../lib/useFilterFold";
|
||||
import ViewSwitcher from "../../components/ui/ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
|
||||
|
||||
const PAGE = 60;
|
||||
|
||||
// The "Show" view filter, content-type filter and ordering live in the feed toolbar
|
||||
// (above the cards), not the filter sidebar.
|
||||
const CONTENT = [
|
||||
{ key: "includeNormal", label: "sidebar.content.normal" },
|
||||
{ key: "includeShorts", label: "sidebar.content.shorts" },
|
||||
{ key: "includeLive", label: "sidebar.content.live" },
|
||||
] as const;
|
||||
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
||||
|
||||
// Icon per view mode; the labels are i18n'd at render (see feed.view.*).
|
||||
const VIEW_ICON: Record<FeedView, LucideIcon> = {
|
||||
cards: LayoutGrid,
|
||||
cardsSmall: Grid3x3,
|
||||
rows: Rows3,
|
||||
rowsCompact: List,
|
||||
tiles: Columns2,
|
||||
};
|
||||
|
||||
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
|
||||
// (which encode both). One entry per concept; a single arrow flips the direction.
|
||||
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
|
||||
|
||||
function matchesView(status: VideoStatus, show: string): boolean {
|
||||
switch (show) {
|
||||
case "hidden":
|
||||
return status === "hidden";
|
||||
case "watched":
|
||||
return status === "watched";
|
||||
case "unwatched":
|
||||
case "in_progress":
|
||||
// (in_progress is further narrowed server-side by resume position; here we only
|
||||
// need to drop a card once it's optimistically marked watched/hidden.)
|
||||
return status !== "watched" && status !== "hidden";
|
||||
default:
|
||||
return status !== "hidden"; // all
|
||||
}
|
||||
}
|
||||
|
||||
// An optimistic value and the write that made it. A refetch that lands is the server's word only on
|
||||
// the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer
|
||||
// than its answer, and so is one sent just before it whose answer had not come back (the server may
|
||||
// read the list first). Dropping either put a card marked watched back into an Unwatched view.
|
||||
type Stamped<T> = { value: T; seq: number };
|
||||
type Overrides<T> = Record<string, Stamped<T>>;
|
||||
const unconfirmed = <T,>(o: Overrides<T>, answered: ReadonlySet<number>): Overrides<T> =>
|
||||
Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq)));
|
||||
// A failed write takes back only its OWN value: a later write for the same video stands.
|
||||
const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
|
||||
if (o[id]?.seq !== seq) return o;
|
||||
const next = { ...o };
|
||||
delete next[id];
|
||||
return next;
|
||||
};
|
||||
|
||||
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
|
||||
const YT_COUNTS = [20, 40, 60, 100] as const;
|
||||
|
||||
export default function Feed({
|
||||
filters,
|
||||
setFilters,
|
||||
view,
|
||||
setView,
|
||||
canRead,
|
||||
ytSearch,
|
||||
onYtSearch,
|
||||
onExitYtSearch,
|
||||
channelScoped,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
// The view mode is an account pref, not a filter: it persists on pick, so it isn't part of
|
||||
// FeedFilters (no share-link param, no saved view, no active-filter chip). Provided by the caller
|
||||
// (FeedViewProvider on the feed page; ChannelPage threads its own).
|
||||
view: FeedView;
|
||||
setView: (v: FeedView) => void;
|
||||
canRead: boolean;
|
||||
// Live YouTube search: the active search term (null = normal feed). Entering a search and
|
||||
// leaving it both step browser history (the search is a feed sub-view with its own history
|
||||
// entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via
|
||||
// onExitYtSearch.
|
||||
ytSearch: string | null;
|
||||
onYtSearch: (q: string) => void;
|
||||
onExitYtSearch: () => void;
|
||||
// True when this feed is scoped to a single channel (the channel page). Drops sort options
|
||||
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
|
||||
channelScoped?: boolean;
|
||||
}) {
|
||||
const { t, i18n: ui } = useTranslation();
|
||||
const lang = ui.language;
|
||||
// "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected).
|
||||
const { openWizard: onOpenWizard } = useWizard();
|
||||
// Same list the panel's badge counts — see lib/useActiveFilters.
|
||||
const activeFilters = useActiveFilters(filters, setFilters);
|
||||
// B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold).
|
||||
const fold = useFilterFold();
|
||||
const sortKeys = channelScoped
|
||||
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
||||
: SORT_KEYS;
|
||||
const [overrides, setOverrides] = useState<Overrides<VideoStatus>>({});
|
||||
const [savedOverrides, setSavedOverrides] = useState<Overrides<boolean>>({});
|
||||
// The write order the stamps above count in, the writes the server has answered, and which of
|
||||
// them it had answered when the running refetch set out.
|
||||
const writeSeq = useRef(0);
|
||||
const answered = useRef(new Set<number>());
|
||||
const answeredAtRefetch = useRef<ReadonlySet<number>>(new Set());
|
||||
// A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out
|
||||
// before the server took the reset puts the old position back: written again on its landing.
|
||||
const resets = useRef(new Map<string, number>());
|
||||
// The open player: which video and where to start (null = resume from saved position).
|
||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||
null,
|
||||
);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// What changed state while the player was open, and the writes still in flight: closing it
|
||||
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
|
||||
// the end) a card leaves an Unwatched view through the override at once, but the list, its count
|
||||
// and the cache stayed the pre-session server's until a reload (owner).
|
||||
const playerSession = useRef({
|
||||
open: false,
|
||||
changed: false,
|
||||
pending: new Set<Promise<unknown>>(),
|
||||
});
|
||||
const openVideo = useCallback((video: Video, startAt: number | null = null) => {
|
||||
playerSession.current.open = true;
|
||||
// Its player writes the next position: a reset no longer speaks for it.
|
||||
resets.current.delete(video.id);
|
||||
setActiveVideo({ video, startAt });
|
||||
}, []);
|
||||
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
|
||||
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
|
||||
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
|
||||
// debounced volume write on each pass.
|
||||
const closePlayer = useCallback(() => setActiveVideo(null), []);
|
||||
// The session ends once the player has UNMOUNTED, not on the close click: its progress hook's
|
||||
// cleanup marks a clip closed near its end watched, a write that lands after the click. React runs
|
||||
// every unmount cleanup before any effect's setup, so here that write is already in the session,
|
||||
// and so is the player's closing checkpoint (`progressFlushes`): refetched before it lands, the
|
||||
// list read the older position and, landing after the player's patch, put it back.
|
||||
const playerOpen = activeVideo !== null;
|
||||
useEffect(() => {
|
||||
const session = playerSession.current;
|
||||
if (playerOpen || !session.open) return;
|
||||
session.open = false;
|
||||
if (!session.changed) return;
|
||||
session.changed = false;
|
||||
void Promise.allSettled([...session.pending, ...progressFlushes()]).then(() => {
|
||||
void qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
void qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
});
|
||||
}, [playerOpen, qc]);
|
||||
|
||||
const ytActive = !!ytSearch;
|
||||
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||
// the free scrape source pages through continuations until this many are collected).
|
||||
const [ytCount, setYtCount] = useState(20);
|
||||
// How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page
|
||||
// must not silently re-sort the library you go back to. Default `relevance` — the provider's
|
||||
// ranking, which is what the view always showed before it had a control.
|
||||
const [ytSort, setYtSort] = usePref<NonNullable<SortState>>("ytSearchSort", {
|
||||
key: "relevance",
|
||||
dir: "desc",
|
||||
});
|
||||
|
||||
// Debounce only the search term feeding the queries: the input still updates instantly (it
|
||||
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
|
||||
// and re-fetched pages keep the previous results on screen, so the feed never blanks/flickers.
|
||||
const debouncedQ = useDebounced(filters.q, 300);
|
||||
const queryFilters: FeedFilters = { ...filters, q: debouncedQ };
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: qk.feed(queryFilters),
|
||||
queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
enabled: !ytActive, // don't load the normal feed while showing live YouTube results
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// Live YouTube search results. We never auto-paginate on scroll — the user pulls more with an
|
||||
// explicit button (each api-source page spends 100 quota units; scrape-source pages are free).
|
||||
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
|
||||
// revisited search from re-spending.
|
||||
const ytQuery = useInfiniteQuery({
|
||||
queryKey: qk.ytSearch(ytSearch, ytCount),
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
enabled: ytActive,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Remember (in history.state) that this live search was served by the zero-quota scrape source,
|
||||
// so a reload restores the results instead of dropping to the feed — re-fetching scrape pages
|
||||
// costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units).
|
||||
useEffect(() => {
|
||||
if (!ytActive) return;
|
||||
const st = window.history.state;
|
||||
if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry
|
||||
if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) {
|
||||
window.history.replaceState({ ...st, _ytScrape: true }, "");
|
||||
}
|
||||
}, [ytActive, ytSearch, ytQuery.data]);
|
||||
|
||||
// Switching to the relevance sort when a search starts happens atomically in the header's
|
||||
// input onChange (race-free with the query update). Here we only handle the reverse: when
|
||||
// the term is cleared, fall back to the default sort — relevance has no dropdown option and
|
||||
// doesn't rank without a query.
|
||||
const qEmpty = !filters.q.trim();
|
||||
useEffect(() => {
|
||||
if (qEmpty && parseSort(filters.sort).key === "relevance") {
|
||||
setFilters({ ...filters, sort: buildSort("date", "desc") });
|
||||
}
|
||||
// Naming `filters` is safe precisely because the write below falsifies the condition above: the
|
||||
// sort it sets is "date", so the one re-run it causes does nothing. The old `[qEmpty]` was
|
||||
// guarding against a loop that the guard already prevents.
|
||||
}, [qEmpty, filters, setFilters]);
|
||||
|
||||
// Drop optimistic status overrides when filters change OR fresh server data
|
||||
// arrives — after a refetch the server is authoritative, so a stale override
|
||||
// (e.g. a video reverted to "new" from the notification center) won't keep it
|
||||
// filtered out of the current view.
|
||||
const [overridesFor, setOverridesFor] = useState(filters);
|
||||
if (overridesFor !== filters) {
|
||||
setOverridesFor(filters);
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
}
|
||||
// ...but only when a refetch LANDS, the server's own answer. Not on an infinite-scroll append (its
|
||||
// page 1 rows are unchanged, so a just-hidden card would flash back), and not on a local write into
|
||||
// the cache: the player's closing position patch bumped `dataUpdatedAt` too, cleared the overrides,
|
||||
// and put a card marked watched back into an Unwatched view as "new", with nothing to refetch it.
|
||||
// And only the overrides whose write it had answered when it set out (`Stamped`).
|
||||
const refetching = query.isFetching && !query.isFetchingNextPage;
|
||||
const wasRefetching = useRef(false);
|
||||
useEffect(() => {
|
||||
const started = !wasRefetching.current && refetching;
|
||||
const landed = wasRefetching.current && !refetching && query.status === "success";
|
||||
wasRefetching.current = refetching;
|
||||
if (started) answeredAtRefetch.current = new Set(answered.current);
|
||||
if (!landed) return;
|
||||
const known = answeredAtRefetch.current;
|
||||
setOverrides((o) => unconfirmed(o, known));
|
||||
setSavedOverrides((o) => unconfirmed(o, known));
|
||||
for (const seq of known) answered.current.delete(seq);
|
||||
const zero = new Map<string, number>();
|
||||
for (const [id, seq] of resets.current) {
|
||||
if (known.has(seq)) resets.current.delete(id);
|
||||
else zero.set(id, 0);
|
||||
}
|
||||
patchCachedProgress(qc, zero);
|
||||
}, [refetching, query.status, qc]);
|
||||
|
||||
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
|
||||
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: qk.feedSummary(queryFilters),
|
||||
queryFn: () => api.feedSummary(queryFilters),
|
||||
staleTime: 30_000,
|
||||
enabled: !ytActive,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||
|
||||
// Keep the loaded videos in a ref so onState can stay referentially stable
|
||||
// (it needs the list only to look up a title for the notification). A stable
|
||||
// onState lets memo(VideoCard) skip re-rendering existing cards on page append.
|
||||
const loadedRef = useRef<Video[]>([]);
|
||||
|
||||
// The toast's Undo re-enters onState, a forward reference: through this ref (set below).
|
||||
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
|
||||
const onState = useCallback(
|
||||
(id: string, status: VideoStatus) => {
|
||||
const seq = ++writeSeq.current;
|
||||
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
|
||||
const write = api.setState(id, status);
|
||||
const session = playerSession.current;
|
||||
if (session.open) {
|
||||
session.changed = true;
|
||||
session.pending.add(write);
|
||||
const settled = () => void session.pending.delete(write);
|
||||
write.then(settled, settled);
|
||||
}
|
||||
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
|
||||
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
|
||||
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
|
||||
// would falsely report a change that the .catch below is about to roll back (e.g. when
|
||||
// the API is unreachable).
|
||||
write
|
||||
.then(() => {
|
||||
// Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter
|
||||
// already show the correct state (a hidden/watched card drops out immediately), so refetching
|
||||
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
|
||||
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
|
||||
answered.current.add(seq);
|
||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||
if (status === "hidden") {
|
||||
const v = loadedRef.current.find((x) => x.id === id);
|
||||
notify({
|
||||
message: v?.title
|
||||
? i18n.t("feed.hiddenNamed", { title: v.title })
|
||||
: i18n.t("feed.hidden"),
|
||||
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
|
||||
meta: {
|
||||
kind: "video-hidden",
|
||||
videoId: id,
|
||||
title: v?.title ?? i18n.t("feed.thisVideo"),
|
||||
channelId: v?.channel_id ?? "",
|
||||
channelName: v?.channel_title ?? "",
|
||||
},
|
||||
});
|
||||
} else if (status === "watched") {
|
||||
const v = loadedRef.current.find((x) => x.id === id);
|
||||
notify({
|
||||
message: v?.title
|
||||
? i18n.t("feed.markedWatchedNamed", { title: v.title })
|
||||
: i18n.t("feed.markedWatched"),
|
||||
action: {
|
||||
label: i18n.t("feed.unwatch"),
|
||||
onClick: () => onStateRef.current(id, "new"),
|
||||
},
|
||||
meta: {
|
||||
kind: "video-watched",
|
||||
videoId: id,
|
||||
title: v?.title ?? i18n.t("feed.thisVideo"),
|
||||
channelId: v?.channel_id ?? "",
|
||||
channelName: v?.channel_title ?? "",
|
||||
},
|
||||
});
|
||||
} else if (status === "new") {
|
||||
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
|
||||
// any stale hide/watch notice for this video so it doesn't linger with a dead action.
|
||||
resolveVideo(id);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// Server rejected the change — drop the optimistic override so the card
|
||||
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
|
||||
// a card that silently snaps back reads as a misclick.
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
|
||||
setOverrides((o) => withoutWrite(o, id, seq));
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
|
||||
const onResetState = useCallback(
|
||||
(id: string) => {
|
||||
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
|
||||
// un-watch toggle can't clear an in-progress position, this can.
|
||||
const seq = ++writeSeq.current;
|
||||
setOverrides((o) => ({ ...o, [id]: { value: "new", seq } }));
|
||||
// The position goes into the cached lists, where the player's own closing patch writes the
|
||||
// next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first
|
||||
// checkpoint wrote over the progress just made.
|
||||
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
|
||||
patchCachedProgress(qc, new Map([[id, 0]]));
|
||||
resets.current.set(id, seq);
|
||||
api
|
||||
.clearState(id)
|
||||
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
||||
.then(() => {
|
||||
answered.current.add(seq);
|
||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
|
||||
setOverrides((o) => withoutWrite(o, id, seq));
|
||||
if (resets.current.get(id) !== seq) return;
|
||||
resets.current.delete(id);
|
||||
if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]]));
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
|
||||
const onToggleSave = useCallback(
|
||||
(id: string, saved: boolean) => {
|
||||
const seq = ++writeSeq.current;
|
||||
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
|
||||
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
||||
.then(() => {
|
||||
answered.current.add(seq);
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(
|
||||
e,
|
||||
i18n.t(
|
||||
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
|
||||
),
|
||||
);
|
||||
setSavedOverrides((o) => withoutWrite(o, id, seq));
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
|
||||
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||||
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
||||
// onState's refs follow each commit: itself, and what the cards show (live search results if on).
|
||||
useLayoutEffect(() => {
|
||||
onStateRef.current = onState;
|
||||
loadedRef.current = ytActive ? ytLoaded : loaded;
|
||||
});
|
||||
const withOverrides = (list: Video[]): Video[] =>
|
||||
list
|
||||
.map((v) => {
|
||||
const status = overrides[v.id]; // read once so the narrowing sticks
|
||||
return status ? { ...v, status: status.value } : v;
|
||||
})
|
||||
.map((v) => {
|
||||
const saved = savedOverrides[v.id];
|
||||
return saved ? { ...v, saved: saved.value } : v;
|
||||
});
|
||||
const merged: Video[] = withOverrides(loaded);
|
||||
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
|
||||
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
|
||||
// watch-state view filter is NOT applied — so marking the current video watched keeps it in the
|
||||
// sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
|
||||
// goes hidden still drops out ("visible affects").
|
||||
const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
|
||||
|
||||
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale
|
||||
// pre-search cache. Drop them so the feed re-fetches fresh on return.
|
||||
const dropFeedCaches = () => {
|
||||
qc.removeQueries({ queryKey: qk.feed() });
|
||||
qc.removeQueries({ queryKey: qk.feedSummary() });
|
||||
};
|
||||
|
||||
// --- Live YouTube search mode -------------------------------------------------------------
|
||||
// A separate data source rendered in the same cards/player. No watch-state view filter (the
|
||||
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
|
||||
if (ytActive) {
|
||||
const ytItems: Video[] = sortYtResults(
|
||||
withOverrides(ytLoaded),
|
||||
ytSort.key as SortKey,
|
||||
ytSort.dir,
|
||||
lang,
|
||||
);
|
||||
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
|
||||
const ytError =
|
||||
ytQuery.error instanceof HttpError
|
||||
? ytQuery.error.detail || t("feed.yt.error")
|
||||
: ytQuery.isError
|
||||
? t("feed.yt.error")
|
||||
: null;
|
||||
// The backend reports which source served the search; scrape spends no search quota, so we
|
||||
// drop the quota warning + "uses quota" wording in that mode.
|
||||
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="pb-3 mb-1 border-b border-border">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<ArrowLeft />}
|
||||
onClick={() => {
|
||||
// Surface the just-searched videos in the feed you return to: switch the Source
|
||||
// filter to "search" (your own search finds) and rank them by relevance.
|
||||
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
|
||||
onExitYtSearch();
|
||||
dropFeedCaches();
|
||||
}}
|
||||
>
|
||||
{t("feed.yt.back")}
|
||||
</Button>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<YouTubeIcon className="w-4 h-4 text-accent shrink-0" />
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{t("feed.yt.resultsFor", { query: ytSearch })}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
||||
</span>
|
||||
{/* Four fixed counts: the house's segmented control, not a native <select> (owner's
|
||||
pick, 2026-09-19). The caption is the group's name, the hint its description. */}
|
||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
|
||||
<span aria-hidden="true">{t("feed.yt.show")}</span>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("feed.yt.show")}
|
||||
title={t("feed.yt.showHint")}
|
||||
value={String(ytCount)}
|
||||
onChange={(v) => setYtCount(Number(v))}
|
||||
options={YT_COUNTS.map((n) => ({ value: String(n), label: String(n) }))}
|
||||
/>
|
||||
</div>
|
||||
{/* Sorts the results already on screen (see sortYtResults) — it never re-queries, so it
|
||||
costs nothing and works the same on both search sources. */}
|
||||
<SortControl
|
||||
options={feedSortOptions(YT_SORT_KEYS, (k) => t("feed.sortKey." + k))}
|
||||
sort={ytSort}
|
||||
onChange={(next) => setYtSort(next ?? { key: "relevance", dir: "desc" })}
|
||||
label={t("feed.sortLabel")}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
/>
|
||||
</div>
|
||||
{ytItems.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
|
||||
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{t("feed.yt.ephemeralNote")}</span>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
icon={<Trash2 />}
|
||||
onClick={() => {
|
||||
const ids = ytItems.map((v) => v.id);
|
||||
api
|
||||
.clearSearch(ids)
|
||||
.then((r) =>
|
||||
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
|
||||
)
|
||||
.catch((e: unknown) =>
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
|
||||
);
|
||||
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
||||
onExitYtSearch();
|
||||
dropFeedCaches();
|
||||
qc.removeQueries({ queryKey: qk.ytSearch() });
|
||||
}}
|
||||
>
|
||||
{t("feed.yt.clearNow")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ytQuery.isLoading ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
|
||||
) : ytError ? (
|
||||
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
|
||||
) : ytItems.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
|
||||
) : (
|
||||
<>
|
||||
<VirtualFeed
|
||||
items={ytItems}
|
||||
view={view}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
hasNextPage={false}
|
||||
isFetching={false}
|
||||
fetchNextPage={() => {}}
|
||||
/>
|
||||
{ytQuery.hasNextPage && (
|
||||
<div className="text-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void ytQuery.fetchNextPage(nextPageOnClick(ytQuery.isFetchingNextPage))
|
||||
}
|
||||
loading={ytQuery.isFetchingNextPage}
|
||||
>
|
||||
{ytQuery.isFetchingNextPage
|
||||
? t("feed.loadingMore")
|
||||
: zeroQuota
|
||||
? t("feed.yt.loadMoreFree")
|
||||
: t("feed.yt.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeVideo && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={activeVideo.video}
|
||||
startAt={activeVideo.startAt}
|
||||
queue={ytPlayerQueue}
|
||||
onClose={closePlayer}
|
||||
onState={onState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isLoading) return <LoadingState shape="grid" label={t("feed.loading")} />;
|
||||
if (query.isError && !query.data)
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
||||
{t("feed.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
// Empty "my" feed with no YouTube access: the onboarding prompt, plus a way into the shared
|
||||
// library. No toolbar (sort/type are meaningless).
|
||||
if (items.length === 0 && !canRead && filters.scope === "my")
|
||||
return (
|
||||
<div className="p-8 grid place-items-center text-center">
|
||||
<div className="max-w-sm">
|
||||
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
|
||||
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
|
||||
<Button variant="primary" size="lg" onClick={onOpenWizard}>
|
||||
{t("feed.setUp")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link-muted"
|
||||
className="mx-auto mt-3 flex text-sm"
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
>
|
||||
{t("feed.browseShared")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
|
||||
// The feed's sort vocabulary as source-agnostic options for the shared SortControl. Relevance only
|
||||
// ranks meaningfully with a search term (offered then); shuffle/relevance are directionless.
|
||||
const sortOptions: SortOption[] = feedSortOptions(
|
||||
filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys,
|
||||
(k) => t("feed.sortKey." + k),
|
||||
);
|
||||
|
||||
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
|
||||
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
|
||||
// same controls render on both the feed and channel-scoped rows.
|
||||
// One setter per toolbar control, shared with the page's background menu (the same choices, the
|
||||
// same effect, two doors).
|
||||
const setSort = (next: SortState) => {
|
||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(next.key as SortKey, next.dir),
|
||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
};
|
||||
const reshuffle = () => setFilters({ ...filters, seed: rollSeed() });
|
||||
const showOptions = SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }));
|
||||
const setShow = (show: string) => setFilters({ ...filters, show: show as FeedFilters["show"] });
|
||||
const contentOptions = CONTENT.map((c) => ({ value: c.key, label: t(c.label) }));
|
||||
const contentSelected = CONTENT.filter((c) => filters[c.key]).map((c) => c.key);
|
||||
const setContent = (next: string[]) => {
|
||||
const patch = { ...filters };
|
||||
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
|
||||
setFilters(patch);
|
||||
};
|
||||
const sourceOptions = [
|
||||
{ value: "organic", label: t("feed.source.organic") },
|
||||
{ value: "all", label: t("feed.source.all") },
|
||||
{ value: "search", label: t("feed.source.only") },
|
||||
];
|
||||
const setSource = (source: string) =>
|
||||
setFilters({ ...filters, librarySource: source as FeedFilters["librarySource"] });
|
||||
const viewOptions = FEED_VIEWS.map((id) => ({
|
||||
id,
|
||||
label: t("feed.view." + id),
|
||||
icon: VIEW_ICON[id],
|
||||
}));
|
||||
|
||||
const sortView = (
|
||||
<>
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={{ key: sortKey, dir: sortDir }}
|
||||
onChange={setSort}
|
||||
label={t("feed.sortLabel")}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
trailing={
|
||||
sortKey === "shuffle" ? (
|
||||
<IconButton variant="outline" onClick={reshuffle} label={t("sidebar.reshuffle")}>
|
||||
<RefreshCw />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<ViewSwitcher
|
||||
value={view}
|
||||
options={viewOptions}
|
||||
onChange={setView}
|
||||
label={t("feed.viewLabel")}
|
||||
// The mode's name only earns its place once the window is wide.
|
||||
labelClassName="hidden xl:inline"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const toolbar = (
|
||||
<div className="pb-1.5">
|
||||
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
||||
never shoves the controls beside it (see ItemCount). */}
|
||||
<ItemCount
|
||||
count={summaryQuery.data?.count}
|
||||
stale={summaryQuery.isPlaceholderData}
|
||||
failed={summaryQuery.isError && !summaryQuery.data}
|
||||
/>
|
||||
<FilterFoldButton
|
||||
fold={fold}
|
||||
label={t("sidebar.filters")}
|
||||
count={channelScoped ? 0 : activeFilters.length}
|
||||
/>
|
||||
{!fold.folded && (
|
||||
<>
|
||||
{/* Watch state is single-select (always exactly one); content types are an
|
||||
independent multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
|
||||
<FilterSelect
|
||||
label={t("feed.showLabel")}
|
||||
testId="feed-show"
|
||||
options={showOptions}
|
||||
selected={[filters.show]}
|
||||
onChange={(next) => next[0] && setShow(next[0])}
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("feed.contentLabel")}
|
||||
testId="feed-content"
|
||||
multiple
|
||||
options={contentOptions}
|
||||
selected={contentSelected}
|
||||
onChange={setContent}
|
||||
/>
|
||||
{/* The Source filter (organic / include-search / search-only) is a global-catalog
|
||||
concept — it selects how videos ENTERED the library. On a channel-scoped view we
|
||||
already show all of this one channel's videos (librarySource is pinned to "all"), so
|
||||
the selector would only offer a near-empty, confusing slice — hide it here. */}
|
||||
{!channelScoped && (
|
||||
<FilterSelect
|
||||
label={t("feed.source.label")}
|
||||
testId="feed-source"
|
||||
options={sourceOptions}
|
||||
selected={[filters.librarySource ?? "organic"]}
|
||||
onChange={(next) => next[0] && setSource(next[0])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Folded, the band is one row: Sort + View join it on the right. */}
|
||||
{fold.folded && (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
|
||||
const pageMenuItems = menuGroups(
|
||||
[
|
||||
sortSubmenu({
|
||||
id: "sort",
|
||||
label: t("feed.sortLabel"),
|
||||
options: sortOptions,
|
||||
sort: { key: sortKey, dir: sortDir },
|
||||
onChange: setSort,
|
||||
ascLabel: t("feed.dirAsc"),
|
||||
descLabel: t("feed.dirDesc"),
|
||||
}),
|
||||
...(sortKey === "shuffle"
|
||||
? [{ id: "reshuffle", label: t("sidebar.reshuffle"), onSelect: reshuffle }]
|
||||
: []),
|
||||
choiceSubmenu({
|
||||
id: "view",
|
||||
label: t("feed.viewLabel"),
|
||||
options: viewOptions.map((o) => ({ value: o.id, label: o.label })),
|
||||
selected: view,
|
||||
onPick: setView,
|
||||
}),
|
||||
],
|
||||
[
|
||||
choiceSubmenu({
|
||||
id: "show",
|
||||
label: t("feed.showLabel"),
|
||||
options: showOptions,
|
||||
selected: filters.show,
|
||||
onPick: setShow,
|
||||
}),
|
||||
multiSubmenu({
|
||||
id: "content",
|
||||
label: t("feed.contentLabel"),
|
||||
options: contentOptions,
|
||||
selected: contentSelected,
|
||||
onChange: setContent,
|
||||
}),
|
||||
...(channelScoped
|
||||
? []
|
||||
: [
|
||||
choiceSubmenu({
|
||||
id: "source",
|
||||
label: t("feed.source.label"),
|
||||
options: sourceOptions,
|
||||
selected: filters.librarySource ?? "organic",
|
||||
onPick: setSource,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
!channelScoped && activeFilters.length > 0
|
||||
? [
|
||||
{
|
||||
id: "clear",
|
||||
label: t("sidebar.clearAll"),
|
||||
onSelect: () => setFilters(clearedFilters(filters)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
|
||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
|
||||
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
|
||||
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
|
||||
the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN
|
||||
baseline (channel-scoped, whole-catalog, the watch state the channel page remembers), so
|
||||
each would read as a "filter you applied" when it's just the page being itself. */}
|
||||
<PageToolbar>
|
||||
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
|
||||
{toolbar}
|
||||
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left
|
||||
(overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
|
||||
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
|
||||
applied"), but still get the right-pinned Sort/View. */}
|
||||
{/* Wraps on a phone: the chips and Sort + View did not fit one line, and the row pushed the
|
||||
page 21px wide (prod UAT 2026-09-19). Sort + View then take a line of their own. */}
|
||||
{!fold.folded && (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{!channelScoped && (
|
||||
<>
|
||||
{/* The active personal view (if any) leads the applied-filter row. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
{items.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">
|
||||
<p>{t("feed.noMatches")}</p>
|
||||
{/* A channel filter under the "Mine" scope returns nothing when that channel is hidden from
|
||||
your subscriptions (or you don't subscribe to it) — its videos only live in the shared
|
||||
Library. Point there rather than leaving a bare "no matches". */}
|
||||
{filters.channelIds.length > 0 && filters.scope === "my" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm">{t("feed.channelScopeHint")}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
icon={<Library />}
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
>
|
||||
{t("feed.viewInLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{filters.q.trim() && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
icon={<YouTubeIcon />}
|
||||
onClick={() => onYtSearch(filters.q.trim())}
|
||||
>
|
||||
{t("feed.yt.searchFor", { query: filters.q.trim() })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<VirtualFeed
|
||||
items={items}
|
||||
view={view}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
// A channel page's view is the channel already: no row there to narrow it to one.
|
||||
channelFilter={
|
||||
channelScoped
|
||||
? undefined
|
||||
: {
|
||||
ids: filters.channelIds,
|
||||
set: (channelIds) => setFilters({ ...filters, channelIds }),
|
||||
}
|
||||
}
|
||||
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
|
||||
// re-request it once a round-trip for as long as the end is in view. The row
|
||||
// below says so and retries on a click.
|
||||
hasNextPage={!!hasNextPage && !query.isFetchNextPageError}
|
||||
isFetching={query.isFetching}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
)}
|
||||
{activeVideo && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={activeVideo.video}
|
||||
startAt={activeVideo.startAt}
|
||||
queue={playerQueue}
|
||||
onClose={closePlayer}
|
||||
onState={onState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{isFetchingNextPage && (
|
||||
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
|
||||
)}
|
||||
{query.isFetchNextPageError && !isFetchingNextPage && (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
|
||||
>
|
||||
{t("common.loadMoreFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The live-YouTube results' Sort control, wired end to end: the pref it writes, the key and
|
||||
// direction Feed hands to sortYtResults, and the order that reaches the grid. sortYtResults itself
|
||||
// is pinned in feedSort.yt.test.ts; a review during the 2026-09 bug hunt found that the CALL SITE
|
||||
// could ignore the chosen direction (or skip the sort entirely) with every lane green.
|
||||
//
|
||||
// The virtualised grid lays out nothing in jsdom, so it is replaced by a plain list that shows the
|
||||
// order Feed gave it — which is exactly the thing under test.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/wizard/wizardContext", () => ({
|
||||
useWizard: () => ({ openWizard: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({ items }: { items: { id: string; title: string }[] }) => (
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>{v.title}</li>
|
||||
))}
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
const hit = (id: string, duration_seconds: number): Video =>
|
||||
({
|
||||
id,
|
||||
title: `hit ${id}`,
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
duration_seconds,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
|
||||
const order = () =>
|
||||
Array.from(screen.getByRole("list", { name: "results" }).querySelectorAll("li")).map(
|
||||
(li) => li.textContent,
|
||||
);
|
||||
|
||||
describe("Feed — sorting the live YouTube results", () => {
|
||||
it("hands the grid the results in the key AND direction the viewer picked", async () => {
|
||||
vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||
vi.spyOn(api, "searchYoutube").mockResolvedValue({
|
||||
items: [hit("a", 300), hit("b", 100), hit("c", 200)],
|
||||
next_cursor: null,
|
||||
source: "scrape",
|
||||
} as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch="lemmino"
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
// Relevance is the provider's own ranking — the order the results arrived in.
|
||||
await waitFor(() => expect(order()).toEqual(["hit a", "hit b", "hit c"]));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^feed\.sortLabel:/ }));
|
||||
const duration = (await screen.findAllByRole("menuitemradio", { hidden: true })).find((o) =>
|
||||
o.textContent?.includes("feed.sortKey.duration"),
|
||||
);
|
||||
fireEvent.click(duration!);
|
||||
fireEvent.click(await screen.findByText("feed.dirAsc"));
|
||||
await waitFor(() => expect(order()).toEqual(["hit b", "hit c", "hit a"]));
|
||||
fireEvent.click(screen.getByText("feed.dirDesc"));
|
||||
await waitFor(() => expect(order()).toEqual(["hit a", "hit c", "hit b"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// loadAccountFilters is the localStorage seed path for the feed filters. It must migrate a persisted
|
||||
// legacy global `tagMode` (older builds wrote it) into the per-category `tagModes` — the same coercion
|
||||
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { loadAccountFilters } from "./feedFiltersInit";
|
||||
import { claimLocalData, LS } from "../../lib/storage";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(7);
|
||||
});
|
||||
|
||||
describe("loadAccountFilters — legacy tagMode migration on seed", () => {
|
||||
it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
|
||||
localStorage.setItem(LS.filters, JSON.stringify({ q: "hi", tagMode: "and" }));
|
||||
const f = loadAccountFilters();
|
||||
expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
|
||||
expect(f.q).toBe("hi"); // other fields preserved through the merge
|
||||
});
|
||||
|
||||
it("leaves tagModes at defaults when nothing is stored", () => {
|
||||
expect(loadAccountFilters().tagModes).toEqual({ language: "or", topic: "or", other: "or" });
|
||||
});
|
||||
|
||||
it("preserves an already-migrated per-category tagModes blob", () => {
|
||||
localStorage.setItem(
|
||||
LS.filters,
|
||||
JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
|
||||
);
|
||||
expect(loadAccountFilters().tagModes).toEqual({
|
||||
language: "and",
|
||||
topic: "or",
|
||||
other: "and",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The feed's filters are seeded at mount from the account's stored view. A share link's filters are
|
||||
// written to the account once at mount too, so the view the link opened survives a later plain reload (the state itself is seeded from the URL, so only storage shows it).
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NavigationProvider } from "../../features/navigation/NavigationProvider";
|
||||
import { FeedFiltersProvider } from "./FeedFiltersProvider";
|
||||
import { useFeedFilters } from "./feedFiltersContext";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import type { Me } from "../../lib/api";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
|
||||
const scopes: string[] = [];
|
||||
function Probe() {
|
||||
scopes.push(useFeedFilters().scope);
|
||||
return null;
|
||||
}
|
||||
|
||||
function mount(me: Partial<Me>) {
|
||||
const qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { id: 5, preferences: {}, ...me });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<Probe />
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(5);
|
||||
scopes.length = 0;
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("FeedFiltersProvider seed", () => {
|
||||
it("opens an account on its own scope", () => {
|
||||
mount({});
|
||||
expect(scopes[0]).toBe("my");
|
||||
});
|
||||
|
||||
it("keeps a share link's filters for the account, so a plain reload opens the same view", () => {
|
||||
window.history.replaceState(null, "", "/?scope=all");
|
||||
mount({});
|
||||
expect(scopes[0]).toBe("all");
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.filters") ?? "{}").scope).toBe("all");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { type FeedFilters } from "../../lib/api";
|
||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../../lib/urlState";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { ownKey, writeJSON } from "../../lib/storage";
|
||||
import { logFilterChange } from "../../lib/filterLog";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
import {
|
||||
FeedFiltersActionsContext,
|
||||
FeedFiltersStateContext,
|
||||
type FeedFiltersActions,
|
||||
} from "./feedFiltersContext";
|
||||
import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersInit";
|
||||
|
||||
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
|
||||
// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters`
|
||||
// through the tree. Mirrors NavigationProvider (split contexts + one-line hooks); composed in
|
||||
// main.tsx inside NavigationProvider, around <App/>.
|
||||
//
|
||||
// SCOPE NOTE: this owns the FEED's filters only. The channel detail page keeps its OWN isolated
|
||||
// filter state (ChannelPage) — it renders a channel-scoped catalog, not the global feed — and the
|
||||
// reusable <Feed> is parameterized by a `filters` prop so it can render either. Because the two live
|
||||
// in structurally separate cells, opening a channel can never leak into the global feed (bug 2).
|
||||
//
|
||||
// The contexts + hooks live in feedFiltersContext.ts; the defaults and the per-account seed in
|
||||
// feedFiltersInit.ts.
|
||||
|
||||
// URL wins over localStorage so a pasted link reproduces the exact view.
|
||||
function loadInitialFilters(): FeedFilters {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
|
||||
return loadAccountFilters();
|
||||
}
|
||||
|
||||
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
// Seeded for the account this tab is — BootGate mounts this provider only once the account is
|
||||
// claimed, so this is the account's own view from the first render.
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
|
||||
// once at mount — such filters win over the account's stored view and are persisted to the account.
|
||||
const [initialHadUrlFilters] = useState(() =>
|
||||
hasFilterParams(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
|
||||
const setFilters = useCallback((next: FeedFilters) => {
|
||||
setFiltersState(next);
|
||||
logFilterChange("feed", next, next.q);
|
||||
// Persist as the account's own view.
|
||||
const key = ownKey(FILTERS_KEY);
|
||||
if (key) writeJSON(key, next);
|
||||
}, []);
|
||||
|
||||
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on
|
||||
// page===feed: on other pages the filter keys are cleared from the URL (localStorage still holds
|
||||
// them; they re-mirror on return to the feed). The channel detail page keeps its own isolated
|
||||
// filters, so those never reach here. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
|
||||
// other providers own.
|
||||
const { page } = useNavigation();
|
||||
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
|
||||
|
||||
// A share link's filters (captured at mount) win over the account's stored view and are persisted
|
||||
// to it once, so the view the link opened also survives a later plain reload.
|
||||
//
|
||||
// The mount-time filters are FROZEN into their own state rather than left to an empty dep list:
|
||||
// `filters` changes on every edit, so listing it honestly would re-persist — and setFilters
|
||||
// setting the state it depends on would spin. Frozen, all three deps are stable and the effect
|
||||
// means exactly what it says: write what the URL brought, once.
|
||||
const [urlSeededFilters] = useState(filters);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters also WRITES to per-account localStorage, which is the point here — persisting what a share link brought. That is a side effect, not a value to derive, and the frozen seed above makes it happen exactly once.
|
||||
if (initialHadUrlFilters) setFilters(urlSeededFilters);
|
||||
}, [initialHadUrlFilters, urlSeededFilters, setFilters]);
|
||||
|
||||
const actions = useMemo<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
|
||||
|
||||
return (
|
||||
<FeedFiltersActionsContext.Provider value={actions}>
|
||||
<FeedFiltersStateContext.Provider value={filters}>
|
||||
{children}
|
||||
</FeedFiltersStateContext.Provider>
|
||||
</FeedFiltersActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Library, User } from "lucide-react";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
|
||||
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
|
||||
// search box — mirroring PlexScopeToggle's placement, so the primary "what am I browsing" control is
|
||||
// always in reach instead of buried in the (possibly collapsed) filter rail. Same `filters.scope`
|
||||
// field, so behaviour is unchanged. The `pointer-events-auto`/`shrink-0` wrapper matches the other
|
||||
// header pills: the header band is `pointer-events-none`, and the pill must not collapse in the flex row.
|
||||
export default function FeedScopeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("header.scope.label")}
|
||||
options={[
|
||||
{
|
||||
value: "my",
|
||||
label: t("header.scope.my"),
|
||||
icon: <User className="w-3.5 h-3.5" />,
|
||||
title: t("header.scope.myTip"),
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
label: t("header.scope.all"),
|
||||
icon: <Library className="w-3.5 h-3.5" />,
|
||||
title: t("header.scope.allTip"),
|
||||
},
|
||||
]}
|
||||
value={filters.scope}
|
||||
onChange={(scope) => setFilters({ ...filters, scope })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { parseFeedView, type FeedView } from "../../lib/feedView";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import {
|
||||
FeedViewActionsContext,
|
||||
FeedViewStateContext,
|
||||
type FeedViewActions,
|
||||
} from "./feedViewContext";
|
||||
|
||||
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
|
||||
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
|
||||
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
|
||||
//
|
||||
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and follows
|
||||
// the account across devices. usePref carries that server-sync (seeded from the server pref, with the
|
||||
// localStorage mirror as the fallback) so this provider is just the two contexts over it.
|
||||
//
|
||||
// The contexts + hooks live in feedViewContext.ts.
|
||||
|
||||
export function FeedViewProvider({ children }: { children: ReactNode }) {
|
||||
// parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values).
|
||||
const [view, setView] = usePref<FeedView>("view", "cards", {
|
||||
localKey: LS.feedView,
|
||||
parse: parseFeedView,
|
||||
});
|
||||
|
||||
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
|
||||
|
||||
return (
|
||||
<FeedViewActionsContext.Provider value={actions}>
|
||||
<FeedViewStateContext.Provider value={view}>{children}</FeedViewStateContext.Provider>
|
||||
</FeedViewActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider
|
||||
// seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH
|
||||
// the feed and Plex — but leaves them alone once the account has its own stored filters. This is the
|
||||
// fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a
|
||||
// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called;
|
||||
// api.savedViews is stubbed and driven through a real QueryClient.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
page: "feed" as "feed" | "plex",
|
||||
setFeedFilters: vi.fn(),
|
||||
setPlexFilters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigation: () => ({ page: h.page }),
|
||||
}));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({
|
||||
tags: [],
|
||||
channelIds: [],
|
||||
tagModes: { language: "or", topic: "or", other: "or" },
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
}),
|
||||
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
|
||||
}));
|
||||
vi.mock("../plex/plexContext", () => ({
|
||||
usePlex: () => ({ filters: {} }),
|
||||
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
import { SavedViewsProvider } from "./SavedViewsProvider";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { api, type SavedView } from "../../lib/api";
|
||||
import { claimLocalData, LS } from "../../lib/storage";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
const ID = 7;
|
||||
const view = (over: Partial<SavedView>): SavedView => ({
|
||||
id: 1,
|
||||
name: "v",
|
||||
filters: {},
|
||||
position: 0,
|
||||
is_default: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const { loading, views } = useSavedViews();
|
||||
return (
|
||||
<div data-testid="loading" data-count={views.length}>
|
||||
{String(loading)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The views query waits for a signed-in account in the cache (BootGate's `me`).
|
||||
function signedInClient() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: ID, preferences: {} });
|
||||
return qc;
|
||||
}
|
||||
|
||||
async function mountResolved(views: SavedView[]) {
|
||||
// Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's
|
||||
// identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing
|
||||
// in the re-seed test; a shared reference would skip the effect and hide a missing guard).
|
||||
vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v })));
|
||||
const qc = signedInClient();
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
// Wait for the query to resolve so the first-resolve effect has run.
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
return qc;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(ID);
|
||||
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
|
||||
// case would suppress seeding in the others.
|
||||
window.history.replaceState({}, "", "/");
|
||||
h.page = "feed";
|
||||
h.setFeedFilters.mockClear();
|
||||
h.setPlexFilters.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
it("feed: seeds the starred default into a fresh module (no stored filters)", async () => {
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the account already has stored filters", async () => {
|
||||
localStorage.setItem(LS.filters, JSON.stringify({ scope: "my" }));
|
||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when no view is starred as default", async () => {
|
||||
await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the URL carries filter params (a shared/bookmarked view wins)", async () => {
|
||||
// A link like ?sort=oldest is an explicit filter the user arrived on; the starred default must not
|
||||
// stomp it. feedCanSeedDefault() bails on hasFilterParams — plex ignores the URL, so this is feed-only.
|
||||
window.history.replaceState({}, "", "/?sort=oldest");
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT re-seed on a background refetch that returns a changed list (per-source one-shot guard)", async () => {
|
||||
// The 60s refetch re-resolves the query; when it returns a CHANGED list (e.g. the user just added a
|
||||
// view elsewhere) react-query hands down a new reference and the seed effect re-runs. seededRef must
|
||||
// stop it re-applying the default over filters the user has since edited. (An identical refetch is
|
||||
// deduped by structural sharing and never reaches the effect, so the changed list is the real test.)
|
||||
const def = view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } });
|
||||
let calls = 0;
|
||||
vi.spyOn(api, "savedViews").mockImplementation(async () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })];
|
||||
});
|
||||
const qc = signedInClient();
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await qc.refetchQueries({ queryKey: qk.savedViews("feed") });
|
||||
});
|
||||
// Wait until the changed list has actually reached the component, so the seed effect has had its
|
||||
// chance to (wrongly) re-fire — otherwise the assertion could win by racing ahead of the re-render.
|
||||
await waitFor(() => expect(screen.getByTestId("loading").dataset.count).toBe("2"));
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => {
|
||||
h.page = "plex";
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
|
||||
});
|
||||
|
||||
it("plex: does NOT seed when plex filters are already stored", async () => {
|
||||
h.page = "plex";
|
||||
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SavedViewsProvider — signed-out tab", () => {
|
||||
it("does NOT ask for saved views while no account is signed in (the landing logged a 401)", async () => {
|
||||
const spy = vi.spyOn(api, "savedViews").mockResolvedValue([]);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), null); // BootGate's "nobody"
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
// Let any fetch the mount would start get going before asserting it never happened.
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
// …and it asks as soon as an account signs in.
|
||||
act(() => {
|
||||
qc.setQueryData(qk.me(), { id: ID, preferences: {} });
|
||||
});
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith("feed"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
coercePlexFilters,
|
||||
plexFiltersSignature,
|
||||
type FeedFilters,
|
||||
type PlexFilters,
|
||||
type SavedView,
|
||||
} from "../../lib/api";
|
||||
import { filtersToParams, hasFilterParams } from "../../lib/urlState";
|
||||
import { pickDefaultToSeed, preferSticky } from "../../lib/savedViews";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { LS, ownKey } from "../../lib/storage";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { usePlex, usePlexActions } from "../plex/plexContext";
|
||||
import {
|
||||
asFilters,
|
||||
SavedViewsCtx,
|
||||
type SavedViewsContext,
|
||||
type ViewSource,
|
||||
} from "./savedViewsContext";
|
||||
|
||||
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
|
||||
// switches which module it operates on by the active page (both filter providers are global, so it can
|
||||
// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and
|
||||
// on the plex page against plex views without a second provider or an App-layout change.
|
||||
//
|
||||
// The context, its value type and the hook live in savedViewsContext.ts.
|
||||
|
||||
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
|
||||
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
|
||||
interface ViewConfig {
|
||||
source: ViewSource;
|
||||
filters: Record<string, unknown>;
|
||||
setFilters: (f: Record<string, unknown>) => void;
|
||||
keyOf: (f: Record<string, unknown>) => string;
|
||||
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
|
||||
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
|
||||
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
|
||||
* stored filters for the active account, and — feed only — no URL filter override. */
|
||||
canSeedDefault: () => boolean;
|
||||
}
|
||||
|
||||
const feedKeyOf = (f: Record<string, unknown>) =>
|
||||
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
||||
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
||||
|
||||
// True once this account has its own persisted filters for `lsKey` — the signal that the last
|
||||
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
||||
// report "stored" so a default never seeds blind over an indeterminate state.
|
||||
function storedFiltersPresent(lsKey: string): boolean {
|
||||
const key = ownKey(lsKey);
|
||||
if (!key) return true;
|
||||
try {
|
||||
return localStorage.getItem(key) != null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Feed also yields to a URL that carries filter params (a shared/bookmarked view).
|
||||
function feedCanSeedDefault(): boolean {
|
||||
if (hasFilterParams(new URLSearchParams(window.location.search))) return false;
|
||||
return !storedFiltersPresent(LS.filters);
|
||||
}
|
||||
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
||||
function plexCanSeedDefault(): boolean {
|
||||
return !storedFiltersPresent(LS.plexFilters);
|
||||
}
|
||||
|
||||
export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const { page } = useNavigation();
|
||||
|
||||
// Both filter providers are global — read both, then use the one the active page owns.
|
||||
const feedFilters = useFeedFilters();
|
||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||
const { filters: plexFilters } = usePlex();
|
||||
const { setFilters: setPlexFilters } = usePlexActions();
|
||||
|
||||
const cfg: ViewConfig = useMemo(() => {
|
||||
if (page === "plex") {
|
||||
return {
|
||||
source: "plex",
|
||||
filters: plexFilters as unknown as Record<string, unknown>,
|
||||
setFilters: (f) => setPlexFilters(f as unknown as PlexFilters),
|
||||
keyOf: plexKeyOf,
|
||||
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
|
||||
stripForSave: (f) => f, // Plex has no transient field to drop
|
||||
canSeedDefault: plexCanSeedDefault,
|
||||
};
|
||||
}
|
||||
return {
|
||||
source: "feed",
|
||||
filters: feedFilters as unknown as Record<string, unknown>,
|
||||
setFilters: (f) => setFeedFilters(f as unknown as FeedFilters),
|
||||
keyOf: feedKeyOf,
|
||||
asFilters: (raw) =>
|
||||
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
|
||||
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
||||
stripForSave: (f) => {
|
||||
const { seed: _seed, ...rest } = f;
|
||||
return rest;
|
||||
},
|
||||
canSeedDefault: feedCanSeedDefault,
|
||||
};
|
||||
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
|
||||
|
||||
const source = cfg.source;
|
||||
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
|
||||
// (the public landing), and saved views are per-account, so asking for "nobody's" views only logs
|
||||
// a 401 on the landing page. Enabled once a signed-in account is cached.
|
||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
const viewsQuery = useQuery({
|
||||
queryKey: qk.savedViews(source),
|
||||
queryFn: () => api.savedViews(source),
|
||||
enabled: !!me,
|
||||
});
|
||||
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
||||
|
||||
const [stickyId, setStickyId] = useState<number | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
|
||||
// and a feed modal shouldn't linger open on plex. Adjusted DURING RENDER against the previous
|
||||
// source rather than from an effect, so the first plex render never commits carrying the feed's
|
||||
// sticky id — which is exactly long enough for a stale view chip to paint.
|
||||
const [prevSource, setPrevSource] = useState(source);
|
||||
if (prevSource !== source) {
|
||||
setPrevSource(source);
|
||||
setStickyId(null);
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
|
||||
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
|
||||
// stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists
|
||||
// it, so it happens once and the normal last-view-wins path takes over, exactly as the storage
|
||||
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
|
||||
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
||||
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
||||
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
|
||||
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
|
||||
// state to the account (claimLocalData) before this provider mounts.
|
||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
|
||||
// fire on the views query's first resolve for a source and never again, which `seededRef`
|
||||
// enforces. Listing cfg would re-arm nothing (the guard still holds) but would re-run the effect
|
||||
// on every keystroke in the filter bar.
|
||||
const latestCfg = useEffectEvent(() => cfg);
|
||||
useEffect(() => {
|
||||
const data = viewsQuery.data;
|
||||
if (!data || seededRef.current.has(source)) return;
|
||||
seededRef.current.add(source);
|
||||
const c = latestCfg();
|
||||
const def = pickDefaultToSeed(data, c.canSeedDefault());
|
||||
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
|
||||
if (def) c.setFilters(c.asFilters(def.filters));
|
||||
}, [viewsQuery.data, source]);
|
||||
|
||||
const currentKey = cfg.keyOf(cfg.filters);
|
||||
const activeView = useMemo(
|
||||
() =>
|
||||
preferSticky(
|
||||
views.filter((v) => cfg.keyOf(v.filters) === currentKey),
|
||||
stickyId,
|
||||
),
|
||||
[views, currentKey, stickyId, cfg],
|
||||
);
|
||||
// The sticky id follows whichever view is active — also adjusted during render. It converges by
|
||||
// construction: preferSticky returns the sticky match when there is one, so the very write below
|
||||
// makes its own condition false on the next pass.
|
||||
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
|
||||
|
||||
const stickyView = useMemo(
|
||||
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
|
||||
[views, stickyId],
|
||||
);
|
||||
const displayView = activeView ?? stickyView;
|
||||
const isDirty = displayView != null && activeView == null;
|
||||
|
||||
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
||||
let ok = true;
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
notifyActionError(e, failMsg);
|
||||
ok = false;
|
||||
}
|
||||
refresh();
|
||||
return ok;
|
||||
}
|
||||
|
||||
const value: SavedViewsContext = {
|
||||
source,
|
||||
views,
|
||||
loading: viewsQuery.isLoading,
|
||||
activeViewId: activeView?.id ?? null,
|
||||
displayView,
|
||||
isDirty,
|
||||
modalOpen,
|
||||
openModal: () => setModalOpen(true),
|
||||
closeModal: () => setModalOpen(false),
|
||||
apply: (v) => {
|
||||
cfg.setFilters(cfg.asFilters(v.filters));
|
||||
setStickyId(v.id);
|
||||
},
|
||||
clearActive: () => setStickyId(null),
|
||||
create: async (name) => {
|
||||
const clean = name.trim();
|
||||
if (!clean) return false;
|
||||
return runViewMutation(
|
||||
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
||||
t("views.actionFailed"),
|
||||
);
|
||||
},
|
||||
rename: async (id, name) => {
|
||||
const next = name.trim();
|
||||
if (!next) return false;
|
||||
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
||||
},
|
||||
updateFilters: (id, filters) =>
|
||||
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
||||
saveCurrent: async (id) => {
|
||||
const filters = cfg.stripForSave(cfg.filters);
|
||||
const ok = await runViewMutation(
|
||||
() => api.updateView(id, { filters }),
|
||||
t("views.actionFailed"),
|
||||
);
|
||||
return ok ? filters : null;
|
||||
},
|
||||
toggleDefault: (v) =>
|
||||
runViewMutation(
|
||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||
t("views.actionFailed"),
|
||||
).then(() => undefined),
|
||||
remove: async (v) => {
|
||||
const ok = await confirm({
|
||||
message: t("views.deleteConfirm", { name: v.name }),
|
||||
confirmLabel: t("views.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return false;
|
||||
if (stickyId === v.id) setStickyId(null);
|
||||
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||
},
|
||||
reorder: async (nextIds) => {
|
||||
qc.setQueryData<SavedView[]>(
|
||||
qk.savedViews(source),
|
||||
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
||||
);
|
||||
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
||||
},
|
||||
};
|
||||
|
||||
return <SavedViewsCtx.Provider value={value}>{children}</SavedViewsCtx.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../../lib/panelLayout";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||
import TagManager from "../channels/TagManager";
|
||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||
import { type MediaSourceKind } from "../../lib/mediaSource";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
import { facetOption } from "./facetOption";
|
||||
import DateFilterSelect from "./DateFilterSelect";
|
||||
import YearFilter from "./YearFilter";
|
||||
import Combobox from "../../components/ui/Combobox";
|
||||
import MatchToggle from "../../components/ui/MatchToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useChannelsActions } from "../channels/channelsContext";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
import { compareText } from "../../lib/collate";
|
||||
|
||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
{ days: 1, key: "24h" },
|
||||
{ days: 7, key: "1week" },
|
||||
{ days: 14, key: "2weeks" },
|
||||
{ days: 30, key: "1month" },
|
||||
{ days: 90, key: "3months" },
|
||||
{ days: 180, key: "6months" },
|
||||
{ days: 365, key: "1year" },
|
||||
{ days: 730, key: "2years" },
|
||||
{ days: 1825, key: "5years" },
|
||||
];
|
||||
|
||||
export default function Sidebar({
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { openModal } = useSavedViews();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
|
||||
const { focusChannel: onFocusChannel } = useChannelsActions();
|
||||
const tagsQuery = useTags();
|
||||
const tags = tagsQuery.data ?? [];
|
||||
|
||||
// Live per-tag channel counts for the current filter context. Debounce the search term so
|
||||
// typing doesn't refire facets per keystroke, and keep previous counts during a refetch.
|
||||
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
|
||||
// Shared with the feed's count (same key + filters -> React Query dedupes to one request): the feed
|
||||
// reads `.count`, this reads `.facets` (PERF-13, replaces the separate facets query).
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: qk.feedSummary(facetFilters),
|
||||
queryFn: () => api.feedSummary(facetFilters),
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const facetsReady = !!summaryQuery.data;
|
||||
const facetCounts = summaryQuery.data?.facets ?? {};
|
||||
// Ordering falls back to the tag's whole-library count until the facets land; the number shown does
|
||||
// not (facetOption).
|
||||
const chipCount = (tag: Tag): number =>
|
||||
facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count;
|
||||
const visibleChips = (list: Tag[]): Tag[] => {
|
||||
const shown = facetsReady
|
||||
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id))
|
||||
: list;
|
||||
return [...shown].sort(
|
||||
(a, b) => chipCount(b) - chipCount(a) || compareText(a.name, b.name, lang),
|
||||
);
|
||||
};
|
||||
|
||||
const languages = tags.filter((t) => t.category === "language");
|
||||
const topics = tags.filter((t) => t.category === "topic");
|
||||
const userTags = tags.filter((t) => !t.system);
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
|
||||
// the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||
const source: MediaSourceKind = "feed";
|
||||
|
||||
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
|
||||
// only its own slice: its selection replaces the ids that belong to its list, leaving the others.
|
||||
const tagOption = (tg: Tag) => facetOption(tg, summaryQuery.data?.facets);
|
||||
const selectedIn = (list: Tag[]): string[] =>
|
||||
filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String);
|
||||
function setTagSelection(list: Tag[], next: string[]) {
|
||||
const inList = new Set(list.map((tg) => tg.id));
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
|
||||
});
|
||||
}
|
||||
// Each tag category (language/topic/other) has its OWN any/all match, set from the switch above its
|
||||
// picker (ui/MatchToggle); the store keeps the older or/and words.
|
||||
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
|
||||
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
|
||||
|
||||
// The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to
|
||||
// channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually
|
||||
// exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears
|
||||
// it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here.
|
||||
function userTagsCombo(): ReactNode {
|
||||
const noneOn = !!filters.untagged;
|
||||
const inList = (id: number) => userTags.some((tg) => tg.id === id);
|
||||
const onChange = (next: string[]) => {
|
||||
if (next.includes(NO_TAG_SENTINEL) && !noneOn) {
|
||||
setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true });
|
||||
} else {
|
||||
const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number);
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: [...filters.tags.filter((id) => !inList(id)), ...reals],
|
||||
untagged: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<MatchToggle
|
||||
picked={selectedIn(userTags).length}
|
||||
value={filters.tagModes.other === "and" ? "all" : "any"}
|
||||
onChange={(m) => setTagMode("other", m === "all" ? "and" : "or")}
|
||||
/>
|
||||
<Combobox
|
||||
label={t("sidebar.widget.tags")}
|
||||
options={[
|
||||
{ value: NO_TAG_SENTINEL, label: t("sidebar.noTagOption") },
|
||||
...visibleChips(userTags).map(tagOption),
|
||||
]}
|
||||
selected={noneOn ? [NO_TAG_SENTINEL] : selectedIn(userTags)}
|
||||
onChange={onChange}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
|
||||
// disagree about what's applied.
|
||||
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||
|
||||
const available: Record<string, boolean> = {
|
||||
date: true,
|
||||
year: true,
|
||||
channel: true,
|
||||
language: languages.length > 0,
|
||||
topic: topics.length > 0,
|
||||
tags: userTags.length > 0,
|
||||
};
|
||||
|
||||
function widgetBody(id: string): ReactNode {
|
||||
switch (id) {
|
||||
case "channel":
|
||||
return <ChannelPicker filters={filters} setFilters={setFilters} />;
|
||||
case "date":
|
||||
return (
|
||||
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
|
||||
);
|
||||
case "year":
|
||||
return <YearFilter filters={filters} setFilters={setFilters} />;
|
||||
case "language":
|
||||
return (
|
||||
<>
|
||||
<MatchToggle
|
||||
picked={selectedIn(languages).length}
|
||||
value={filters.tagModes.language === "and" ? "all" : "any"}
|
||||
onChange={(m) => setTagMode("language", m === "all" ? "and" : "or")}
|
||||
/>
|
||||
<Combobox
|
||||
label={t("sidebar.widget.language")}
|
||||
options={visibleChips(languages).map(tagOption)}
|
||||
selected={selectedIn(languages)}
|
||||
onChange={(next) => setTagSelection(languages, next)}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "topic":
|
||||
return (
|
||||
<>
|
||||
<MatchToggle
|
||||
picked={selectedIn(topics).length}
|
||||
value={filters.tagModes.topic === "and" ? "all" : "any"}
|
||||
onChange={(m) => setTagMode("topic", m === "all" ? "and" : "or")}
|
||||
/>
|
||||
<Combobox
|
||||
label={t("sidebar.widget.topic")}
|
||||
options={visibleChips(topics).map(tagOption)}
|
||||
selected={selectedIn(topics)}
|
||||
onChange={(next) => setTagSelection(topics, next)}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "tags":
|
||||
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
|
||||
// body is just the picker — user tags plus the "No tag" sentinel.
|
||||
return userTagsCombo();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Group ids come from the shared default order (panelLayout) so there's one source of truth;
|
||||
// the actual render order is the user's saved layout (PanelGroups orders by it).
|
||||
const items: PanelItem[] = defaultLayout(source)
|
||||
.order.filter((id) => available[id])
|
||||
.map((id) => ({
|
||||
id,
|
||||
title: t("sidebar.widget." + id),
|
||||
body: widgetBody(id),
|
||||
// The widgets aren't collapsible, so a widget's title can act as its own trigger: "Your tags"
|
||||
// opens the tag manager (replacing the old inline Manage button).
|
||||
onTitleClick: id === "tags" ? () => setTagManagerOpen(true) : undefined,
|
||||
titleActionLabel: id === "tags" ? t("sidebar.manageTags") : undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
// The feed widgets are simple label + control rows, not collapsible islands.
|
||||
collapsible={false}
|
||||
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
|
||||
// full manager modal. Feed rail only.
|
||||
iconExpanded={
|
||||
source === "feed" ? (
|
||||
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||||
) : undefined
|
||||
}
|
||||
onTitleClick={source === "feed" ? openModal : undefined}
|
||||
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
|
||||
>
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
)}
|
||||
</FilterRail>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The chip-overflow popovers are open/closed by a DERIVED flag (`openRaw && hidden > 0`), which is
|
||||
// what keeps a menu from outliving the "+N" trigger it is anchored to for a frame. The trap the
|
||||
// derivation sets is that it hides the surface without closing it: floating-ui only reports a close
|
||||
// for a real event on a mounted surface, so when the lane stops overflowing, the trigger and the
|
||||
// menu disappear while the raw flag stays true — and the next re-fit that overflows again brings
|
||||
// back a menu nobody asked for. `useChipOverflowFit` re-decides from a ResizeObserver, so that
|
||||
// second re-fit needs no user action at all.
|
||||
//
|
||||
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
|
||||
// measurement can only ever produce one answer.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: Record<string, unknown>) => (o?.count != null ? `${k}:${o.count}` : k),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const fit = { visible: 1 };
|
||||
vi.mock("../../lib/useChipOverflowFit", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/useChipOverflowFit")>();
|
||||
return { ...actual, useChipOverflowFit: () => fit.visible };
|
||||
});
|
||||
|
||||
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
|
||||
|
||||
const chip = (key: string): FilterChip => ({ key, label: key, active: false, onToggle: () => {} });
|
||||
const chips = [chip("alpha"), chip("beta"), chip("gamma")];
|
||||
|
||||
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
|
||||
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
|
||||
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
|
||||
const menu = () => screen.queryByRole("menu", { hidden: true });
|
||||
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
fit.visible = 1;
|
||||
});
|
||||
|
||||
describe("TagFilterChips — the overflow menu", () => {
|
||||
it("does not come back on its own once the lane stops overflowing", () => {
|
||||
const { rerender } = render(<TagFilterChips chips={chips} />);
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeTruthy();
|
||||
|
||||
// The lane widens (a resize, the text-size slider, the trailing Clear button unmounting) and
|
||||
// nothing is collapsed any more: both trigger and menu go.
|
||||
fit.visible = 3;
|
||||
rerender(<TagFilterChips chips={chips} />);
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toBeNull();
|
||||
|
||||
// …and it narrows again, with no user interaction in between. The menu must stay shut, and the
|
||||
// returning trigger must not claim to be expanded.
|
||||
fit.visible = 1;
|
||||
rerender(<TagFilterChips chips={chips} />);
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
|
||||
});
|
||||
|
||||
it("does not come back when the chip set SHRINKS under the open menu", () => {
|
||||
// The nastier half of the same bug, and it needs the hook's real timing to reproduce.
|
||||
// `useChipOverflowFit` keeps `visible` in state and only rewrites it from a LAYOUT EFFECT, so
|
||||
// the render in which the set shrinks still carries the measurement taken for the PREVIOUS,
|
||||
// longer set. `length - visible` is negative in that render, a `hidden === 0` guard reading it
|
||||
// unclamped never fires, and the flag survives the one window it exists to close.
|
||||
//
|
||||
// So `fit.visible` is left ALONE across the shrink — that staleness is the precondition, not a
|
||||
// convenience of the mock. Five chips of which three fit, then three of them are deleted in the
|
||||
// tag manager — so the shrink render computes `chips.length - visible` = 2 - 3 = -1.
|
||||
const five = ["alpha", "beta", "gamma", "delta", "epsilon"].map(chip);
|
||||
fit.visible = 3;
|
||||
const { rerender } = render(<TagFilterChips chips={five} />);
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeTruthy();
|
||||
|
||||
rerender(<TagFilterChips chips={five.slice(0, 2)} />); // `visible` is still the stale 3
|
||||
expect(menu()).toBeNull();
|
||||
|
||||
// …and now the layout effect lands: two long names, only one of them fits.
|
||||
fit.visible = 1;
|
||||
rerender(<TagFilterChips chips={five.slice(0, 2)} />);
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
|
||||
});
|
||||
|
||||
it("still opens and closes on the trigger", () => {
|
||||
render(<TagFilterChips chips={chips} />);
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeTruthy();
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fitChipsWithPill } from "../../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
|
||||
import { Chip } from "../../components/ui/Chip";
|
||||
import { chipClass } from "../../components/ui/chipStyles";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { ChipLabelPending } from "../../components/ui/Skeleton";
|
||||
|
||||
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
|
||||
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
|
||||
// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by
|
||||
// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions
|
||||
// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…"
|
||||
// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N".
|
||||
|
||||
export interface FilterChip {
|
||||
key: string;
|
||||
label: string;
|
||||
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
|
||||
pending?: boolean;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
|
||||
// measures what shows (ActiveFilterChips' ChipText, for this row's chips).
|
||||
function ChipText({ chip }: { chip: FilterChip }) {
|
||||
return chip.pending ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
|
||||
}
|
||||
|
||||
function TagChip({ chip }: { chip: FilterChip }) {
|
||||
return (
|
||||
<Chip onClick={chip.onToggle} pressed={chip.active}>
|
||||
<ChipText chip={chip} />
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagFilterChips({
|
||||
chips,
|
||||
className,
|
||||
}: {
|
||||
chips: FilterChip[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
|
||||
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
|
||||
const key = chips
|
||||
.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
|
||||
.join("|");
|
||||
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: key,
|
||||
initial: chips.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, chips.length - visible);
|
||||
// The popover can only be open while there is a "+N" trigger to anchor it. Derived rather than
|
||||
// closed by an effect, so the frame in which the last overflowing chip fits again cannot also
|
||||
// contain a menu hanging off a reference that is already gone.
|
||||
//
|
||||
// Deriving alone is NOT enough, and the missing half is the flag itself: floating-ui only reports
|
||||
// a close for a real event on a mounted surface, so when the lane stops overflowing the trigger
|
||||
// and the menu just disappear and `openRaw` stays true. The next re-fit that overflows again —
|
||||
// a window resize, the text-size slider, or the trailing Clear button mounting as a tag is
|
||||
// toggled; `useChipOverflowFit` re-decides from a ResizeObserver, with no user near this menu —
|
||||
// would then re-open a menu nobody asked for, with its trigger already `aria-expanded`. So the
|
||||
// flag is reset in RENDER against the same condition that hides it: convergent (the write
|
||||
// falsifies it) and, unlike an effect, gone before the frame is painted.
|
||||
const [openRaw, setOpen] = useState(false);
|
||||
if (openRaw && hidden === 0) setOpen(false);
|
||||
const open = openRaw && hidden > 0;
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
||||
|
||||
return (
|
||||
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
|
||||
read each chip's right edge, the rem gap and the pill width off real boxes. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
{chips.map((c, i) => (
|
||||
<span
|
||||
key={c.key}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<span className={chipClass()}>
|
||||
<ChipText chip={c} />
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={chipClass()}>
|
||||
+{chips.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
||||
{chips.slice(0, visible).map((c) => (
|
||||
<TagChip key={c.key} chip={c} />
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
|
||||
+{hidden}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={moreLabel}
|
||||
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{chips.slice(visible).map((c) => (
|
||||
<TagChip key={c.key} chip={c} />
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A video's ONE action list. The right-click and the card's "…" open the same menu, which
|
||||
// holds everything the card no longer shows (download, details, copy, hide, reset, pin) beside the
|
||||
// everyday toggles, and each row fires the same handler the card's own buttons do.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { Video } from "../../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
comfortPins: () => Promise.resolve({ items: [] }),
|
||||
downloadIndex: () => Promise.resolve({}),
|
||||
playlists: () => Promise.resolve([]),
|
||||
},
|
||||
};
|
||||
});
|
||||
const copyText = vi.fn((_: string) => Promise.resolve(true));
|
||||
vi.mock("../../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) }));
|
||||
|
||||
import { VideoMenu } from "./VideoMenu";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// The surface is visibility:hidden in jsdom (floating-ui never positions), so an item has no
|
||||
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
|
||||
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||
return found;
|
||||
};
|
||||
|
||||
const video = (extra: Partial<Video>): Video =>
|
||||
({
|
||||
id: "v1",
|
||||
title: "A video",
|
||||
channel_id: "c1",
|
||||
channel_title: "A channel",
|
||||
watch_url: "https://youtu.be/v1",
|
||||
status: "new",
|
||||
saved: false,
|
||||
position_seconds: 0,
|
||||
...extra,
|
||||
}) as Video;
|
||||
|
||||
function Feed({
|
||||
v,
|
||||
handlers,
|
||||
}: {
|
||||
v: Video;
|
||||
handlers: Parameters<typeof VideoMenu>[0]["handlers"];
|
||||
}) {
|
||||
const menu = useContextMenu<Video>();
|
||||
return (
|
||||
<>
|
||||
<div {...menu.triggerProps(v)}>card</div>
|
||||
<MoreButton
|
||||
label="more"
|
||||
expanded={menu.open && menu.target?.id === v.id}
|
||||
menuProps={menu.buttonProps(v)}
|
||||
/>
|
||||
<VideoMenu menu={menu} handlers={handlers} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function setup(v: Video) {
|
||||
const handlers = {
|
||||
onState: vi.fn(),
|
||||
onToggleSave: vi.fn(),
|
||||
onResetState: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
};
|
||||
const qc = new QueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<Feed v={v} handlers={handlers} />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { handlers };
|
||||
}
|
||||
const rightClick = () => fireEvent.contextMenu(screen.getByText("card"));
|
||||
const more = () => screen.getByRole("button", { name: "more" });
|
||||
|
||||
describe("a video's menu", () => {
|
||||
it("lists the whole action list for a fresh video, in its groups", () => {
|
||||
setup(video({}));
|
||||
rightClick();
|
||||
expect(labels()).toEqual([
|
||||
"card.play",
|
||||
"card.menu.openChannel",
|
||||
"card.markWatched",
|
||||
"card.saveForLater",
|
||||
"playlists.addToPlaylist",
|
||||
"downloads.menu.download",
|
||||
"card.info",
|
||||
"common.copy.title",
|
||||
"common.copy.link",
|
||||
"card.hide",
|
||||
"home.pin.add",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers Continue and the restart on a half-watched one, the way back and the reset", async () => {
|
||||
const { handlers } = setup(video({ position_seconds: 30, saved: true }));
|
||||
rightClick();
|
||||
expect(labels()).toContain("card.restartTitle");
|
||||
expect(labels()).toContain("card.menu.unsave");
|
||||
expect(labels()).toContain("card.resetState");
|
||||
fireEvent.click(item("card.restartTitle"));
|
||||
await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0));
|
||||
});
|
||||
|
||||
it("each toggle fires the card's own handler", async () => {
|
||||
const { handlers } = setup(video({ status: "watched" }));
|
||||
rightClick();
|
||||
fireEvent.click(item("card.menu.unmarkWatched"));
|
||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
|
||||
rightClick();
|
||||
fireEvent.click(item("card.hide"));
|
||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "hidden"));
|
||||
});
|
||||
|
||||
it("copies the link INSIDE the click, before the menu closes (Safari's gesture rule)", () => {
|
||||
setup(video({}));
|
||||
rightClick();
|
||||
fireEvent.click(item("common.copy.link"));
|
||||
// Synchronously: a row that went through closeThenAct would run a task later.
|
||||
expect(copyText).toHaveBeenCalledWith("https://youtu.be/v1");
|
||||
expect(labels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens the playlists panel from the menu, anchored where the menu came from", async () => {
|
||||
setup(video({}));
|
||||
rightClick();
|
||||
fireEvent.click(item("playlists.addToPlaylist"));
|
||||
expect(await screen.findByText("playlists.noneYet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("the '…' opens the same menu, says so, and a second press closes it", () => {
|
||||
setup(video({}));
|
||||
expect(more().getAttribute("aria-haspopup")).toBe("menu");
|
||||
expect(more().getAttribute("aria-expanded")).toBe("false");
|
||||
fireEvent.click(more());
|
||||
expect(labels()).toContain("card.hide");
|
||||
expect(more().getAttribute("aria-expanded")).toBe("true");
|
||||
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
|
||||
// it must not open it again.
|
||||
fireEvent.pointerDown(more(), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(more());
|
||||
fireEvent.click(more(), { detail: 1 });
|
||||
expect(labels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a keyboard click on the '…' always opens, whatever an earlier press left behind", () => {
|
||||
setup(video({}));
|
||||
fireEvent.click(more(), { detail: 1 });
|
||||
// A press on the open row's "…" that never became a click (released elsewhere)…
|
||||
fireEvent.pointerDown(more(), { pointerType: "mouse" });
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// …then Enter on the button: a click with no pointer (detail 0).
|
||||
fireEvent.click(more(), { detail: 0 });
|
||||
expect(labels()).toContain("card.hide");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Video } from "../../lib/api";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { type ContextMenuController } from "../../components/ui/useContextMenu";
|
||||
import { useVideoActions, type VideoActionHandlers } from "../../features/video/useVideoActions";
|
||||
|
||||
/** A video list's one menu (the feed grid and a channel page): every trigger — right-click,
|
||||
* long-press, Shift+F10 and each card's "…" — opens it with the video's whole action list. Render
|
||||
* ONE per list, outside the cards (see `ui/ContextMenu`). */
|
||||
export function VideoMenu({
|
||||
menu,
|
||||
handlers,
|
||||
}: {
|
||||
menu: ContextMenuController<Video>;
|
||||
handlers: VideoActionHandlers;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const actions = useVideoActions(handlers, menu.open);
|
||||
const v = menu.target;
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("card.menu.label")}
|
||||
items={v ? actions.items(v, menu.anchor) : []}
|
||||
/>
|
||||
{actions.panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Video, VideoStatus } from "../../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../../lib/feedView";
|
||||
import type { NEXT_PAGE } from "../../lib/nextPage";
|
||||
import VideoCard from "../../features/video/VideoCard";
|
||||
import { VideoMenu } from "./VideoMenu";
|
||||
import type { VideoActionHandlers } from "../../features/video/useVideoActions";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import VirtualGrid from "../../components/ui/VirtualGrid";
|
||||
|
||||
// Estimated row heights before measurement; real heights are measured per-row so these only affect
|
||||
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
|
||||
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
|
||||
// RE-MEASURE these when a mode's layout changes: they have gone stale twice already, most recently
|
||||
// when the action row moved onto the card's picture (cards 340 -> 262, small 257 -> 226).
|
||||
// The card figures are the roughest of the set by nature — a card's height follows its column width
|
||||
// (262 measured at 4 columns in a 1268px window), so no single constant is right for every window.
|
||||
const ROW_EST: Record<FeedView, number> = {
|
||||
cards: 262,
|
||||
cardsSmall: 226,
|
||||
rows: 117,
|
||||
rowsCompact: 46,
|
||||
tiles: 138,
|
||||
};
|
||||
|
||||
export interface VirtualFeedProps {
|
||||
items: Video[];
|
||||
view: FeedView;
|
||||
onState: (id: string, status: VideoStatus) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onResetState: (id: string) => void;
|
||||
onOpen: (v: Video, startAt?: number | null) => void;
|
||||
/** The card menu's channel-filter row (see `useVideoActions`); omitted where it has no place. */
|
||||
channelFilter?: VideoActionHandlers["channelFilter"];
|
||||
hasNextPage: boolean;
|
||||
isFetching: boolean;
|
||||
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
|
||||
}
|
||||
|
||||
// The feed's videos on the shared VirtualGrid engine — this wrapper only maps a FeedView to the
|
||||
// grid's layout spec and renders a VideoCard per item. All the virtualization mechanics (measure,
|
||||
// chunk, place, infinite-scroll) live in VirtualGrid.
|
||||
export default function VirtualFeed({
|
||||
items,
|
||||
view,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onResetState,
|
||||
onOpen,
|
||||
channelFilter,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
fetchNextPage,
|
||||
}: VirtualFeedProps) {
|
||||
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
|
||||
// The card menu (the video's whole action list) lives HERE rather than in each of the
|
||||
// feed's callers: this is the list, and `useContextMenu`'s contract is one menu per list. Mounting
|
||||
// it at this level is also what gives the channel page the menu for free — it renders
|
||||
// `<Feed channelScoped>`, so it is the same component all the way down.
|
||||
const menu = useContextMenu<Video>();
|
||||
const openId = menu.open ? menu.target?.id : undefined;
|
||||
return (
|
||||
<>
|
||||
<VirtualGrid
|
||||
items={items}
|
||||
getKey={(v) => v.id}
|
||||
minCol={minCol}
|
||||
colMaxWidth={colMaxWidth}
|
||||
maxCols={maxCols}
|
||||
rowEst={ROW_EST[view]}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetching={isFetching}
|
||||
fetchNextPage={fetchNextPage}
|
||||
keyboardNav
|
||||
renderItem={(v, itemWidth, index) => (
|
||||
<VideoCard
|
||||
video={v}
|
||||
view={view}
|
||||
navIndex={index}
|
||||
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
||||
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
||||
width={thumb ? 0 : itemWidth}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpen={onOpen}
|
||||
menuProps={menu.triggerProps}
|
||||
menuButtonProps={menu.buttonProps}
|
||||
// A boolean per card, not the open id: only the two cards whose answer changes re-render.
|
||||
menuOpen={openId === v.id}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<VideoMenu
|
||||
menu={menu}
|
||||
handlers={{ onState, onToggleSave, onResetState, onOpen, channelFilter }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { MenuCheck } from "../../components/ui/MenuCheck";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { api, type FeedFilters, type YearCount } from "../../lib/api";
|
||||
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { Chip } from "../../components/ui/Chip";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import CountBadge from "../../components/ui/CountBadge";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The feed rail's "Year" control: quick chips for the most recent years plus a
|
||||
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
|
||||
// filters the feed to that calendar year (a whole-year published_after/before range, applied in the
|
||||
// api layer); it's a single-select toggle (click the active year to clear it) and mutually exclusive
|
||||
// with the Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
|
||||
//
|
||||
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
|
||||
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
|
||||
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
|
||||
// own category.
|
||||
|
||||
const QUICK = 6; // recent-year chips shown before the "More…" overflow
|
||||
// One shared empty list while the counts load: a fresh `[]` per render made the memos below recompute.
|
||||
const NO_YEARS: YearCount[] = [];
|
||||
|
||||
export default function YearFilter({
|
||||
filters,
|
||||
setFilters,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sortByCount, setSortByCount] = useState(false);
|
||||
|
||||
// Debounce the search term (like the sibling summary facet in Sidebar) so typing doesn't refire the
|
||||
// per-year count query on every keystroke — each distinct q is a fresh cache key, so without this
|
||||
// an N-char query would issue N /feed/years requests. The year/date narrowings are dropped: this
|
||||
// list is a live drill-down that must not change when a year (or upload-date) is picked.
|
||||
const facetFilters: FeedFilters = {
|
||||
...filters,
|
||||
q: useDebounced(filters.q, 300),
|
||||
year: undefined,
|
||||
maxAgeDays: undefined,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
};
|
||||
const yearsQuery = useQuery({
|
||||
queryKey: qk.feedYears(facetFilters),
|
||||
queryFn: () => api.feedYears(facetFilters),
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const years = yearsQuery.data?.years ?? NO_YEARS;
|
||||
const selected = filters.year;
|
||||
|
||||
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
|
||||
const pick = (year?: number) =>
|
||||
setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined });
|
||||
|
||||
// Quick chips = the most recent years, plus the selected year if it falls outside that window (so the
|
||||
// active pick is always visible), re-sorted newest-first.
|
||||
const quick = useMemo(() => {
|
||||
const top = years.slice(0, QUICK);
|
||||
if (selected != null && !top.some((y) => y.year === selected)) {
|
||||
const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 };
|
||||
return [...top, sel].sort((a, b) => b.year - a.year);
|
||||
}
|
||||
return top;
|
||||
}, [years, selected]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...years].sort((a, b) =>
|
||||
sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year,
|
||||
),
|
||||
[years, sortByCount],
|
||||
);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
|
||||
if (years.length === 0) {
|
||||
if (yearsQuery.isLoading) {
|
||||
return (
|
||||
<p className="text-muted px-1 py-1.5 text-xs" role="status" aria-live="polite">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (yearsQuery.isError) {
|
||||
return (
|
||||
<p className="text-muted px-1 py-1.5 text-xs" role="alert">
|
||||
{t("common.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <p className="text-muted px-1 py-1.5 text-xs">{t("sidebar.year.none")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{quick.map((y) => {
|
||||
const on = selected === y.year;
|
||||
return (
|
||||
<Chip key={y.year} pressed={on} onClick={() => pick(on ? undefined : y.year)}>
|
||||
<span className="tabular-nums">{y.year}</span>
|
||||
{y.count > 0 && <CountBadge count={y.count} active={on} />}
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
|
||||
{years.length > quick.length && (
|
||||
<>
|
||||
<Chip
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={t("sidebar.year.moreLabel")}
|
||||
>
|
||||
<span>{t("sidebar.year.more")}</span>
|
||||
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Chip>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("sidebar.year.moreLabel")}
|
||||
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
|
||||
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(
|
||||
[
|
||||
["sortYear", false],
|
||||
["sortCount", true],
|
||||
] as const
|
||||
).map(([key, v]) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
pressed={sortByCount === v}
|
||||
onClick={() => setSortByCount(v)}
|
||||
>
|
||||
{t("sidebar.year." + key)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="max-h-64 overflow-y-auto no-scrollbar"
|
||||
role="group"
|
||||
aria-label={t("sidebar.year.moreLabel")}
|
||||
>
|
||||
{sorted.map((y) => {
|
||||
const on = selected === y.year;
|
||||
return (
|
||||
<PopoverItem
|
||||
key={y.year}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => {
|
||||
pick(on ? undefined : y.year);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={String(y.year)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<MenuCheck kind="one" on={on} />
|
||||
<span className="flex-1 tabular-nums">{y.year}</span>
|
||||
<span className="text-2xs text-muted shrink-0 tabular-nums">{y.count}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A channel filter's chips are named from the subscription list, which a reload has not fetched yet:
|
||||
// they used to read as raw UC… ids for a moment (the owner, 2026-10-03). The names seen last time now
|
||||
// name them until the list lands; a channel never named on this browser draws a placeholder instead.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { api, type FeedFilters, type ManagedChannel } from "../../lib/api";
|
||||
import { claimLocalData, LS } from "../../lib/storage";
|
||||
import { clearedFilters, useActiveFilters } from "../../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const KNOWN = "UCknown0000000000000000";
|
||||
const NEW = "UCnever00000000000000000";
|
||||
const filters: FeedFilters = {
|
||||
...clearedFilters({ q: "" } as FeedFilters),
|
||||
channelIds: [KNOWN, NEW],
|
||||
};
|
||||
const namesKey = () => LS.channelNames;
|
||||
|
||||
let resolveChannels: (c: ManagedChannel[]) => void;
|
||||
let qc: QueryClient;
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: qc }, children);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(5);
|
||||
localStorage.setItem(namesKey(), JSON.stringify({ [KNOWN]: "Remembered name" }));
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
vi.spyOn(api, "channels").mockReturnValue(
|
||||
new Promise<ManagedChannel[]>((r) => {
|
||||
resolveChannels = r;
|
||||
}),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const land = async () => {
|
||||
await act(async () => {
|
||||
resolveChannels([
|
||||
{ id: KNOWN, title: "Renamed since" },
|
||||
{ id: NEW, title: "Brand new" },
|
||||
] as ManagedChannel[]);
|
||||
});
|
||||
};
|
||||
|
||||
describe("a channel filter's chip names", () => {
|
||||
it("name a remembered channel before the list lands, and hold the rest as pending", async () => {
|
||||
const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
|
||||
const chip = (id: string) => result.current.find((f) => f.key === `channel:${id}`)!;
|
||||
expect(chip(KNOWN)).toMatchObject({ label: "Remembered name", pending: false });
|
||||
expect(chip(NEW)).toMatchObject({ label: NEW, pending: true });
|
||||
|
||||
await land();
|
||||
await waitFor(() => expect(chip(NEW)).toMatchObject({ label: "Brand new", pending: false }));
|
||||
// The list wins over the remembered copy, and replaces it for the next reload.
|
||||
expect(chip(KNOWN).label).toBe("Renamed since");
|
||||
expect(JSON.parse(localStorage.getItem(namesKey())!)).toEqual({
|
||||
[KNOWN]: "Renamed since",
|
||||
[NEW]: "Brand new",
|
||||
});
|
||||
});
|
||||
|
||||
it("draw a placeholder above the feed, not the raw id", () => {
|
||||
const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
|
||||
const { container } = render(
|
||||
<ActiveFilterChips filters={result.current} onClearAll={() => {}} />,
|
||||
);
|
||||
expect(screen.getAllByText("Remembered name").length).toBeGreaterThan(0);
|
||||
// The id is only a screen reader's (sr-only), beside a bone; nothing paints it.
|
||||
for (const el of screen.getAllByText(NEW)) expect(el.className).toBe("sr-only");
|
||||
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("draw a placeholder in the filter panel's picker, then the name", async () => {
|
||||
const { container } = render(<ChannelPicker filters={filters} setFilters={() => {}} />, {
|
||||
wrapper,
|
||||
});
|
||||
screen.getByText("Remembered name");
|
||||
expect(screen.getByText(NEW).className).toBe("sr-only");
|
||||
expect(container.querySelectorAll(".animate-pulse")).toHaveLength(1);
|
||||
|
||||
await land();
|
||||
await waitFor(() => screen.getByText("Brand new"));
|
||||
expect(screen.queryByText(NEW)).toBeNull();
|
||||
expect(container.querySelectorAll(".animate-pulse")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fall back to the id when the list fails, so each chip stays told apart", async () => {
|
||||
localStorage.removeItem(namesKey());
|
||||
vi.spyOn(api, "channels").mockRejectedValue(new Error("down"));
|
||||
const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
|
||||
await waitFor(() =>
|
||||
expect(result.current.find((f) => f.key === `channel:${NEW}`)).toMatchObject({
|
||||
label: NEW,
|
||||
pending: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Tag } from "../../lib/api";
|
||||
import { facetOption } from "./facetOption";
|
||||
|
||||
const music = { id: 7, name: "Music", category: "topic", channel_count: 238 } as Tag;
|
||||
|
||||
describe("a tag as a filter row", () => {
|
||||
it("counts the current filter's channels", () => {
|
||||
expect(facetOption(music, { "7": 3 }).count).toBe(3);
|
||||
expect(facetOption(music, {}).count).toBe(0);
|
||||
});
|
||||
|
||||
// Seen live: with the summary failed, the rail read "Music 238" beside a search that matched
|
||||
// 3 Music channels — the whole library's count printed as the filter's.
|
||||
it("shows no count until the facets land, rather than the whole library's", () => {
|
||||
expect(facetOption(music, undefined).count).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Tag } from "../../lib/api";
|
||||
import type { ComboboxOption } from "../../components/ui/Combobox";
|
||||
|
||||
// A tag as a filter row: its count is the CURRENT filter's (the summary's facets), or none at all while
|
||||
// the facets load or after they failed. The rail printed the tag's whole-library count in that gap, as
|
||||
// if it were the filter's: "Music 238" beside a search that matched 3 of its channels.
|
||||
export const facetOption = (
|
||||
tag: Tag,
|
||||
facets: Record<string, number> | undefined,
|
||||
): ComboboxOption => ({
|
||||
value: String(tag.id),
|
||||
label: tag.name,
|
||||
count: facets ? (facets[String(tag.id)] ?? 0) : undefined,
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { FeedFilters } from "../../lib/api";
|
||||
import { DEFAULT_FILTERS } from "./feedFiltersInit";
|
||||
|
||||
// The feed-filter contexts + hooks, split out of FeedFiltersProvider so that file exports only
|
||||
// components (Fast Refresh).
|
||||
//
|
||||
// TWO contexts on purpose (mirrors NavigationProvider): a STABLE actions context (setFilters never
|
||||
// changes reference) and a CHANGING state context (the filters object). Context consumers re-render
|
||||
// only when the context they read changes — so a component that dispatches filter changes without
|
||||
// reading the current filters can subscribe to actions ONLY and skip re-rendering on every tweak.
|
||||
|
||||
export interface FeedFiltersActions {
|
||||
setFilters: (next: FeedFilters) => void;
|
||||
}
|
||||
|
||||
export const FeedFiltersStateContext = createContext<FeedFilters>(DEFAULT_FILTERS);
|
||||
export const FeedFiltersActionsContext = createContext<FeedFiltersActions>({
|
||||
setFilters: () => {},
|
||||
});
|
||||
|
||||
export function useFeedFilters(): FeedFilters {
|
||||
return useContext(FeedFiltersStateContext);
|
||||
}
|
||||
export function useFeedFiltersActions(): FeedFiltersActions {
|
||||
return useContext(FeedFiltersActionsContext);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
|
||||
import { LS, ownKey, readJSON } from "../../lib/storage";
|
||||
|
||||
// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider
|
||||
// (like navigationInit / playlistsInit) so that file exports only components (Fast Refresh).
|
||||
|
||||
export const DEFAULT_FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
/** The watch-state choices, in the order the filter offers them. */
|
||||
export const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
|
||||
|
||||
export const FILTERS_KEY = LS.filters;
|
||||
|
||||
// This account's own persisted filters (see ownKey). No account
|
||||
// (signed out) → defaults.
|
||||
export function loadAccountFilters(): FeedFilters {
|
||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
||||
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
|
||||
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
||||
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
|
||||
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
|
||||
const fKey = ownKey(FILTERS_KEY);
|
||||
let hasStored = false;
|
||||
try {
|
||||
hasStored = !!fKey && localStorage.getItem(fKey) != null;
|
||||
} catch {
|
||||
/* localStorage may be unavailable */
|
||||
}
|
||||
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes`, matching what the
|
||||
// saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes MUST run on the
|
||||
// RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects DEFAULT_TAG_MODES
|
||||
// (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
|
||||
if (hasStored && fKey) {
|
||||
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
|
||||
return {
|
||||
...DEFAULT_FILTERS,
|
||||
...raw,
|
||||
tagModes: coerceTagModes(raw),
|
||||
untagged: coerceUntagged(raw),
|
||||
};
|
||||
}
|
||||
return { ...DEFAULT_FILTERS };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { FeedView } from "../../lib/feedView";
|
||||
|
||||
// The feed view-mode contexts + hooks, split out of FeedViewProvider so that file exports only
|
||||
// components (Fast Refresh).
|
||||
|
||||
export interface FeedViewActions {
|
||||
setView: (v: FeedView) => void;
|
||||
}
|
||||
|
||||
export const FeedViewStateContext = createContext<FeedView>("cards");
|
||||
export const FeedViewActionsContext = createContext<FeedViewActions>({ setView: () => {} });
|
||||
|
||||
export function useFeedView(): FeedView {
|
||||
return useContext(FeedViewStateContext);
|
||||
}
|
||||
export function useFeedViewActions(): FeedViewActions {
|
||||
return useContext(FeedViewActionsContext);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
|
||||
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
|
||||
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
|
||||
// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it.
|
||||
export const Feed = lazyWithPreload(() => import("./Feed"));
|
||||
|
||||
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
|
||||
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
|
||||
export function FeedPage() {
|
||||
const me = useMe();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
const view = useFeedView();
|
||||
const { setView } = useFeedViewActions();
|
||||
const { ytSearch } = useNavigation();
|
||||
const { enterYtSearch, exitYtSearch } = useNavigationActions();
|
||||
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
|
||||
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
|
||||
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
|
||||
const { modalOpen } = useSavedViews();
|
||||
return (
|
||||
<>
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
ytSearch={ytSearch}
|
||||
onYtSearch={enterYtSearch}
|
||||
onExitYtSearch={exitYtSearch}
|
||||
/>
|
||||
{modalOpen && <PersonalViewsModal />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { coerceTagModes, coerceUntagged, type FeedFilters, type SavedView } from "../../lib/api";
|
||||
|
||||
// The saved-views context + hook (and the feed blob coercion its consumers share), split out of
|
||||
// SavedViewsProvider so that file exports only components (Fast Refresh).
|
||||
|
||||
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
|
||||
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
|
||||
export const asFilters = (raw: FeedFilters): FeedFilters => ({
|
||||
...raw,
|
||||
tags: raw.tags ?? [],
|
||||
channelIds: raw.channelIds ?? [],
|
||||
tagModes: coerceTagModes(raw),
|
||||
untagged: coerceUntagged(raw),
|
||||
});
|
||||
|
||||
export type ViewSource = "feed" | "plex";
|
||||
|
||||
export interface SavedViewsContext {
|
||||
/** Which module these views belong to — the shared UI branches its filter-describer on it. */
|
||||
source: ViewSource;
|
||||
views: SavedView[];
|
||||
loading: boolean;
|
||||
activeViewId: number | null;
|
||||
displayView: SavedView | null;
|
||||
isDirty: boolean;
|
||||
modalOpen: boolean;
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
apply: (v: SavedView) => void;
|
||||
clearActive: () => void;
|
||||
create: (name: string) => Promise<boolean>;
|
||||
rename: (id: number, name: string) => Promise<boolean>;
|
||||
/** Overwrite a view's captured filters (used by the modal's removable chips). */
|
||||
updateFilters: (id: number, filters: Record<string, unknown>) => Promise<boolean>;
|
||||
/** Save the module's CURRENTLY-applied filters into a view (the live→stored inverse of `apply`);
|
||||
* resolves to the saved snapshot on success (so the caller can resync its editor) or null. */
|
||||
saveCurrent: (id: number) => Promise<Record<string, unknown> | null>;
|
||||
toggleDefault: (v: SavedView) => Promise<void>;
|
||||
remove: (v: SavedView) => Promise<boolean>;
|
||||
reorder: (nextIds: number[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SavedViewsCtx = createContext<SavedViewsContext | null>(null);
|
||||
|
||||
export function useSavedViews(): SavedViewsContext {
|
||||
const ctx = useContext(SavedViewsCtx);
|
||||
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Scissors } from "lucide-react";
|
||||
import type { LibraryFile } from "../../lib/api";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
import { JobThumb } from "../../features/downloads/downloadRow";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
||||
import { MediaCard, MediaPlayGlyph } from "../../components/ui/MediaCard";
|
||||
import {
|
||||
MEDIA_BADGE,
|
||||
MEDIA_BAR,
|
||||
MEDIA_HOVER_LAYER,
|
||||
MEDIA_PICTURE,
|
||||
mediaPictureBox,
|
||||
mediaTitleClass,
|
||||
} from "../../components/ui/mediaCardStyles";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
|
||||
// A library file in the Files grid: the one media card (ui/MediaCard) — its picture plays it (a file
|
||||
// that is not media: opens it in the viewer), the title under it, then where it came from and what
|
||||
// needs saying about it (kept, leaving soon). The
|
||||
// right click, the long press and the "…" open the same menu as its table row (DESIGN.md "Files page":
|
||||
// the grid is the list's other paint, a toggle remembered per folder).
|
||||
|
||||
export function FileCard({
|
||||
file,
|
||||
index,
|
||||
onOpen,
|
||||
menuProps,
|
||||
moreProps,
|
||||
menuOpen,
|
||||
arrived,
|
||||
}: {
|
||||
file: LibraryFile;
|
||||
/** Its place on the page, for the keyboard walk. */
|
||||
index: number;
|
||||
/** Plays it, or opens it in the viewer. Null: a shared file its owner keeps from me. */
|
||||
onOpen: (() => void) | null;
|
||||
menuProps: Record<string, unknown>;
|
||||
moreProps: Record<string, unknown>;
|
||||
menuOpen: boolean;
|
||||
/** The file a link or a notice opened the page on. */
|
||||
arrived: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const title = jobTitle(file);
|
||||
const channel = file.display_uploader || file.asset?.uploader || null;
|
||||
const clip = file.job_kind === "edit" || file.job_kind === "clip";
|
||||
const media = file.asset?.media_kind !== "file";
|
||||
const thumb = <JobThumb job={file} box="h-full w-full" />;
|
||||
return (
|
||||
<MediaCard
|
||||
dense
|
||||
rootProps={{ ...menuProps, ...navItemAttrs(index, title), "data-file-id": file.id }}
|
||||
className={clsx(arrived && "ring-2 ring-accent/60")}
|
||||
picture={
|
||||
<div className={mediaPictureBox("wide")}>
|
||||
{onOpen ? (
|
||||
<Pressable
|
||||
onClick={onOpen}
|
||||
aria-label={t(media ? "downloads.actions.play" : "files.actions.view")}
|
||||
data-nav-open=""
|
||||
className={MEDIA_PICTURE}
|
||||
>
|
||||
{thumb}
|
||||
{media && (
|
||||
<div className={MEDIA_HOVER_LAYER}>
|
||||
<MediaPlayGlyph small />
|
||||
</div>
|
||||
)}
|
||||
</Pressable>
|
||||
) : (
|
||||
<div className={clsx(MEDIA_PICTURE, "cursor-default")}>{thumb}</div>
|
||||
)}
|
||||
{clip && (
|
||||
<span
|
||||
className={clsx(MEDIA_BADGE, "left-1.5 top-1.5 inline-flex items-center gap-0.5")}
|
||||
>
|
||||
<Scissors className="size-3" aria-hidden />
|
||||
{t("downloads.clipBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className={MEDIA_BAR}>
|
||||
{/* `media`: legible on any picture, the Plex cards' hover row's look. */}
|
||||
<MoreButton
|
||||
variant="media"
|
||||
label={t("files.rowMenu")}
|
||||
expanded={menuOpen}
|
||||
menuProps={moreProps}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<span className={mediaTitleClass(true)} title={title}>
|
||||
{title}
|
||||
</span>
|
||||
}
|
||||
meta={
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
|
||||
<SourceBadge file={file} />
|
||||
{channel && <span className="truncate">{channel}</span>}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<PhoneBadge file={file} />
|
||||
<RetentionBadge file={file} quiet />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
more={{ props: moreProps, open: menuOpen }}
|
||||
moreLabel={t("files.rowMenu")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Files viewer: what each kind of file shows (an image, a PDF in a frame, text as text — HTML too,
|
||||
// never a page — or the "no preview" card), ← / → through the files it was given in their order,
|
||||
// and closing when the file it shows leaves the list. 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, HttpError, type LibraryFile } from "../../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, o?: Record<string, unknown>) =>
|
||||
o && "n" in o ? `${key}:${String(o.n)}/${String(o.total)}` : key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import FilePreview from "./FilePreview";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
|
||||
const file = (id: number, preview: string | null, extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id,
|
||||
status: "done",
|
||||
display_name: `File ${id}`,
|
||||
can_download: true,
|
||||
job_kind: "upload",
|
||||
shared: false,
|
||||
source_key: "upload",
|
||||
asset: { media_kind: "file", preview, container: "x", size_bytes: 4096, ...extra },
|
||||
}) as unknown as LibraryFile;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mount(files: LibraryFile[], fileId: number, onClose = vi.fn()) {
|
||||
const onFileChange = vi.fn();
|
||||
const view = (list: LibraryFile[], id: number) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FilePreview
|
||||
files={list}
|
||||
fileId={id}
|
||||
onFileChange={onFileChange}
|
||||
onClose={onClose}
|
||||
menuItems={() => []}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const r = render(view(files, fileId));
|
||||
return {
|
||||
onFileChange,
|
||||
onClose,
|
||||
rerender: (l: LibraryFile[], id: number) => r.rerender(view(l, id)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("FilePreview · what it shows", () => {
|
||||
it("an image as an image, from the sandboxed preview", () => {
|
||||
mount([file(1, "image")], 1);
|
||||
const img = screen.getByRole("img", { name: "File 1" });
|
||||
expect(img.getAttribute("src")).toBe(api.previewUrl(1));
|
||||
});
|
||||
|
||||
it("a PDF in a frame of the preview, the browser's own viewer", () => {
|
||||
mount([file(2, "pdf")], 2);
|
||||
const frame = document.querySelector("iframe")!;
|
||||
expect(frame.getAttribute("src")).toBe(api.previewUrl(2));
|
||||
expect(frame.title).toBe("File 2");
|
||||
});
|
||||
|
||||
it("a page's source as text, line by line — never as a page", async () => {
|
||||
vi.spyOn(api, "previewText").mockResolvedValue({
|
||||
text: "<h1 id=x>hi</h1>\n<script>boom()</script>",
|
||||
truncated: false,
|
||||
});
|
||||
mount([file(3, "text")], 3);
|
||||
await screen.findByText("<h1 id=x>hi</h1>");
|
||||
expect(screen.getByText("<script>boom()</script>")).toBeTruthy();
|
||||
expect(document.getElementById("x")).toBeNull();
|
||||
expect(document.querySelector("script")).toBeNull();
|
||||
expect(screen.getByText("files.preview.asText")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("numbers a file's lines, the newline that ends it making no line of its own", async () => {
|
||||
vi.spyOn(api, "previewText").mockResolvedValue({ text: "one\r\ntwo\n", truncated: false });
|
||||
mount([file(8, "text")], 8);
|
||||
await screen.findByText("one");
|
||||
expect([...document.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
|
||||
"1one",
|
||||
"2two",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lays out a long text's first lines only, and says where the rest is", async () => {
|
||||
// Every line is a row of the page: a log of tens of thousands would stall the viewer.
|
||||
const text = Array.from({ length: 6000 }, (_, i) => `line ${i + 1}`).join(
|
||||
String.fromCharCode(10),
|
||||
);
|
||||
vi.spyOn(api, "previewText").mockResolvedValue({ text, truncated: false });
|
||||
mount([file(9, "text")], 9);
|
||||
await screen.findByText("line 1");
|
||||
expect(document.querySelectorAll("li")).toHaveLength(5000);
|
||||
expect(screen.getByText(/files\.preview\.firstLines/)).toBeTruthy();
|
||||
expect(screen.getByText("files.preview.downloadWhole").closest("a")!.getAttribute("href")).toBe(
|
||||
api.fileUrl(9),
|
||||
);
|
||||
});
|
||||
|
||||
it("says when it shows only the start of a long text", async () => {
|
||||
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a", truncated: true });
|
||||
mount([file(4, "text")], 4);
|
||||
expect(await screen.findByText("files.preview.truncated")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a text the server refuses (binary after all) gets the no-preview card, not an error", async () => {
|
||||
vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(415, "x", {}));
|
||||
mount([file(5, "text")], 5);
|
||||
expect(await screen.findByText("files.preview.none")).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
// A new tab would only show the refusal.
|
||||
expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull();
|
||||
});
|
||||
|
||||
it("a text that fails to load says so, with a retry", async () => {
|
||||
vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(500, "x", {}));
|
||||
mount([file(6, "text")], 6);
|
||||
expect(await screen.findByRole("alert", {}, { timeout: 3000 })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "common.retry" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("anything else: the card that offers the file itself", () => {
|
||||
mount([file(7, null)], 7);
|
||||
expect(screen.getByText("files.preview.none")).toBeTruthy();
|
||||
const save = screen.getAllByRole("link", { name: "downloads.actions.download" });
|
||||
expect(save.every((a) => a.getAttribute("href") === api.fileUrl(7))).toBe(true);
|
||||
// Nothing to open in a tab: the preview would refuse it.
|
||||
expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilePreview · stepping", () => {
|
||||
const list = [file(1, "image"), file(2, "pdf"), file(3, null)];
|
||||
|
||||
it("← / → go to the files beside it, in the order given", () => {
|
||||
const { onFileChange } = mount(list, 2);
|
||||
expect(screen.getByText("files.preview.position:2/3")).toBeTruthy();
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
expect(onFileChange).toHaveBeenLastCalledWith(3);
|
||||
fireEvent.keyDown(window, { key: "ArrowLeft" });
|
||||
expect(onFileChange).toHaveBeenLastCalledWith(1);
|
||||
});
|
||||
|
||||
it("a layer opened from it (a menu, a confirm) keeps its arrows — the viewer stays on its file", () => {
|
||||
const { onFileChange } = mount(list, 2);
|
||||
const confirm = pushLayer();
|
||||
try {
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
expect(onFileChange).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
removeLayer(confirm);
|
||||
}
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
expect(onFileChange).toHaveBeenLastCalledWith(3);
|
||||
});
|
||||
|
||||
it("a text line wider than the stage scrolls sideways under ← / →, it does not page", async () => {
|
||||
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a very long line", truncated: false });
|
||||
const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
|
||||
await screen.findByText("a very long line");
|
||||
const region = screen.getByRole("region");
|
||||
Object.defineProperty(region, "scrollWidth", { value: 900 });
|
||||
Object.defineProperty(region, "clientWidth", { value: 300 });
|
||||
fireEvent.keyDown(region, { key: "ArrowRight" });
|
||||
expect(onFileChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a text whose lines fit pages under ← / →, even with the keyboard in it", async () => {
|
||||
vi.spyOn(api, "previewText").mockResolvedValue({ text: "short", truncated: false });
|
||||
const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
|
||||
await screen.findByText("short");
|
||||
const region = screen.getByRole("region");
|
||||
Object.defineProperty(region, "scrollWidth", { value: 300 });
|
||||
Object.defineProperty(region, "clientWidth", { value: 300 });
|
||||
fireEvent.keyDown(region, { key: "ArrowRight" });
|
||||
expect(onFileChange).toHaveBeenLastCalledWith(9);
|
||||
});
|
||||
|
||||
it("Escape closes it — after a layer opened above it has taken its own", () => {
|
||||
const { onClose } = mount(list, 2);
|
||||
const menu = pushLayer();
|
||||
try {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
removeLayer(menu);
|
||||
}
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops at either end", () => {
|
||||
const { onFileChange } = mount(list, 1);
|
||||
fireEvent.keyDown(window, { key: "ArrowLeft" });
|
||||
expect(onFileChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "files.preview.prev" }).hasAttribute("disabled"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("closes when the file it shows leaves the list", async () => {
|
||||
const { onClose, rerender } = mount(list, 2);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
act(() => rerender([list[0]!, list[2]!], 2));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useEffect, useEffectEvent, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileQuestion,
|
||||
WrapText,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { dispatch, useKeyScope } from "../../lib/keymap";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { IconLink, LinkButton } from "../../components/ui/LinkButton";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// The Files viewer (DESIGN.md "Files preview"): the library's files that are not media, one at a time,
|
||||
// in the shared dialog shell. What a file shows is the server's call (`asset.preview`,
|
||||
// backend downloads/preview.py): an image as an image, a PDF in the browser's own viewer, a text
|
||||
// format as plain text — HTML and SVG too, never rendered: on this origin a page would run with the
|
||||
// reader's session. The rest gets a card that offers the file itself. ← / → step through `files`, the
|
||||
// list's own order, so the viewer pages the way the list reads.
|
||||
//
|
||||
// Every kind fills the same stage, so a step never resizes the dialog: its ‹ › stay under the pointer.
|
||||
const STAGE = "h-[min(75dvh,56rem)] w-full";
|
||||
// How many lines of a text file the viewer lays out (see TextStage).
|
||||
const TEXT_LINES = 5000;
|
||||
|
||||
/** A text file's head (one read, shared by the viewer's header and its stage). */
|
||||
function useTextPreview(id: number, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: qk.filePreview(id),
|
||||
queryFn: () => api.previewText(id),
|
||||
enabled,
|
||||
// A refusal (the "text" turned out binary) is an answer, not a blip to retry.
|
||||
retry: (n, e) => !(e instanceof HttpError && e.status < 500) && n < 1,
|
||||
});
|
||||
}
|
||||
/** The server's "no preview" for a file it listed as text: it was binary after all. */
|
||||
const refused = (e: unknown) => e instanceof HttpError && e.status === 415;
|
||||
|
||||
export default function FilePreview({
|
||||
files,
|
||||
fileId,
|
||||
onFileChange,
|
||||
onClose,
|
||||
menuItems,
|
||||
}: {
|
||||
/** The files the viewer steps through, in the list's order. */
|
||||
files: readonly LibraryFile[];
|
||||
fileId: number;
|
||||
onFileChange: (id: number) => void;
|
||||
onClose: () => void;
|
||||
/** The file's row menu, behind the viewer's "…". */
|
||||
menuItems: (file: LibraryFile) => ActionMenuItem[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const at = files.findIndex((f) => f.id === fileId);
|
||||
const file = at >= 0 ? files[at] : undefined;
|
||||
// Gone from the list under the viewer (deleted from its menu, or by another tab): nothing to show.
|
||||
useEffect(() => {
|
||||
if (!file) onClose();
|
||||
}, [file, onClose]);
|
||||
|
||||
const step = (by: number) => {
|
||||
const next = files[at + by];
|
||||
if (next) onFileChange(next.id);
|
||||
};
|
||||
useKeyScope("fileViewer");
|
||||
// The viewer's own place on the layer stack: ← / → are its only while nothing is on top of it — its
|
||||
// "…" menu, a confirm, the Share or Details dialog opened from that menu. There an arrow moves
|
||||
// through the layer's own buttons; paging the viewer under it would show one file while the dialog
|
||||
// acts on another. It mounts with its dialog (both only while there is a file), and effects run
|
||||
// child first, so it sits just ABOVE the dialog's layer: the dialog is never topmost, and Escape is
|
||||
// this layer's to take — passive, so the dialog keeps its Tab trap.
|
||||
const layer = useLayer(!!file, onClose, { passive: true });
|
||||
const onKey = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (layer.blockingAbove()) return;
|
||||
// The text, scrolled sideways by its arrows: a line wider than the stage is read that way.
|
||||
const scroller =
|
||||
e.target instanceof Element ? e.target.closest("[data-scrolls-sideways]") : null;
|
||||
if (scroller && scroller.scrollWidth > scroller.clientWidth) return;
|
||||
dispatch(e, "fileViewer", {
|
||||
prevFile: (ev) => {
|
||||
ev.preventDefault();
|
||||
step(-1);
|
||||
},
|
||||
nextFile: (ev) => {
|
||||
ev.preventDefault();
|
||||
step(1);
|
||||
},
|
||||
});
|
||||
});
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => onKey(e);
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
const menu = useContextMenu<LibraryFile>({ keyOf: (f) => String(f.id) });
|
||||
const text = useTextPreview(fileId, file?.asset?.preview === "text");
|
||||
if (!file) return null;
|
||||
const kind = file.asset?.preview ?? null;
|
||||
// A new tab would only show the refusal.
|
||||
const openable = kind !== null && !refused(text.error);
|
||||
const title = jobTitle(file);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
initialFocus="close"
|
||||
title={<span className="block truncate">{title}</span>}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-6xl"
|
||||
headerActions={
|
||||
<>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
label={t("files.preview.prev")}
|
||||
disabled={at <= 0}
|
||||
onClick={() => step(-1)}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
<span className="text-xs tabular-nums text-muted">
|
||||
{t("files.preview.position", { n: at + 1, total: files.length })}
|
||||
</span>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
label={t("files.preview.next")}
|
||||
disabled={at >= files.length - 1}
|
||||
onClick={() => step(1)}
|
||||
>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
{openable ? (
|
||||
<IconLink
|
||||
href={api.previewUrl(file.id)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
label={t("files.preview.openInTab")}
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
) : (
|
||||
// Its place kept, so the icons after it do not shift from one file to the next.
|
||||
<span className="size-7 shrink-0" aria-hidden />
|
||||
)}
|
||||
<IconLink href={api.fileUrl(file.id)} label={t("downloads.actions.download")}>
|
||||
<Download />
|
||||
</IconLink>
|
||||
<MoreButton
|
||||
label={t("files.rowMenu")}
|
||||
expanded={menu.open && menu.target?.id === file.id}
|
||||
menuProps={menu.buttonProps(file)}
|
||||
/>
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("files.rowMenu")}
|
||||
items={menu.target ? menuItems(menu.target) : []}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Keyed by file: a step starts the next one's own load, never a frame of the last one. */}
|
||||
<Stage key={file.id} file={file} kind={kind} title={title} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Stage({
|
||||
file,
|
||||
kind,
|
||||
title,
|
||||
}: {
|
||||
file: LibraryFile;
|
||||
kind: FilePreviewKind | null;
|
||||
title: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (kind === "image")
|
||||
return (
|
||||
<div className={`${STAGE} flex items-center justify-center`}>
|
||||
{/* A preview that fails is an answer (the file is not an image after all), not a hiccup:
|
||||
said at once, no retries. */}
|
||||
<RemoteImage
|
||||
src={api.previewUrl(file.id)}
|
||||
alt={title}
|
||||
className="max-h-full max-w-full rounded-md object-contain"
|
||||
retries={0}
|
||||
fallback={<StateMessage tone="error">{t("common.loadError")}</StateMessage>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (kind === "pdf")
|
||||
return (
|
||||
<iframe
|
||||
src={api.previewUrl(file.id)}
|
||||
title={title}
|
||||
className={`${STAGE} rounded-md border border-border bg-white`}
|
||||
/>
|
||||
);
|
||||
if (kind === "text") return <TextStage file={file} />;
|
||||
return <NoPreview file={file} />;
|
||||
}
|
||||
|
||||
function TextStage({ file }: { file: LibraryFile }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [wrap, setWrap] = useState(true);
|
||||
const q = useTextPreview(file.id, true);
|
||||
if (q.isError)
|
||||
return refused(q.error) ? (
|
||||
<NoPreview file={file} />
|
||||
) : (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
onRetry={() => void q.refetch()}
|
||||
className={`${STAGE} justify-center`}
|
||||
>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
if (!q.data) return <LoadingState className={`${STAGE} justify-center`} />;
|
||||
// A file's last line ends in a newline; that is no line of its own. Only the first TEXT_LINES are
|
||||
// laid out: every line is a row of the page, and a head of short lines (a log) runs to tens of
|
||||
// thousands of them — a stall on open and on every wrap toggle. The rest is behind Download.
|
||||
const all = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
|
||||
const lines = all.length > TEXT_LINES ? all.slice(0, TEXT_LINES) : all;
|
||||
return (
|
||||
<div className={`${STAGE} flex flex-col gap-2`}>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{q.data.truncated
|
||||
? t("files.preview.truncated", {
|
||||
size: formatBytes(file.asset?.size_bytes, i18n.language),
|
||||
})
|
||||
: t("files.preview.asText")}
|
||||
</span>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
label={t("files.preview.wrap")}
|
||||
pressed={wrap}
|
||||
onClick={() => setWrap(!wrap)}
|
||||
>
|
||||
<WrapText />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div
|
||||
// Scrolls by keyboard too: a focusable region with a name — sideways as well, its ← / →
|
||||
// taken from the viewer's paging while a line runs past the edge.
|
||||
tabIndex={0}
|
||||
data-scrolls-sideways
|
||||
role="region"
|
||||
aria-label={jobTitle(file)}
|
||||
className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-bg font-mono text-xs leading-relaxed"
|
||||
>
|
||||
<ol className="m-0 list-none p-0">
|
||||
{lines.map((line, i) => (
|
||||
<li key={i} className="flex">
|
||||
<span
|
||||
aria-hidden
|
||||
className="sticky left-0 w-12 shrink-0 select-none bg-bg pr-3 text-right text-muted"
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span
|
||||
className={wrap ? "min-w-0 whitespace-pre-wrap wrap-anywhere" : "whitespace-pre"}
|
||||
>
|
||||
{line || " "}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{lines.length < all.length && (
|
||||
<p className="m-0 border-t border-border px-3 py-2 font-sans text-muted">
|
||||
{t("files.preview.firstLines", { lines: TEXT_LINES })}{" "}
|
||||
<LinkButton href={api.fileUrl(file.id)}>{t("files.preview.downloadWhole")}</LinkButton>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoPreview({ file }: { file: LibraryFile }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const size = file.asset?.size_bytes;
|
||||
return (
|
||||
<div className={`${STAGE} flex flex-col items-center justify-center gap-3 text-sm text-muted`}>
|
||||
<FileQuestion className="size-10" aria-hidden />
|
||||
<span className="text-center">
|
||||
{jobTitle(file)}
|
||||
{file.asset?.container ? ` · ${file.asset.container.toUpperCase()}` : ""}
|
||||
{size ? ` · ${formatBytes(size, i18n.language)}` : ""}
|
||||
</span>
|
||||
<span>{t("files.preview.none")}</span>
|
||||
<LinkButton href={api.fileUrl(file.id)} icon={<Download className="size-4" />}>
|
||||
{t("downloads.actions.download")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,808 @@
|
||||
import { lazy, Suspense, useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, LayoutGrid, Link, List, Scissors, Share2, UploadCloud } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
FILE_SORTS,
|
||||
FILES_LIST_CAP,
|
||||
type FileFolder,
|
||||
type FileListParams,
|
||||
type FileSort,
|
||||
type LibraryFile,
|
||||
} from "../../lib/api";
|
||||
import { type SortState, sortRows } from "../../lib/columnSort";
|
||||
import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions";
|
||||
import { formatBytes, formatDateTime, relativeTime } from "../../lib/format";
|
||||
import { focusNavItem } from "../../lib/listNav";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { LS, ownKey } from "../../lib/storage";
|
||||
import { useCardPager } from "../../lib/useCardPager";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { JobThumb } from "../../features/downloads/downloadRow";
|
||||
import { MetaEditModal } from "../../features/downloads/downloadModals";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { FileCard } from "./FileCard";
|
||||
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
||||
import { folderLabel, takesUploads } from "./fileFolders";
|
||||
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
|
||||
import { useFileActions } from "./useFileActions";
|
||||
import { useFolderActions } from "./useFolderActions";
|
||||
import { RenameField } from "../../components/ui/RenameField";
|
||||
import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import ColumnSortControl from "../../components/ui/ColumnSortControl";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import DataTable, { type Column } from "../../components/ui/DataTable";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { IconLink } from "../../components/ui/LinkButton";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import NavGrid from "../../components/ui/NavGrid";
|
||||
import Overlay from "../../components/ui/Overlay";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { LinkBox } from "./LinkBox";
|
||||
import Pager from "../../components/ui/Pager";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
// Lazy: these open on demand, so they stay out of the Files chunk until used (the video editor in
|
||||
// particular is heavy — filmstrip + scrubber).
|
||||
const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||
const FilePreview = lazy(() => import("./FilePreview"));
|
||||
const VideoEditor = lazy(() => import("../../features/player/VideoEditor"));
|
||||
|
||||
// The Files library: every done file of mine, the ones shared with me, or one folder's (the rail
|
||||
// picks — FilesProvider holds it), searched by the header's box. One aligned table: every kind of
|
||||
// value in its own column, an action that does not apply leaving its slot empty, the same menu on a
|
||||
// right click and on the "…" (DESIGN.md "Files page", "Library list columns"). The grid is the same
|
||||
// rows' other paint — a toggle remembered per folder, sharing the table's sort.
|
||||
//
|
||||
// A link or a notice can open the page on ONE file (`openFile`, `?item=`): the page turns to it, puts
|
||||
// the keyboard on it and rings it until the view changes. A file dropped anywhere on the page goes
|
||||
// into the Upload dialog, bound for the folder on screen.
|
||||
|
||||
const fileKey = (f: LibraryFile) => f.id;
|
||||
const folderKey = (f: FileFolder) => f.id;
|
||||
|
||||
type FilesLayout = "list" | "grid";
|
||||
const LAYOUTS: readonly FilesLayout[] = ["list", "grid"];
|
||||
const LAYOUT_ICON = { list: List, grid: LayoutGrid } as const;
|
||||
|
||||
/** A stored per-view layout map: the pref round-trips the server, so it is checked, not trusted. */
|
||||
function parseLayouts(raw: unknown): Record<string, FilesLayout> {
|
||||
if (!raw || typeof raw !== "object") return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw).filter((e): e is [string, FilesLayout] => LAYOUTS.includes(e[1])),
|
||||
);
|
||||
}
|
||||
|
||||
/** Put the keyboard on a file's row or card (lib/listNav), whichever paint is on screen. */
|
||||
function focusFile(within: HTMLElement | null, id: number): void {
|
||||
const item = within && shownFileElement(within, id)?.closest<HTMLElement>("[data-nav-index]");
|
||||
if (within && item) focusNavItem(within, Number(item.getAttribute("data-nav-index")));
|
||||
}
|
||||
|
||||
/** The element a file is painted as on screen — its table row, the row's narrow card twin, or its
|
||||
* grid card, whichever is shown (DataTable keeps both row shapes mounted). */
|
||||
function shownFileElement(within: HTMLElement, id: number): HTMLElement | null {
|
||||
const all = [...within.querySelectorAll<HTMLElement>(`[data-file-id="${id}"]`)];
|
||||
// Nothing laid out at all (no layout engine, as under test): the first is the only one there is.
|
||||
return all.find((el) => el.getClientRects().length > 0) ?? all[0] ?? null;
|
||||
}
|
||||
|
||||
/** The list a view reads. Below the server's cap it holds every file of the view, so the search and
|
||||
* the sort run here, on the rows; a view that fills the cap asks the server for both (see below). */
|
||||
function listParams(view: FilesView): FileListParams {
|
||||
if (view.kind === "shared") return { shared: true };
|
||||
if (view.kind === "folder") return { folderId: view.id };
|
||||
return {};
|
||||
}
|
||||
|
||||
function matches(file: LibraryFile, q: string, lang: string): boolean {
|
||||
if (!q) return true;
|
||||
const hay = `${jobTitle(file)} ${file.display_uploader ?? file.asset?.uploader ?? ""}`;
|
||||
return hay.toLocaleLowerCase(lang).includes(q.toLocaleLowerCase(lang));
|
||||
}
|
||||
|
||||
/** Where an upload from this view lands: the folder itself when it may take one (`takesUploads`),
|
||||
* else the Uploads folder. */
|
||||
function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number | null {
|
||||
if (view.kind !== "folder") return null;
|
||||
const f = folders.find((x) => x.id === view.id);
|
||||
return f && takesUploads(f) ? f.id : null;
|
||||
}
|
||||
|
||||
/** The files the viewer pages through: the list's, and the open one even when the list moved on
|
||||
* without it — a server sort's answer replacing the rows it was opened from. It closes only once the
|
||||
* library no longer has it (deleted). */
|
||||
function previewFiles(
|
||||
listed: LibraryFile[],
|
||||
openId: number | null,
|
||||
library: readonly (readonly LibraryFile[] | undefined)[],
|
||||
): LibraryFile[] {
|
||||
if (openId === null || listed.some((f) => f.id === openId)) return listed;
|
||||
const open = library.flatMap((l) => l ?? []).find((f) => f.id === openId);
|
||||
return open ? [open, ...listed] : listed;
|
||||
}
|
||||
|
||||
function NameCell({
|
||||
file,
|
||||
renaming,
|
||||
onOpen,
|
||||
onRenamed,
|
||||
}: {
|
||||
file: LibraryFile;
|
||||
renaming: boolean;
|
||||
onOpen: (() => void) | null;
|
||||
onRenamed: (name: string | null, byKey: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const channel = file.display_uploader || file.asset?.uploader || null;
|
||||
const clip = file.job_kind === "edit" || file.job_kind === "clip";
|
||||
if (renaming)
|
||||
return (
|
||||
<RenameField initial={jobTitle(file)} label={t("files.actions.rename")} onDone={onRenamed} />
|
||||
);
|
||||
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
{onOpen ? (
|
||||
<Pressable
|
||||
selectable
|
||||
data-nav-open=""
|
||||
onClick={onOpen}
|
||||
title={jobTitle(file)}
|
||||
className="min-w-0 rounded-sm hover:text-accent"
|
||||
>
|
||||
{title}
|
||||
</Pressable>
|
||||
) : (
|
||||
<span title={jobTitle(file)}>{title}</span>
|
||||
)}
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
|
||||
<span className="truncate">{channel ?? "—"}</span>
|
||||
{clip && (
|
||||
<Badge>
|
||||
<Scissors />
|
||||
{t("downloads.clipBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Files() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { search, view, focus, arrived } = useFiles();
|
||||
const { setView, consumeFocus } = useFilesActions();
|
||||
|
||||
const foldersQ = useLiveQuery(qk.fileFolders(), api.fileFolders, { intervalMs: 15000 });
|
||||
const folders = foldersQ.data ?? [];
|
||||
// A remembered or bookmarked folder that is gone (deleted, or another account's id) opens the whole
|
||||
// library instead of an empty page that names nothing; the provider is told, so the rail agrees.
|
||||
// Decided on a fresh list only: a folder made since the cached one (a first download from a new
|
||||
// site, opened from its notice) is not gone, just not in the cache yet.
|
||||
const gone =
|
||||
view.kind === "folder" &&
|
||||
!!foldersQ.data &&
|
||||
!foldersQ.isFetching &&
|
||||
!folders.some((f) => f.id === view.id);
|
||||
const shown = gone ? ALL_FILES : view;
|
||||
// A folder deleted from its own "…" (or its rail entry) takes the focused button with it: the
|
||||
// keyboard goes to the page's heading, never to <body>, where Tab would start the page over.
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
useEffect(() => {
|
||||
if (!gone) return;
|
||||
setView(ALL_FILES);
|
||||
const lost = document.activeElement;
|
||||
if (!lost || lost === document.body || !lost.isConnected) heading.current?.focus();
|
||||
}, [gone, setView]);
|
||||
|
||||
// One sort for both paints: the table's headers and the grid's dropdown move the same state.
|
||||
const [sort, setSort] = usePref<SortState>("filesSort", null, { localKey: LS.filesSort });
|
||||
const params = listParams(shown);
|
||||
// Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own.
|
||||
const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 });
|
||||
const q = search.trim();
|
||||
// A view that fills the server's cap holds only its newest files: searching or sorting those rows
|
||||
// would miss an older file, so the server does both, over the whole view. Asked once the typing
|
||||
// pauses; a sort the server does not know (the source, the retention) stays on the rows.
|
||||
const capped = (filesQ.data?.length ?? 0) >= FILES_LIST_CAP;
|
||||
// An arrival at one file clears the search (openFile): the cleared box is taken at once, not after
|
||||
// a pause, so the file is looked for in the list it is about to be shown in.
|
||||
const typed = useDebounced(q, 300, focus?.token);
|
||||
const serverSort = sort && (FILE_SORTS as readonly string[]).includes(sort.key) ? sort : null;
|
||||
const wide = capped && (!!typed || !!serverSort);
|
||||
const wideParams: FileListParams = {
|
||||
...params,
|
||||
q: typed,
|
||||
...(serverSort && { sort: serverSort.key as FileSort, order: serverSort.dir }),
|
||||
};
|
||||
const wideQ = useLiveQuery(qk.files(wideParams), () => api.files(wideParams), {
|
||||
intervalMs: 15000,
|
||||
enabled: wide,
|
||||
});
|
||||
// Until a server sort's first answer is in, the rows already on screen stay, sorted here: a skeleton
|
||||
// in their place would unmount the table under the header the user just pressed. A search waits in
|
||||
// a skeleton instead — the newest files searched here would say "no match" for a file the server is
|
||||
// about to find — its keyboard being in the search box, not the table.
|
||||
const wideIn = wide && (!!typed || wideQ.data !== undefined || wideQ.isError);
|
||||
const listQ = wideIn ? wideQ : filesQ;
|
||||
const all = listQ.data ?? [];
|
||||
const rows = wideIn ? all : all.filter((f) => matches(f, q, lang));
|
||||
// The server's cap cut this list: the newest files of the view, or the first by its sort or search.
|
||||
const cut = all.length >= FILES_LIST_CAP;
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||
// A rename left by Enter or Escape hands the keyboard back to its row (RenameField).
|
||||
const refocusFile = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = refocusFile.current;
|
||||
if (renamingId !== null || id === null) return;
|
||||
refocusFile.current = null;
|
||||
focusFile(listRef.current, id);
|
||||
}, [renamingId]);
|
||||
// The Upload dialog while open, with the file a drop on the page brought (null: pick one in it).
|
||||
const [upload, setUpload] = useState<{ file: File | null } | null>(null);
|
||||
const [metaFile, setMetaFile] = useState<LibraryFile | null>(null);
|
||||
const [shareFile, setShareFile] = useState<LibraryFile | null>(null);
|
||||
const [editFile, setEditFile] = useState<LibraryFile | null>(null);
|
||||
const [playing, setPlaying] = useState<{ file: LibraryFile; fromStart: boolean } | null>(null);
|
||||
const play = (file: LibraryFile, fromStart = false) => setPlaying({ file, fromStart });
|
||||
// The file open in the viewer (one that is not media), by id: the list under it keeps re-reading.
|
||||
const [viewing, setViewing] = useState<number | null>(null);
|
||||
|
||||
const actions = useFileActions({
|
||||
folders: foldersQ.data ?? null,
|
||||
onPlay: play,
|
||||
onView: (f) => setViewing(f.id),
|
||||
onEdit: setEditFile,
|
||||
onRename: (f) => setRenamingId(f.id),
|
||||
onEditDetails: setMetaFile,
|
||||
onManageShares: setShareFile,
|
||||
});
|
||||
// Keyed by id: the list re-reads every 15 s, replacing the file under an open menu, and its "…"
|
||||
// must still close it.
|
||||
const rowMenu = useContextMenu<LibraryFile>({ keyOf: fileKey });
|
||||
const menuItems = rowMenu.target ? actions.items(rowMenu.target) : [];
|
||||
|
||||
const open = shown.kind === "folder" ? folders.find((f) => f.id === shown.id) : undefined;
|
||||
// The open folder's menu, on the "…" beside its name — the one its rail entry's right click opens.
|
||||
const [renamingFolder, setRenamingFolder] = useState(false);
|
||||
const folderActions = useFolderActions({ onRename: () => setRenamingFolder(true) });
|
||||
const folderMenu = useContextMenu<FileFolder>({ keyOf: folderKey });
|
||||
// Its rename left by a key hands the keyboard back to the "…" it was opened from.
|
||||
const folderMore = useRef<HTMLButtonElement>(null);
|
||||
const refocusFolderMore = useRef(false);
|
||||
useEffect(() => {
|
||||
if (renamingFolder || !refocusFolderMore.current) return;
|
||||
refocusFolderMore.current = false;
|
||||
folderMore.current?.focus();
|
||||
}, [renamingFolder]);
|
||||
|
||||
// A folder view names its folder once the folder list is in (blank until then, never a guess).
|
||||
const title =
|
||||
shown.kind === "shared"
|
||||
? t("files.rail.shared")
|
||||
: shown.kind === "folder"
|
||||
? open
|
||||
? folderLabel(open, t)
|
||||
: foldersQ.isError && !foldersQ.data
|
||||
? t("files.folderUnknown")
|
||||
: ""
|
||||
: t("files.rail.all");
|
||||
// Not before the folders are known: what a folder may take (`uploadFolder`) is read from them, and
|
||||
// a guess would send the file to Uploads instead of the folder on screen.
|
||||
const canUpload = shown.kind !== "shared" && (shown.kind !== "folder" || !!foldersQ.data);
|
||||
const uploadTarget = uploadFolder(shown, folders);
|
||||
const uploadTargetName =
|
||||
uploadTarget !== null && open ? folderLabel(open, t) : t("files.source.upload");
|
||||
|
||||
// List or grid, per view (DESIGN.md "Files page": the grid a toggle remembered per folder).
|
||||
const [layouts, setLayouts] = usePref<Record<string, FilesLayout>>(
|
||||
"filesLayouts",
|
||||
{},
|
||||
{
|
||||
localKey: LS.filesLayouts,
|
||||
parse: parseLayouts,
|
||||
},
|
||||
);
|
||||
const viewKey = viewParam(shown);
|
||||
const layout: FilesLayout = layouts[viewKey] ?? "list";
|
||||
const isGrid = layout === "grid";
|
||||
|
||||
// ── Arrival at one file ─────────────────────────────────────────────────────────────────────────
|
||||
// A pending arrival (`focus`) is looked for once the list is in: found here, it becomes `arrived`
|
||||
// (FilesProvider), which turns the table's or the grid's page to the file (`reveal`), puts the
|
||||
// keyboard on it and rings it while this view is open. Not in this view, it is looked for among
|
||||
// every file; nowhere (gone since the link was made), it leaves nothing to arrive at, and is spent.
|
||||
// Looked for in the rows the page SHOWS — a server sort's own answer, not the rows standing in until
|
||||
// it comes: the table can only turn to a file it holds.
|
||||
const shownData = wide && !wideIn ? undefined : listQ.data;
|
||||
const here = !!focus && !!shownData?.some((f) => f.id === focus.id);
|
||||
// "Not here" only once the list is fresh: a just-finished upload is not in the cached rows yet.
|
||||
const missing = !!focus && !!shownData && !here && !listQ.isFetching;
|
||||
const lookEverywhere = missing && shown.kind !== "all";
|
||||
useEffect(() => {
|
||||
if (lookEverywhere) setView(ALL_FILES);
|
||||
else if (here) consumeFocus(viewKey);
|
||||
else if (missing) {
|
||||
consumeFocus(null);
|
||||
// Said, not swallowed. A list the cap cut cannot tell a deleted file from one beyond its end;
|
||||
// a whole list can: an old notice's file may have expired or been deleted since.
|
||||
notify({ level: "info", message: t(cut ? "files.arrival.notListed" : "files.arrival.gone") });
|
||||
}
|
||||
}, [here, missing, lookEverywhere, cut, viewKey, setView, consumeFocus, t]);
|
||||
const arrival = arrived && arrived.view === viewKey ? arrived : null;
|
||||
const ringed = arrival?.id ?? null;
|
||||
const reveal = arrival && { key: String(arrival.id), seq: arrival.token };
|
||||
// The keyboard half, once the page that holds the file has painted.
|
||||
const focusArrived = useEffectEvent(() => {
|
||||
if (ringed !== null) focusFile(listRef.current, ringed);
|
||||
});
|
||||
// Once per arrival (its token), not on every list re-read or layout switch.
|
||||
const arrivalToken = arrival?.token;
|
||||
useEffect(() => {
|
||||
if (arrivalToken !== undefined) focusArrived();
|
||||
}, [arrivalToken]);
|
||||
|
||||
// ── A file dropped anywhere on the page ─────────────────────────────────────────────────────────
|
||||
// The page is the target, not a box on it: a short folder leaves most of the screen empty. Window
|
||||
// listeners, since the drag starts outside the app. While the dialog is open its own zone takes the
|
||||
// drop: a drag something under the pointer already accepted (that zone) is left as it set it — these
|
||||
// listeners run last, and a "none" written here would refuse the zone's drop. A drag that carries no
|
||||
// files (a link, selected text) is not ours. A file dropped where the page takes none (beside the
|
||||
// open dialog, on the shared list) is still kept from the browser, which would open it in the tab —
|
||||
// unloading the app and any upload under way.
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragDepth = useRef(0);
|
||||
const dropReady = canUpload && upload === null;
|
||||
const onDrag = useEffectEvent((e: DragEvent) => {
|
||||
if (!e.dataTransfer?.types.includes("Files")) return;
|
||||
// A drop ends the drag wherever it lands, taken by another zone too (no dragleave follows it): the
|
||||
// page's "drop here" goes with it.
|
||||
if (e.type === "drop") {
|
||||
dragDepth.current = 0;
|
||||
setDragging(false);
|
||||
}
|
||||
if (e.defaultPrevented) return;
|
||||
e.preventDefault();
|
||||
if (!dropReady) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
return;
|
||||
}
|
||||
if (e.type === "dragenter") dragDepth.current += 1;
|
||||
if (e.type === "dragleave") dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (e.type === "drop") {
|
||||
const dropped = e.dataTransfer.files;
|
||||
const first = dropped.item(0);
|
||||
if (first) setUpload({ file: first });
|
||||
if (dropped.length > 1) notify({ level: "info", message: t("files.drop.oneAtATime") });
|
||||
}
|
||||
setDragging(dragDepth.current > 0);
|
||||
});
|
||||
useEffect(() => {
|
||||
const on = (e: DragEvent) => onDrag(e);
|
||||
const kinds = ["dragenter", "dragover", "dragleave", "drop"] as const;
|
||||
for (const k of kinds) window.addEventListener(k, on);
|
||||
return () => {
|
||||
for (const k of kinds) window.removeEventListener(k, on);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const iconSlot = <span className="size-9 shrink-0" aria-hidden />;
|
||||
// What a row's name or a card's picture does: plays it, or opens it in the viewer.
|
||||
const openOf = (f: LibraryFile) => {
|
||||
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
|
||||
return a.play ? () => play(f) : a.view ? () => setViewing(f.id) : null;
|
||||
};
|
||||
const columns: Column<LibraryFile>[] = [
|
||||
{
|
||||
key: "thumb",
|
||||
header: "",
|
||||
width: "5.5rem",
|
||||
hideInCard: true,
|
||||
// A narrow table (a pinned rail beside it) gives its fixed columns' room to the name: with the
|
||||
// picture and the date in, the name had none left.
|
||||
narrowHidden: true,
|
||||
render: (f) => (
|
||||
<JobThumb
|
||||
job={f}
|
||||
box="w-20 aspect-video shrink-0 rounded-md overflow-hidden bg-surface border border-border"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
header: t("files.columns.name"),
|
||||
cardPrimary: true,
|
||||
sortable: true,
|
||||
sortValue: (f) => jobTitle(f),
|
||||
render: (f) => (
|
||||
<NameCell
|
||||
key={renamingId === f.id ? "renaming" : "shown"}
|
||||
file={f}
|
||||
renaming={renamingId === f.id}
|
||||
onOpen={openOf(f)}
|
||||
onRenamed={(name, byKey) => {
|
||||
if (byKey) refocusFile.current = f.id;
|
||||
setRenamingId(null);
|
||||
if (name !== null) actions.rename(f, name);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
header: t("files.columns.source"),
|
||||
width: "8rem",
|
||||
tabletHidden: true,
|
||||
render: (f) => <SourceBadge file={f} />,
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
header: t("files.columns.phone"),
|
||||
width: "5.5rem",
|
||||
narrowHidden: true,
|
||||
render: (f) =>
|
||||
f.asset?.media_kind === "file" ? (
|
||||
<span className="text-xs text-muted">—</span>
|
||||
) : (
|
||||
<PhoneBadge file={f} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "retention",
|
||||
header: t("files.columns.retention"),
|
||||
width: "6.75rem",
|
||||
narrowHidden: true,
|
||||
render: (f) => <RetentionBadge file={f} />,
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
header: t("files.columns.size"),
|
||||
width: "5.5rem",
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (f) => f.asset?.size_bytes ?? 0,
|
||||
render: (f) => (
|
||||
<span className="text-xs tabular-nums text-muted">
|
||||
{formatBytes(f.asset?.size_bytes, lang)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "added",
|
||||
header: t("files.columns.added"),
|
||||
width: "6.5rem",
|
||||
narrowHidden: true,
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (f) => f.shared_at ?? f.ready_at ?? f.created_at ?? "",
|
||||
render: (f) => {
|
||||
const at = f.shared_at ?? f.ready_at ?? f.created_at;
|
||||
return (
|
||||
<span className="text-xs text-muted" title={formatDateTime(at, lang)}>
|
||||
{relativeTime(at, lang, t)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
// Only the "…" holds a place: the quick icons come up over the date while the pointer or the
|
||||
// keyboard is on the row (the mail-list pattern), so the name gets their room. A card keeps
|
||||
// them in its own line — a phone has no hover — and the menu holds them all anyway.
|
||||
width: "3rem",
|
||||
align: "right",
|
||||
cardLabel: false,
|
||||
cardRow: true,
|
||||
render: (f) => {
|
||||
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
|
||||
const mine = rowMenu.open && rowMenu.target?.id === f.id;
|
||||
return (
|
||||
<span className="relative flex items-center justify-end gap-0.5">
|
||||
<span
|
||||
data-quick-actions
|
||||
className="flex items-center gap-0.5 md:absolute md:right-full md:top-1/2 md:-translate-y-1/2 md:rounded-md md:bg-bg md:pointer-events-none md:opacity-0 md:transition-opacity md:group-hover:pointer-events-auto md:group-hover:opacity-100 md:group-focus-within:pointer-events-auto md:group-focus-within:opacity-100"
|
||||
>
|
||||
{!f.shared && a.play ? (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
label={t("files.actions.copyLink")}
|
||||
disabled={actions.sharing !== null}
|
||||
onClick={() => actions.share(f)}
|
||||
>
|
||||
<Link />
|
||||
</IconButton>
|
||||
) : (
|
||||
iconSlot
|
||||
)}
|
||||
{!f.shared ? (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
label={t("files.actions.share")}
|
||||
onClick={() => setShareFile(f)}
|
||||
>
|
||||
<Share2 />
|
||||
</IconButton>
|
||||
) : (
|
||||
iconSlot
|
||||
)}
|
||||
{f.can_download ? (
|
||||
<IconLink
|
||||
href={api.fileUrl(f.id)}
|
||||
label={t("downloads.actions.download")}
|
||||
size="md"
|
||||
>
|
||||
<Download />
|
||||
</IconLink>
|
||||
) : (
|
||||
iconSlot
|
||||
)}
|
||||
</span>
|
||||
<MoreButton
|
||||
label={t("files.rowMenu")}
|
||||
size="md"
|
||||
expanded={mine}
|
||||
menuProps={rowMenu.buttonProps(f)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Each folder and each search is a list of its own: it opens on its first page.
|
||||
const pageScope = `${viewKey}|${q}`;
|
||||
// The grid sorts and pages here; the table does both itself (from the same sort).
|
||||
const cards = isGrid ? sortRows(rows, columns, sort, lang) : rows;
|
||||
const { paged, pagerProps } = useCardPager(
|
||||
cards,
|
||||
LS.filesCardSize,
|
||||
isGrid,
|
||||
reveal && { seq: reveal.seq, match: (f) => String(f.id) === reveal.key },
|
||||
t("files.perPage"),
|
||||
`${pageScope}|${JSON.stringify(sort)}`,
|
||||
);
|
||||
|
||||
const emptyText = q
|
||||
? t("files.empty.search", { q })
|
||||
: t(
|
||||
shown.kind === "shared"
|
||||
? "files.empty.shared"
|
||||
: shown.kind === "folder"
|
||||
? "files.empty.folder"
|
||||
: "files.empty.all",
|
||||
);
|
||||
|
||||
const grid =
|
||||
cards.length === 0 ? (
|
||||
<StateMessage>{emptyText}</StateMessage>
|
||||
) : (
|
||||
<>
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
||||
{paged.map((f, i) => (
|
||||
<FileCard
|
||||
key={f.id}
|
||||
file={f}
|
||||
index={i}
|
||||
onOpen={openOf(f)}
|
||||
menuProps={rowMenu.triggerProps(f)}
|
||||
moreProps={rowMenu.buttonProps(f)}
|
||||
menuOpen={rowMenu.open && rowMenu.target?.id === f.id}
|
||||
arrived={ringed === f.id}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
<div className="flex justify-end pt-3">
|
||||
<Pager {...pagerProps} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const body =
|
||||
listQ.isError && !listQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => listQ.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : listQ.isLoading ? (
|
||||
<LoadingState shape={isGrid ? "grid" : "list"} />
|
||||
) : isGrid ? (
|
||||
grid
|
||||
) : (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(f) => String(f.id)}
|
||||
persistKey={ownKey(LS.filesTable) ?? undefined}
|
||||
pageSize={50}
|
||||
keyboardNav
|
||||
fixedLayout
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
reveal={reveal}
|
||||
narrowedBy={q}
|
||||
pageScope={pageScope}
|
||||
rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })}
|
||||
// `group`: the row's quick icons show while it is hovered or holds the focus.
|
||||
rowClassName={(f) =>
|
||||
ringed === f.id ? "group bg-accent/8 ring-2 ring-inset ring-accent/60" : "group"
|
||||
}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
);
|
||||
|
||||
const layoutOptions: ViewOption<FilesLayout>[] = LAYOUTS.map((id) => ({
|
||||
id,
|
||||
label: t(`files.layout.${id}`),
|
||||
icon: LAYOUT_ICON[id],
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageToolbar>
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-3 gap-y-2 px-4 pt-2 pb-2">
|
||||
{renamingFolder && open?.kind === "own" ? (
|
||||
<RenameField
|
||||
initial={open.name ?? ""}
|
||||
label={t("files.folder.rename")}
|
||||
className="w-64 max-w-full"
|
||||
onDone={(name, byKey) => {
|
||||
refocusFolderMore.current = byKey;
|
||||
setRenamingFolder(false);
|
||||
if (name !== null) folderActions.rename(open, name);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
ref={heading}
|
||||
tabIndex={-1}
|
||||
className="min-w-0 truncate text-lg font-bold outline-none"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{open && !renamingFolder && (
|
||||
<MoreButton
|
||||
ref={folderMore}
|
||||
label={t("files.folder.menu")}
|
||||
size="sm"
|
||||
expanded={folderMenu.open}
|
||||
menuProps={folderMenu.buttonProps(open)}
|
||||
/>
|
||||
)}
|
||||
{listQ.data && (
|
||||
<span className="text-xs tabular-nums text-muted">
|
||||
{/* A list the cap cut says which files it holds: the first by the server's sort, else
|
||||
the newest (a search's too — the server answers it newest first). */}
|
||||
{t(
|
||||
cut
|
||||
? wideIn && serverSort
|
||||
? "files.countFirst"
|
||||
: "files.countNewest"
|
||||
: "files.count",
|
||||
{
|
||||
count: rows.length,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{isGrid && (
|
||||
<span className="ml-auto">
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
label={t("files.sort.label")}
|
||||
defaultLabel={t("files.sort.default")}
|
||||
ascLabel={t("files.sort.asc")}
|
||||
descLabel={t("files.sort.desc")}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{/* The intake row (DESIGN.md "Files page"): paste a link, or upload; then the paint. On a
|
||||
phone the link box takes the line and the rest wraps under it. A link is downloaded
|
||||
into its site's folder whatever is open, so the box stays even in Shared with me. */}
|
||||
<span className="flex w-full flex-wrap items-center gap-2">
|
||||
<LinkBox className="flex-[1_1_18rem]" />
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
{canUpload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<UploadCloud className="w-4 h-4" />}
|
||||
onClick={() => setUpload({ file: null })}
|
||||
>
|
||||
{t("files.actions.upload")}
|
||||
</Button>
|
||||
)}
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
options={layoutOptions}
|
||||
onChange={(next) => setLayouts({ ...layouts, [viewKey]: next })}
|
||||
label={t("files.layout.label")}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
{/* The table starts at the scroller's top: a padding above it let its sticky header ride up that
|
||||
far on the first scroll, then stop. The cards have no header, so they keep the gap. */}
|
||||
<div ref={listRef} className={`mx-auto w-full max-w-7xl px-4 pb-4${isGrid ? " pt-3" : ""}`}>
|
||||
{body}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} />
|
||||
<ContextMenu
|
||||
menu={folderMenu}
|
||||
ariaLabel={t("files.folder.menu")}
|
||||
items={folderMenu.target ? folderActions.items(folderMenu.target) : []}
|
||||
/>
|
||||
{folderActions.dialog}
|
||||
<Suspense fallback={null}>
|
||||
{upload && (
|
||||
<UploadDialog
|
||||
folderId={uploadTarget}
|
||||
initialFile={upload.file}
|
||||
onClose={() => setUpload(null)}
|
||||
/>
|
||||
)}
|
||||
{metaFile && <MetaEditModal job={metaFile} onClose={() => setMetaFile(null)} />}
|
||||
{shareFile && <ShareDialog job={shareFile} onClose={() => setShareFile(null)} />}
|
||||
{playing && (
|
||||
<LibraryPlayer
|
||||
job={playing.file}
|
||||
fromStart={playing.fromStart}
|
||||
onClose={() => setPlaying(null)}
|
||||
/>
|
||||
)}
|
||||
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />}
|
||||
{viewing !== null && (
|
||||
<FilePreview
|
||||
// The viewer pages the list as it reads (its sort), through the files it can open.
|
||||
files={previewFiles(
|
||||
sortRows(rows, columns, sort, lang).filter(
|
||||
(f) => (f.shared ? sharedRowActions(f) : libraryRowActions(f)).view,
|
||||
),
|
||||
viewing,
|
||||
[filesQ.data, wideQ.data],
|
||||
)}
|
||||
fileId={viewing}
|
||||
onFileChange={setViewing}
|
||||
onClose={() => setViewing(null)}
|
||||
menuItems={(f) => actions.items(f, true)}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
{dragging && (
|
||||
// Through <Overlay>: declared in the page, a `fixed` layer is trapped in its stacking
|
||||
// context and the rail paints over it.
|
||||
<Overlay>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-3 z-overlay grid place-items-center rounded-2xl border-2 border-dashed border-accent bg-accent/10 backdrop-blur-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2 rounded-xl bg-card px-4 py-3 text-base font-semibold shadow-float">
|
||||
<UploadCloud className="size-5 text-accent" />
|
||||
{t("files.drop.here", { folder: uploadTargetName })}
|
||||
</span>
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// FilesProvider's arrival at one file: `openFile` (a notice's "Open") and a `?item=` link both make a
|
||||
// pending focus that the Files page spends once it has looked; what it found is held until the page
|
||||
// is left. Real storage, claimed for the account; only the navigation is mocked, its page
|
||||
// switchable.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const nav = vi.hoisted(() => ({ page: "files", setPage: vi.fn() }));
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigation: () => ({ page: nav.page }),
|
||||
useNavigationActions: () => ({ setPage: nav.setPage }),
|
||||
}));
|
||||
|
||||
import { FilesProvider } from "./FilesProvider";
|
||||
import { ALL_FILES, useFiles, useFilesActions } from "./filesContext";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<FilesProvider>{children}</FilesProvider>
|
||||
);
|
||||
const render = () => renderHook(() => ({ ...useFiles(), ...useFilesActions() }), { wrapper });
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(1);
|
||||
window.history.replaceState(null, "", "/");
|
||||
nav.page = "files";
|
||||
nav.setPage.mockClear();
|
||||
});
|
||||
|
||||
describe("FilesProvider · arriving at one file", () => {
|
||||
it("openFile opens its folder on the Files page, with no search hiding it", () => {
|
||||
nav.page = "feed";
|
||||
const { result, rerender } = render();
|
||||
act(() => result.current.setSearch("valami"));
|
||||
act(() => result.current.openFile(7, 3));
|
||||
expect(nav.setPage).toHaveBeenCalledWith("files");
|
||||
expect(result.current.view).toEqual({ kind: "folder", id: 3 });
|
||||
expect(result.current.search).toBe("");
|
||||
nav.page = "files";
|
||||
rerender();
|
||||
expect(result.current.focus).toMatchObject({ id: 7 });
|
||||
// The address carries it until it is spent, so a reload mid-arrival still lands on the file.
|
||||
expect(new URLSearchParams(window.location.search).get("item")).toBe("7");
|
||||
});
|
||||
|
||||
it("a file in no folder opens every file", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.openFile(7, null));
|
||||
expect(result.current.view).toEqual(ALL_FILES);
|
||||
});
|
||||
|
||||
it("a ?item= link is an arrival from the start", () => {
|
||||
window.history.replaceState(null, "", "/?item=12");
|
||||
const { result } = render();
|
||||
expect(result.current.focus).toMatchObject({ id: 12 });
|
||||
});
|
||||
|
||||
it("spent, it is what the page found — until the page is left", () => {
|
||||
const { result, rerender } = render();
|
||||
act(() => result.current.openFile(7, null));
|
||||
act(() => result.current.consumeFocus("all"));
|
||||
expect(result.current.focus).toBeNull();
|
||||
expect(result.current.arrived).toMatchObject({ id: 7, view: "all" });
|
||||
expect(new URLSearchParams(window.location.search).get("item")).toBeNull();
|
||||
nav.page = "feed";
|
||||
rerender();
|
||||
expect(result.current.arrived).toBeNull();
|
||||
nav.page = "files";
|
||||
rerender();
|
||||
// Back on the page later: nothing is rung or turned to again.
|
||||
expect(result.current.arrived).toBeNull();
|
||||
});
|
||||
|
||||
it("an arrival found nowhere leaves nothing found", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.openFile(7, null));
|
||||
act(() => result.current.consumeFocus(null));
|
||||
expect(result.current.focus).toBeNull();
|
||||
expect(result.current.arrived).toBeNull();
|
||||
});
|
||||
|
||||
it("the same file opened again is a new arrival", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.openFile(7, null));
|
||||
act(() => result.current.consumeFocus("all"));
|
||||
act(() => result.current.openFile(7, null));
|
||||
expect(result.current.focus).toMatchObject({ id: 7 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import {
|
||||
ALL_FILES,
|
||||
FilesActionsContext,
|
||||
FilesStateContext,
|
||||
parseItem,
|
||||
parseView,
|
||||
viewParam,
|
||||
type FilesActions,
|
||||
type FilesArrival,
|
||||
type FilesFocus,
|
||||
type FilesState,
|
||||
type FilesView,
|
||||
} from "./filesContext";
|
||||
|
||||
// Owns the Files module's shared state — the header search term and the view the rail picked (every
|
||||
// file, shared with me, or a folder) — so the header's search box, the FilesRail and the Files page
|
||||
// read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in
|
||||
// main.tsx around <App/>.
|
||||
//
|
||||
// The search is ephemeral (resets on reload, like the other list filters); the view is remembered per
|
||||
// account and mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder.
|
||||
// A `?folder=` in the address wins over the remembered one. A folder that no longer exists is the
|
||||
// page's to fall back from (it knows the folder list).
|
||||
//
|
||||
// An arrival at one file (`?item=`, or `openFile` from a notice) is an INTENT the page consumes once it
|
||||
// has the file in view, the way the channel manager's focusChannel is: the consumed marker lives here,
|
||||
// because the page chunk remounts on every navigation and would otherwise re-apply it. Until then the
|
||||
// address carries it, so a reload mid-arrival still lands on the file.
|
||||
|
||||
function initialView(): FilesView {
|
||||
return (
|
||||
parseView(new URLSearchParams(window.location.search).get("folder")) ??
|
||||
parseView(getAccountRaw(LS.filesView)) ??
|
||||
ALL_FILES
|
||||
);
|
||||
}
|
||||
|
||||
function initialFocus(): FilesFocus | null {
|
||||
const id = parseItem(new URLSearchParams(window.location.search).get("item"));
|
||||
return id === null ? null : { id, token: 1 };
|
||||
}
|
||||
|
||||
export function FilesProvider({ children }: { children: ReactNode }) {
|
||||
const [search, setSearchState] = useState("");
|
||||
const [view, setViewState] = useState<FilesView>(initialView);
|
||||
const [focusRaw, setFocusRaw] = useState<FilesFocus | null>(initialFocus);
|
||||
const [focusApplied, setFocusApplied] = useState(0);
|
||||
|
||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||
const setView = useCallback((next: FilesView) => {
|
||||
setViewState(next);
|
||||
setAccountRaw(LS.filesView, viewParam(next));
|
||||
}, []);
|
||||
|
||||
const { page } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
const openFile = useCallback(
|
||||
(id: number, folderId: number | null) => {
|
||||
setView(folderId === null ? ALL_FILES : { kind: "folder", id: folderId });
|
||||
// A search that hides the file would leave nothing to arrive at.
|
||||
setSearchState("");
|
||||
setFocusRaw((prev) => ({ id, token: (prev?.token ?? 0) + 1 }));
|
||||
setPage("files");
|
||||
},
|
||||
[setView, setPage],
|
||||
);
|
||||
// What the page found. Held HERE, and set only once the page has mounted and looked: the table and
|
||||
// the grid follow a reveal that CHANGES under them, never one they mount with (a remount must not
|
||||
// move a page nobody asked to move), so an arrival decided during the page's first render would be
|
||||
// one they never turn to.
|
||||
const [arrivedRaw, setArrivedRaw] = useState<FilesArrival | null>(null);
|
||||
const consumeFocus = useCallback(
|
||||
(at: string | null) => {
|
||||
setFocusApplied(focusRaw?.token ?? 0);
|
||||
setArrivedRaw(focusRaw && at !== null ? { ...focusRaw, view: at } : null);
|
||||
},
|
||||
[focusRaw],
|
||||
);
|
||||
// Only ON the Files page, and only until the page has used it.
|
||||
const focus = page === "files" && focusRaw && focusRaw.token !== focusApplied ? focusRaw : null;
|
||||
// Leaving the page ends the arrival: back on it later, nothing is rung or turned to.
|
||||
if (page !== "files" && arrivedRaw) setArrivedRaw(null);
|
||||
const arrived = page === "files" ? arrivedRaw : null;
|
||||
|
||||
useUrlMirror({
|
||||
folder: page === "files" ? viewParam(view) : null,
|
||||
item: focus ? String(focus.id) : null,
|
||||
});
|
||||
|
||||
const state = useMemo<FilesState>(
|
||||
() => ({ search, view, focus, arrived }),
|
||||
[search, view, focus, arrived],
|
||||
);
|
||||
const actions = useMemo<FilesActions>(
|
||||
() => ({ setSearch, setView, openFile, consumeFocus }),
|
||||
[setSearch, setView, openFile, consumeFocus],
|
||||
);
|
||||
|
||||
return (
|
||||
<FilesActionsContext.Provider value={actions}>
|
||||
<FilesStateContext.Provider value={state}>{children}</FilesStateContext.Provider>
|
||||
</FilesActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Files rail's folder management: a new folder is named in place, a folder's right click opens its
|
||||
// ONE menu (the page's "…" opens the same — Files.menu.dom.test), and a rename happens in the entry.
|
||||
// A field left by Enter or Escape hands the keyboard back to what stands there now, never to <body>.
|
||||
// The SidePanel chrome around the groups is stubbed; the writes are watched at the API.
|
||||
import { afterEach, beforeAll, 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 type { ReactNode } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||
});
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => false }));
|
||||
vi.mock("../../components/ui/FilterRail", () => ({
|
||||
default: ({
|
||||
items,
|
||||
children,
|
||||
}: {
|
||||
items: { id: string; body: ReactNode }[];
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
{items.map((i) => (
|
||||
<div key={i.id}>{i.body}</div>
|
||||
))}
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
import FilesRail from "./FilesRail";
|
||||
import { api, type FileFolder } from "../../lib/api";
|
||||
import { defaultLayout } from "../../lib/panelLayout";
|
||||
|
||||
// The share dialog is a lazy chunk, and the test that opens it would pay for transforming it inside
|
||||
// its own wait for the dialog — over that on a busy machine. Loaded once, up front.
|
||||
beforeAll(async () => {
|
||||
await import("./ShareDialog");
|
||||
}, 60_000);
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
|
||||
id,
|
||||
kind: "source",
|
||||
source_key: null,
|
||||
name: null,
|
||||
retention_days: 30,
|
||||
count: 1,
|
||||
size_bytes: 0,
|
||||
created_at: null,
|
||||
has_link: false,
|
||||
...extra,
|
||||
});
|
||||
const YT = folder(1, { source_key: "youtube" });
|
||||
const OWN = folder(2, { kind: "own", name: "Receptek", retention_days: null });
|
||||
|
||||
async function mount(folders: FileFolder[] = [YT, OWN]) {
|
||||
vi.spyOn(api, "fileFolders").mockResolvedValue(folders);
|
||||
vi.spyOn(api, "filesUsage").mockResolvedValue({ unlimited: true, footprint_bytes: 0 } as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FilesRail
|
||||
layout={defaultLayout("files")}
|
||||
setLayout={vi.fn()}
|
||||
collapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("files.folder.new");
|
||||
}
|
||||
|
||||
const entry = (name: string) => screen.getByText(name).closest<HTMLElement>("button")!;
|
||||
const menuRows = () =>
|
||||
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||
|
||||
describe("FilesRail · folders", () => {
|
||||
it("names a new folder in place; Enter makes it and gives the keyboard back", async () => {
|
||||
await mount();
|
||||
const create = vi.spyOn(api, "createFolder").mockResolvedValue(folder(3, { kind: "own" }));
|
||||
fireEvent.click(screen.getByText("files.folder.new"));
|
||||
const field = screen.getByRole("textbox", { name: "files.folder.newName" });
|
||||
fireEvent.change(field, { target: { value: " Cimboráknak " } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(create).toHaveBeenCalledWith("Cimboráknak"));
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByText("files.folder.new").closest("button")),
|
||||
);
|
||||
});
|
||||
|
||||
it("Escape makes nothing; an empty name makes nothing", async () => {
|
||||
await mount();
|
||||
const create = vi.spyOn(api, "createFolder");
|
||||
fireEvent.click(screen.getByText("files.folder.new"));
|
||||
const field = screen.getByRole("textbox", { name: "files.folder.newName" });
|
||||
fireEvent.change(field, { target: { value: "Más" } });
|
||||
fireEvent.keyDown(field, { key: "Escape" });
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
fireEvent.click(screen.getByText("files.folder.new"));
|
||||
fireEvent.keyDown(screen.getByRole("textbox", { name: "files.folder.newName" }), {
|
||||
key: "Enter",
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a folder's right click opens its menu; a source folder's holds only its retention", async () => {
|
||||
await mount();
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("Receptek"));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(menuRows()).toEqual([
|
||||
"files.actions.copyLink",
|
||||
"files.folder.shareLink",
|
||||
"files.folder.rename",
|
||||
"files.folder.retention",
|
||||
"files.folder.delete",
|
||||
]),
|
||||
);
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("files.source.youtube"));
|
||||
});
|
||||
await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
|
||||
});
|
||||
|
||||
it("the Hub folder closes the sources, shown while empty, its menu only its retention", async () => {
|
||||
const hub = folder(4, { kind: "hub", count: 0, retention_days: null });
|
||||
await mount([hub, folder(3, { source_key: "site:tiktok.com" }), YT, OWN]);
|
||||
const sources = entry("files.source.youtube").parentElement!;
|
||||
const names = [...sources.querySelectorAll("button")].map((b) => b.textContent);
|
||||
// Last, though its name sorts first: the sources by name, then the Hub folder.
|
||||
expect(names).toEqual(["files.source.youtube1", "tiktok.com1", "files.folder.hub0"]);
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("files.folder.hub"));
|
||||
});
|
||||
await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
|
||||
});
|
||||
|
||||
it("renames in the entry, and Enter hands the keyboard back to it", async () => {
|
||||
await mount();
|
||||
const update = vi.spyOn(api, "updateFolder").mockResolvedValue(OWN);
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("Receptek"));
|
||||
});
|
||||
await waitFor(() => expect(menuRows()).toContain("files.folder.rename"));
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "files.folder.rename")!,
|
||||
);
|
||||
const field = await screen.findByRole("textbox", { name: "files.folder.rename" });
|
||||
fireEvent.change(field, { target: { value: "Ételek" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Ételek" }));
|
||||
await waitFor(() => expect(document.activeElement).toBe(entry("Receptek")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesRail · what it says when a part is empty or missing", () => {
|
||||
it("says nothing was downloaded only when nothing is there — not when it all moved into own folders", async () => {
|
||||
await mount([folder(1, { source_key: "youtube", count: 0 }), { ...OWN, count: 3 }]);
|
||||
expect(screen.queryByText("files.rail.noSources")).toBeNull();
|
||||
cleanup();
|
||||
await mount([folder(1, { source_key: "youtube", count: 0 }), { ...OWN, count: 0 }]);
|
||||
expect(screen.getByText("files.rail.noSources")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a storage figure that failed to load says so, rather than the meter quietly not being there", async () => {
|
||||
vi.spyOn(api, "fileFolders").mockResolvedValue([YT, OWN]);
|
||||
vi.spyOn(api, "filesUsage").mockRejectedValue(new Error("down"));
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<FilesRail
|
||||
layout={defaultLayout("files")}
|
||||
setLayout={vi.fn()}
|
||||
collapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByText("files.usage.failed")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesRail · a folder shared by link", () => {
|
||||
it("marks the folder a live link opens, and only that one", async () => {
|
||||
const work = folder(5, { kind: "own", name: "Munka", retention_days: null });
|
||||
await mount([YT, { ...OWN, has_link: true }, work]);
|
||||
expect(entry("Receptek").textContent).toContain("files.folder.shared");
|
||||
expect(entry("Munka").textContent).not.toContain("files.folder.shared");
|
||||
expect(entry("files.source.youtube").textContent).not.toContain("files.folder.shared");
|
||||
});
|
||||
|
||||
it("copies the folder's link in one click, making it the first time", async () => {
|
||||
await mount();
|
||||
const list = vi.spyOn(api, "folderLinks").mockResolvedValue([]);
|
||||
const make = vi
|
||||
.spyOn(api, "createFolderLink")
|
||||
.mockResolvedValue({ id: 1, url: "/watch/f" } as never);
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("Receptek"));
|
||||
});
|
||||
await waitFor(() => expect(menuRows()).toContain("files.actions.copyLink"));
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "files.actions.copyLink")!,
|
||||
);
|
||||
await waitFor(() => expect(make).toHaveBeenCalledWith(2, {}));
|
||||
expect(list).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("opens the folder's Share dialog", async () => {
|
||||
await mount();
|
||||
vi.spyOn(api, "folderLinks").mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
token: "f",
|
||||
url: "/watch/f",
|
||||
allow_download: true,
|
||||
has_password: false,
|
||||
expires_at: null,
|
||||
view_count: 0,
|
||||
created_at: null,
|
||||
},
|
||||
]);
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("Receptek"));
|
||||
});
|
||||
await waitFor(() => expect(menuRows()).toContain("files.folder.shareLink"));
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "files.folder.shareLink")!,
|
||||
);
|
||||
expect(await screen.findByText("downloads.share.folderLinkHint")).toBeTruthy();
|
||||
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Meter } from "../../components/ui/Meter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react";
|
||||
import { api, type FileFolder } from "../../lib/api";
|
||||
import { compareText } from "../../lib/collate";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { type PanelLayout } from "../../lib/panelLayout";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
import { folderIcon, folderLabel } from "./fileFolders";
|
||||
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
|
||||
import { RenameField } from "../../components/ui/RenameField";
|
||||
import { useFolderActions } from "./useFolderActions";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
|
||||
// The Files module's left rail: the library's places — every file, the ones shared with me, the
|
||||
// source folders the library fills by itself, the user's own folders — and the storage meter. Lifted
|
||||
// to App level like the other rails (the SidePanel's collapse and customise come with it); the view it
|
||||
// picks lives in FilesProvider, which the Files page reads. A folder's right click (or long press, or
|
||||
// Shift+F10) opens its menu (`useFolderActions`); the same menu is the "…" beside an open folder's
|
||||
// name on the page, so nothing here is reachable only by a gesture.
|
||||
|
||||
const folderKey = (f: FileFolder) => f.id;
|
||||
|
||||
function Entry({
|
||||
icon: Icon,
|
||||
label,
|
||||
count,
|
||||
view,
|
||||
trigger,
|
||||
folderId,
|
||||
shared = false,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
count?: number;
|
||||
/** A live public link opens the folder: what is moved in is shown to whoever holds the link. */
|
||||
shared?: boolean;
|
||||
view: FilesView;
|
||||
/** The folder's menu triggers (right click, long press, Shift+F10). */
|
||||
trigger?: Record<string, unknown>;
|
||||
/** A folder's entry is found by it, to hand the keyboard back after a rename. */
|
||||
folderId?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { view: current } = useFiles();
|
||||
const { setView } = useFilesActions();
|
||||
const active = sameView(view, current);
|
||||
return (
|
||||
<Pressable
|
||||
{...trigger}
|
||||
data-folder-id={folderId}
|
||||
selectable
|
||||
layer={!active}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => setView(view)}
|
||||
className={clsx(
|
||||
"flex w-full min-h-8 items-center gap-2 rounded-lg px-2 text-sm",
|
||||
active ? "bg-tint-current font-medium text-accent" : "text-fg",
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
{shared && (
|
||||
<span title={t("files.folder.shared")} className="shrink-0">
|
||||
<Link2 aria-hidden className="size-3.5 text-accent" />
|
||||
<span className="sr-only">{t("files.folder.shared")}</span>
|
||||
</span>
|
||||
)}
|
||||
{count !== undefined && <span className="text-xs tabular-nums text-muted">{count}</span>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageMeter() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
// Live while something is in flight (a download's bytes land when the worker finishes it), at rest
|
||||
// otherwise — the old Download Center's meter, moved to where the files are.
|
||||
const usage = useLiveQuery(qk.storageUsage(), api.filesUsage, {
|
||||
intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
|
||||
});
|
||||
const u = usage.data;
|
||||
// A failed read says so where the figures would be, rather than the meter quietly not being there.
|
||||
if (!u)
|
||||
return usage.isError ? (
|
||||
<p className="mt-3 px-2 text-xs text-muted" data-testid="storage-meter">
|
||||
{t("files.usage.failed")}
|
||||
</p>
|
||||
) : null;
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-1 px-2 text-xs text-muted" data-testid="storage-meter">
|
||||
<span className="flex justify-between gap-2">
|
||||
<span>{t("files.usage.title")}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatBytes(u.footprint_bytes, lang)}
|
||||
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`}
|
||||
</span>
|
||||
</span>
|
||||
{!u.unlimited && (
|
||||
<Meter value={u.footprint_bytes} max={u.max_bytes} label={t("files.usage.title")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilesRail({
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { view } = useFiles();
|
||||
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
|
||||
const folders = foldersQ.data ?? [];
|
||||
const total = folders.reduce((n, f) => n + f.count, 0);
|
||||
// A source folder its files have all left stays on the server; here it shows only while it holds
|
||||
// something, or while it is the one open (so the page never points at a folder the rail hides).
|
||||
const shown = (f: FileFolder) => f.count > 0 || sameView(view, { kind: "folder", id: f.id });
|
||||
const byName = (a: FileFolder, b: FileFolder) =>
|
||||
compareText(folderLabel(a, t), folderLabel(b, t), i18n.language);
|
||||
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
|
||||
const own = folders.filter((f) => f.kind === "own").sort(byName);
|
||||
// The Hub folder closes the sources, shown even while empty: it exists only once a hub token made
|
||||
// it, and an empty one is where the hub's files will turn up.
|
||||
const hub = folders.filter((f) => f.kind === "hub");
|
||||
|
||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
// A name field left by Enter or Escape hands the keyboard back to what stands there now: the
|
||||
// folder's entry, or the New-folder command (RenameField).
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
const refocusFolder = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = refocusFolder.current;
|
||||
if (renamingId !== null || id === null) return;
|
||||
refocusFolder.current = null;
|
||||
railRef.current?.querySelector<HTMLElement>(`[data-folder-id="${id}"]`)?.focus();
|
||||
}, [renamingId]);
|
||||
const newFolder = useRef<HTMLButtonElement>(null);
|
||||
const refocusNew = useRef(false);
|
||||
useEffect(() => {
|
||||
if (creating || !refocusNew.current) return;
|
||||
refocusNew.current = false;
|
||||
newFolder.current?.focus();
|
||||
}, [creating]);
|
||||
const folderActions = useFolderActions({ onRename: (f) => setRenamingId(f.id) });
|
||||
const menu = useContextMenu<FileFolder>({ keyOf: folderKey });
|
||||
|
||||
const failed = foldersQ.isError && !foldersQ.data && (
|
||||
<Pressable
|
||||
onClick={() => foldersQ.refetch()}
|
||||
className="px-2 py-1 text-xs text-danger hover:underline"
|
||||
>
|
||||
{t("common.loadError")} — {t("common.retry")}
|
||||
</Pressable>
|
||||
);
|
||||
const entry = (f: FileFolder) =>
|
||||
renamingId === f.id ? (
|
||||
<RenameField
|
||||
key={f.id}
|
||||
initial={f.name ?? ""}
|
||||
label={t("files.folder.rename")}
|
||||
onDone={(name, byKey) => {
|
||||
if (byKey) refocusFolder.current = f.id;
|
||||
setRenamingId(null);
|
||||
if (name !== null) folderActions.rename(f, name);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Entry
|
||||
key={f.id}
|
||||
icon={folderIcon(f)}
|
||||
label={folderLabel(f, t)}
|
||||
count={f.count}
|
||||
shared={f.has_link}
|
||||
view={{ kind: "folder", id: f.id }}
|
||||
trigger={menu.triggerProps(f)}
|
||||
folderId={f.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const items: PanelItem[] = [
|
||||
{
|
||||
id: "library",
|
||||
title: t("files.rail.library"),
|
||||
body: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Entry
|
||||
icon={HardDrive}
|
||||
label={t("files.rail.all")}
|
||||
count={foldersQ.data ? total : undefined}
|
||||
view={{ kind: "all" }}
|
||||
/>
|
||||
<Entry icon={Users} label={t("files.rail.shared")} view={{ kind: "shared" }} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sources",
|
||||
title: t("files.rail.sources"),
|
||||
body: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{failed}
|
||||
{sources.map(entry)}
|
||||
{hub.map(entry)}
|
||||
{/* Only when there is nothing at all: files moved into own folders were still downloaded. */}
|
||||
{foldersQ.data && sources.length + hub.length === 0 && total === 0 && (
|
||||
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "own",
|
||||
title: t("files.rail.own"),
|
||||
body: (
|
||||
<div ref={railRef} className="flex flex-col gap-0.5">
|
||||
{failed}
|
||||
{own.map(entry)}
|
||||
{foldersQ.data && own.length === 0 && !creating && (
|
||||
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noOwn")}</span>
|
||||
)}
|
||||
{creating ? (
|
||||
<RenameField
|
||||
initial=""
|
||||
label={t("files.folder.newName")}
|
||||
onDone={(name, byKey) => {
|
||||
refocusNew.current = byKey;
|
||||
setCreating(false);
|
||||
if (name !== null) void folderActions.add(name);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
foldersQ.data && (
|
||||
<Pressable
|
||||
ref={newFolder}
|
||||
onClick={() => setCreating(true)}
|
||||
className="flex min-h-8 w-full items-center gap-2 rounded-lg px-2 text-sm text-muted hover:text-fg"
|
||||
>
|
||||
<FolderPlus className="size-4 shrink-0" />
|
||||
{t("files.folder.new")}
|
||||
</Pressable>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<FilterRail
|
||||
kind="files"
|
||||
title={t("files.navLabel")}
|
||||
icon={<FolderOpen className="w-4 h-4" />}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
>
|
||||
<StorageMeter />
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("files.folder.menu")}
|
||||
items={menu.target ? folderActions.items(menu.target) : []}
|
||||
/>
|
||||
{folderActions.dialog}
|
||||
</FilterRail>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The in-app library player streams the completed item's LOCAL file into a native media
|
||||
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
|
||||
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
||||
// is key-passthrough here.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render as rtlRender, cleanup } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
const expose = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||
|
||||
import LibraryPlayer from "./LibraryPlayer";
|
||||
import { api, type DownloadJob } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
const baseJob: DownloadJob = {
|
||||
id: 7,
|
||||
status: "done",
|
||||
progress: 100,
|
||||
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: "abc",
|
||||
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",
|
||||
display_uploader: null,
|
||||
display_uploader_url: null,
|
||||
extra_links: [],
|
||||
can_download: true,
|
||||
folder_id: null,
|
||||
kept: false,
|
||||
expires_at: null,
|
||||
asset: null,
|
||||
};
|
||||
|
||||
const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJob => ({
|
||||
...baseJob,
|
||||
asset: {
|
||||
id: 10,
|
||||
status: "ready",
|
||||
title: "Clip",
|
||||
uploader: null,
|
||||
uploader_url: null,
|
||||
upload_date: null,
|
||||
duration_s: 12,
|
||||
size_bytes: 1000,
|
||||
width: null,
|
||||
height: null,
|
||||
container: null,
|
||||
media_kind: "video",
|
||||
thumbnail_url: null,
|
||||
...over,
|
||||
},
|
||||
});
|
||||
|
||||
// The player writes its resume point into the library listings' cache, so it sits under a client.
|
||||
const render = (ui: ReactElement, client = new QueryClient()) =>
|
||||
rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** The rendered <video>, given a length and a clock the test sets (jsdom plays nothing). */
|
||||
function loaded(duration = 600) {
|
||||
const video = document.body.querySelector("video")!;
|
||||
let t = 0;
|
||||
Object.defineProperties(video, {
|
||||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||||
duration: { get: () => duration, configurable: true },
|
||||
readyState: { get: () => 1, configurable: true },
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
return video;
|
||||
}
|
||||
|
||||
describe("LibraryPlayer", () => {
|
||||
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
|
||||
render(
|
||||
<LibraryPlayer
|
||||
job={withAsset({ width: 1280, height: 720, container: "mp4" })}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const video = document.body.querySelector("video");
|
||||
expect(video).not.toBeNull();
|
||||
expect(video!.getAttribute("src")).toBe("/api/files/7/file");
|
||||
// No <audio> element for a video asset.
|
||||
expect(document.body.querySelector("audio")).toBeNull();
|
||||
});
|
||||
|
||||
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", () => {
|
||||
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
|
||||
expect(expose).toHaveBeenCalledWith(document.body.querySelector("video"));
|
||||
});
|
||||
|
||||
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
|
||||
render(
|
||||
<LibraryPlayer
|
||||
job={withAsset({ media_kind: "audio", container: "m4a" })}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const audio = document.body.querySelector("audio");
|
||||
expect(audio).not.toBeNull();
|
||||
expect(audio!.getAttribute("src")).toBe("/api/files/7/file");
|
||||
expect(document.body.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses media_kind — an audio upload with an uncommon container still renders <audio>", () => {
|
||||
// The exact bug the fix targets: the old container whitelist (m4a/mp3/…) would drop an .m4b/.weba
|
||||
// audio upload into a <video> box. media_kind='audio' is authoritative regardless of container.
|
||||
render(
|
||||
<LibraryPlayer
|
||||
job={withAsset({ media_kind: "audio", container: "m4b" })}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||
expect(document.body.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the container heuristic for a legacy null media_kind", () => {
|
||||
// A pre-discriminator asset (media_kind null) still resolves audio vs video from dims+container.
|
||||
render(
|
||||
<LibraryPlayer job={withAsset({ media_kind: null, container: "mp3" })} onClose={() => {}} />,
|
||||
);
|
||||
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens focused on its panel, so Space plays rather than pressing the dialog's ✕", () => {
|
||||
// jsdom lays nothing out, so every element reads as hidden (`offsetParent` null) and a dialog
|
||||
// falls back to its panel whatever it asked for; as in a browser, the ✕ is on screen here.
|
||||
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
) {
|
||||
return this.parentElement;
|
||||
});
|
||||
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
|
||||
const panel = document.activeElement as HTMLElement;
|
||||
expect(panel.tagName).toBe("DIV");
|
||||
expect(panel.contains(document.body.querySelector("video"))).toBe(true);
|
||||
});
|
||||
|
||||
it("opens where the viewer stopped — or from the start when asked", () => {
|
||||
const job = { ...withAsset({ width: 1280, height: 720 }), position_seconds: 300 };
|
||||
render(<LibraryPlayer job={job} onClose={() => {}} />);
|
||||
expect(loaded().currentTime).toBe(300);
|
||||
cleanup();
|
||||
render(<LibraryPlayer job={job} fromStart onClose={() => {}} />);
|
||||
expect(loaded().currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps where it stopped: on the server, and on its row in every library list at once", () => {
|
||||
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
|
||||
ok: true,
|
||||
job_id: 7,
|
||||
position_seconds: 125,
|
||||
});
|
||||
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
|
||||
const client = new QueryClient();
|
||||
const job = withAsset({ width: 1280, height: 720 });
|
||||
// The file sits in two lists at once: its folder's and the whole library's.
|
||||
client.setQueryData(qk.files({ folderId: 1 }), [job, { ...job, id: 8 }]);
|
||||
client.setQueryData(qk.files({}), [job]);
|
||||
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
||||
const video = loaded();
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
video.currentTime = 125.4;
|
||||
video.dispatchEvent(new Event("pause"));
|
||||
|
||||
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
|
||||
const rows = client.getQueryData<DownloadJob[]>(qk.files({ folderId: 1 }))!;
|
||||
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
|
||||
expect(client.getQueryData<DownloadJob[]>(qk.files({}))![0]!.position_seconds).toBe(125);
|
||||
// Closing the player sends the point the way that survives the page going away.
|
||||
unmount();
|
||||
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
|
||||
});
|
||||
|
||||
it("keeps the closing point over a list fetch that read the server before the point landed", async () => {
|
||||
// The listing polls. One already out when the player closed answered with the point the server
|
||||
// held BEFORE the closing beacon, over the row's patch — and Continue then opened there, and its
|
||||
// first checkpoint wrote that older point back over the right one.
|
||||
let settled: (() => void) | undefined;
|
||||
vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation((_id, _at, _dur, onSettled) => {
|
||||
settled = onSettled;
|
||||
});
|
||||
const client = new QueryClient();
|
||||
const job = withAsset({ width: 1280, height: 720 });
|
||||
client.setQueryData(qk.files({}), [{ ...job, position_seconds: 10 }]);
|
||||
let answer: (jobs: DownloadJob[]) => void = () => {};
|
||||
const poll = client.fetchQuery({
|
||||
queryKey: qk.files({}),
|
||||
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
|
||||
});
|
||||
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
||||
const video = loaded();
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
video.currentTime = 300;
|
||||
unmount();
|
||||
answer([{ ...job, position_seconds: 10 }]);
|
||||
await poll;
|
||||
expect(client.getQueryData<DownloadJob[]>(qk.files({}))![0]!.position_seconds).toBe(300);
|
||||
// Once the point has landed, the listing asks the server again.
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
settled?.();
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { api, type DownloadJob } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { exposeVideoToBackgroundPlay } from "../../lib/browserBackgroundPlay";
|
||||
import { useMediaLogRef } from "../../lib/mediaLog";
|
||||
import { useMediaSession } from "../../lib/mediaSession";
|
||||
import { useMediaKeys } from "../../lib/mediaKeys";
|
||||
import { keptPosition, useResume } from "../../lib/resume";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
||||
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
||||
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
|
||||
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
|
||||
// <video>/<audio controls> — the same element VideoEditor already plays this URL in. Downloads are a
|
||||
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
|
||||
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
|
||||
// any codec (best-effort playback).
|
||||
//
|
||||
// It opens where the viewer stopped (`job.position_seconds`, from the library listing) unless
|
||||
// `fromStart`, and keeps the point on the server as it plays (`lib/resume.ts`); the OS's media
|
||||
// controls and the basic keys drive it like the other players.
|
||||
export default function LibraryPlayer({
|
||||
job,
|
||||
fromStart = false,
|
||||
onClose,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
fromStart?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
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 —
|
||||
// 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).
|
||||
// Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind.
|
||||
const asset = job.asset;
|
||||
const isAudio =
|
||||
asset?.media_kind === "audio" ||
|
||||
(asset?.media_kind == null &&
|
||||
!(asset?.width && asset?.height) &&
|
||||
!!asset?.container &&
|
||||
["m4a", "mp3", "opus", "ogg", "wav", "flac", "aac"].includes(asset.container.toLowerCase()));
|
||||
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
|
||||
// the fallback. Same precedence as the library-row thumbnail.
|
||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||
const logMedia = useMediaLogRef("library");
|
||||
// The element as STATE, so the session, the keys and the resume bind to it once it exists.
|
||||
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||
const mediaRef = useCallback(
|
||||
(el: HTMLMediaElement | null) => {
|
||||
if (el instanceof HTMLVideoElement) exposeVideoToBackgroundPlay(el);
|
||||
logMedia(el);
|
||||
setMedia(el);
|
||||
},
|
||||
[logMedia],
|
||||
);
|
||||
|
||||
const title = jobTitle(job);
|
||||
useMediaSession(media, {
|
||||
title,
|
||||
artist: job.display_uploader ?? asset?.uploader ?? null,
|
||||
artwork: poster ?? null,
|
||||
});
|
||||
useMediaKeys(media);
|
||||
// The library row's bar follows the point at once — every library listing this file may sit in
|
||||
// (each folder, search and the shared view is its own key under `files`) — rather than after the
|
||||
// next refetch. On the way out a listing read may already be out, having read the server before
|
||||
// the closing point lands: it is cancelled, or its answer replaced the row's point with the older
|
||||
// one, Continue opened there, and its first checkpoint wrote it back over the right one. Once the
|
||||
// point is on the server, the listings are read again.
|
||||
const lists = { queryKey: qk.files() };
|
||||
const keepPoint = (position: number, duration: number, leaving: boolean) => {
|
||||
const at = keptPosition(position, duration);
|
||||
if (leaving) void qc.cancelQueries(lists);
|
||||
qc.setQueriesData<DownloadJob[]>(lists, (jobs) =>
|
||||
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
|
||||
);
|
||||
if (leaving) {
|
||||
api.saveDownloadProgressBeacon(job.id, position, duration, () => {
|
||||
void qc.invalidateQueries(lists);
|
||||
});
|
||||
} else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
|
||||
};
|
||||
useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint);
|
||||
|
||||
return (
|
||||
<Modal initialFocus="player" title={title} onClose={onClose} maxWidth="max-w-4xl">
|
||||
{isAudio ? (
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
{poster ? (
|
||||
<RemoteImage
|
||||
src={poster}
|
||||
alt=""
|
||||
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||
/>
|
||||
) : null}
|
||||
<audio ref={mediaRef} src={fileUrl} controls autoPlay className="w-full">
|
||||
{t("downloads.player.unsupported")}
|
||||
</audio>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
ref={mediaRef}
|
||||
src={fileUrl}
|
||||
poster={poster}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="w-full max-h-[70dvh] rounded-lg bg-black object-contain"
|
||||
>
|
||||
{t("downloads.player.unsupported")}
|
||||
</video>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClipboardPaste, Download } from "lucide-react";
|
||||
import {
|
||||
asDownloadLink,
|
||||
requestDownload,
|
||||
takeLinkBox,
|
||||
useLinkBoxRequest,
|
||||
} from "../../lib/downloadIntake";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
|
||||
/** The clipboard's text, or null where the browser refuses. Outside the component: the React
|
||||
* Compiler cannot lower an optional call inside a try yet, and skipped the whole page for it. */
|
||||
async function clipboardText(): Promise<string | null> {
|
||||
try {
|
||||
return (await navigator.clipboard?.readText()) ?? null;
|
||||
} catch {
|
||||
return null; // permission denied or unsupported — the paste button still works on a click
|
||||
}
|
||||
}
|
||||
|
||||
// "Paste a link and I download it": the field, the clipboard button and Download. The link goes to
|
||||
// the app's one download dialog (lib/downloadIntake), where the profile is picked.
|
||||
export function LinkBox({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState("");
|
||||
const field = useRef<HTMLInputElement>(null);
|
||||
|
||||
const submit = () => {
|
||||
const link = url.trim();
|
||||
if (!link) return;
|
||||
requestDownload(link);
|
||||
setUrl("");
|
||||
};
|
||||
|
||||
// Pull a link off the clipboard. Reading it needs a secure context and a gesture or a permission
|
||||
// (iOS Safari and Brave are strict), so it is best-effort: the button is a gesture and reliable, the
|
||||
// field's focus tries silently. Only an http(s) link is taken, and never over what was typed.
|
||||
const paste = async (force: boolean) => {
|
||||
if (!force && url.trim()) return;
|
||||
const link = asDownloadLink(await clipboardText());
|
||||
// Asked again once the read is back: it can wait on a permission prompt, and what was typed
|
||||
// meanwhile is the reader's.
|
||||
if (link) setUrl((typed) => (force || !typed.trim() ? link : typed));
|
||||
};
|
||||
|
||||
// The palette's "Download from link…" (lib/downloadIntake): the request wakes this effect, the
|
||||
// take decides — it clears the token, so a later pass or another entry's visit does nothing.
|
||||
const request = useLinkBoxRequest();
|
||||
useEffect(() => {
|
||||
if (request !== null && takeLinkBox()) field.current?.focus();
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
// Narrow, the field takes its own line and the two buttons go under it, right: beside them it
|
||||
// was left a few letters wide on a phone.
|
||||
<span className={`flex min-w-0 flex-wrap items-center justify-end gap-2 ${className ?? ""}`}>
|
||||
<Input
|
||||
ref={field}
|
||||
size="sm"
|
||||
className="min-w-0 flex-[1_1_14rem]"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onFocus={() => void paste(false)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
placeholder={t("files.link.placeholder")}
|
||||
aria-label={t("files.link.label")}
|
||||
/>
|
||||
<IconButton
|
||||
label={t("downloads.page.paste")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void paste(true)}
|
||||
>
|
||||
<ClipboardPaste className="w-4 h-4" />
|
||||
</IconButton>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Download className="w-4 h-4" />}
|
||||
onClick={submit}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("files.link.download")}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The share dialog's link row says two things with colour alone: a PROTECTED link's lock is lit, and
|
||||
// Revoke is the destructive one. Both colours once sat on the button, where the ghost variant's own
|
||||
// colour class outranks them (clsx does not merge Tailwind), so a password-protected link looked
|
||||
// unprotected and Revoke hovered neutral. They live on the ICON now; this pins them there.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Partial: the import graph reaches `src/i18n`, which initialises against the real
|
||||
// `initReactI18next`, so a wholesale mock throws before any test runs.
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../components/ui/Modal", () => ({
|
||||
default: ({ title, children }: { title: string; children: ReactNode }) => (
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
|
||||
const h = vi.hoisted(() => ({
|
||||
confirm: false,
|
||||
revokeDownloadLink: vi.fn(),
|
||||
links: null as unknown[] | null,
|
||||
folderLinks: vi.fn(),
|
||||
createFolderLink: vi.fn(),
|
||||
createDownloadLink: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
shareRecipients: async () => [],
|
||||
folderLinks: h.folderLinks,
|
||||
createFolderLink: h.createFolderLink,
|
||||
createDownloadLink: h.createDownloadLink,
|
||||
revokeDownloadLink: h.revokeDownloadLink,
|
||||
downloadLinks: async () =>
|
||||
h.links ?? [
|
||||
{
|
||||
id: 1,
|
||||
token: "a",
|
||||
url: "/watch/a",
|
||||
allow_download: false,
|
||||
has_password: true,
|
||||
expires_at: null,
|
||||
view_count: 0,
|
||||
created_at: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
token: "b",
|
||||
url: "/watch/b",
|
||||
allow_download: false,
|
||||
has_password: false,
|
||||
expires_at: null,
|
||||
view_count: 0,
|
||||
created_at: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
import ShareDialog from "./ShareDialog";
|
||||
import type { DownloadJob } from "../../lib/api";
|
||||
|
||||
afterEach(cleanup);
|
||||
beforeEach(() => {
|
||||
h.links = null;
|
||||
h.confirm = false;
|
||||
});
|
||||
|
||||
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
|
||||
|
||||
describe("ShareDialog · a link row's state colours", () => {
|
||||
it("lights the lock of a protected link only, and paints Revoke as danger — on the icon", async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const protectedLock = await screen.findByRole("button", {
|
||||
name: "downloads.share.changePassword",
|
||||
});
|
||||
const openLock = screen.getByRole("button", { name: "downloads.share.setPassword" });
|
||||
expect(iconClass(protectedLock)).toContain("text-accent");
|
||||
expect(iconClass(openLock)).not.toContain("text-accent");
|
||||
|
||||
for (const revoke of screen.getAllByRole("button", { name: "downloads.share.revoke" }))
|
||||
expect(iconClass(revoke)).toContain("text-danger");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShareDialog · a link whose address cannot be shown again", () => {
|
||||
it("says so in place of the address, with nothing to copy", async () => {
|
||||
const link = (id: number, url: string | null) => ({
|
||||
id,
|
||||
token: url && url.slice("/watch/".length),
|
||||
url,
|
||||
allow_download: false,
|
||||
has_password: false,
|
||||
expires_at: null,
|
||||
view_count: 0,
|
||||
created_at: null,
|
||||
});
|
||||
h.links = [link(3, null), link(2, "/watch/b")];
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByText("downloads.share.urlHidden")).toBeTruthy();
|
||||
// One row has an address to show and copy; the hidden one shows none (not "…/null").
|
||||
expect(screen.getAllByRole("button", { name: "downloads.share.copy" })).toHaveLength(1);
|
||||
const shown = screen.getAllByRole("textbox").map((el) => (el as HTMLInputElement).value);
|
||||
expect(shown).toContain(`${window.location.origin}/watch/b`);
|
||||
expect(shown.some((v) => v.includes("null"))).toBe(false);
|
||||
// Its other controls stay: it can still be protected, made downloadable, or revoked.
|
||||
expect(screen.getAllByRole("button", { name: "downloads.share.revoke" })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShareDialog · a folder", () => {
|
||||
const link = (id: number) => ({
|
||||
id,
|
||||
token: `f${id}`,
|
||||
url: `/watch/f${id}`,
|
||||
allow_download: true,
|
||||
has_password: false,
|
||||
expires_at: null,
|
||||
view_count: 0,
|
||||
created_at: null,
|
||||
});
|
||||
|
||||
it("shares by link only, says what a folder link shows, and makes it for the folder", async () => {
|
||||
h.folderLinks.mockReset().mockResolvedValue([link(4)]);
|
||||
h.createFolderLink.mockReset().mockResolvedValue(link(5));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
|
||||
expect(h.folderLinks).toHaveBeenCalledWith(7);
|
||||
expect(screen.getByRole("heading").textContent).toBe("downloads.share.folderTitle");
|
||||
expect(screen.getByText("downloads.share.folderLinkHint")).toBeTruthy();
|
||||
// No sharing with a user: "Shared with me" lists files, not folders.
|
||||
expect(screen.queryByText("downloads.share.userSection")).toBeNull();
|
||||
|
||||
// A stream-only link, then the form is back at its default for the next one: downloadable.
|
||||
fireEvent.click(screen.getByRole("button", { name: "downloads.share.newLink" }));
|
||||
// The form's own checkbox, under the link rows' (each row has its own).
|
||||
const allow = () => {
|
||||
const boxes = screen.getAllByRole("checkbox", { name: "downloads.share.allowDownload" });
|
||||
return boxes[boxes.length - 1]!;
|
||||
};
|
||||
fireEvent.click(allow());
|
||||
expect((allow() as HTMLInputElement).checked).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "downloads.share.createLink" }));
|
||||
await waitFor(() =>
|
||||
expect(h.createFolderLink).toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({
|
||||
allow_download: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(h.createDownloadLink).not.toHaveBeenCalled();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.newLink" }));
|
||||
expect((allow() as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("a list that did not load says so with Retry, and offers no New link beside it", async () => {
|
||||
h.folderLinks
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error("503"))
|
||||
.mockResolvedValue([link(4)]);
|
||||
h.createFolderLink.mockReset();
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByText("downloads.share.linksFailed")).toBeTruthy();
|
||||
expect(screen.queryByText("downloads.share.noLinks")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "downloads.share.newLink" })).toBeNull();
|
||||
expect(h.createFolderLink).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
|
||||
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "downloads.share.newLink" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("makes a link on opening with none, but never right after the last one was revoked", async () => {
|
||||
h.folderLinks.mockReset().mockResolvedValue([]);
|
||||
h.createFolderLink.mockReset().mockResolvedValue(link(6));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(h.createFolderLink).toHaveBeenCalledTimes(1));
|
||||
view.unmount();
|
||||
|
||||
h.confirm = true;
|
||||
h.folderLinks
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce([link(4)])
|
||||
.mockResolvedValue([]);
|
||||
h.createFolderLink.mockReset();
|
||||
h.revokeDownloadLink.mockReset().mockResolvedValue({ ok: true, deleted: 4 });
|
||||
const qc2 = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc2}>
|
||||
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.revoke" }));
|
||||
await waitFor(() => expect(h.revokeDownloadLink).toHaveBeenCalledWith(4));
|
||||
await screen.findByText("downloads.share.noLinks");
|
||||
expect(h.createFolderLink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../../lib/api";
|
||||
import {
|
||||
fetchLinks,
|
||||
invalidateLinks,
|
||||
linksKey,
|
||||
makeLink,
|
||||
type LinkTarget,
|
||||
} from "../../lib/downloads";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Input, InnerInput } from "../../components/ui/Input";
|
||||
import { CheckRow } from "../../components/ui/NativeControls";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import UserPicker from "./UserPicker";
|
||||
import { useCopyFeedback } from "../../components/ui/useCopyFeedback";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||
|
||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||
|
||||
// One shared empty list while the recipients load: a fresh `[]` per render made `filtered` below
|
||||
// recompute on every render.
|
||||
const NO_RECIPIENTS: ShareRecipient[] = [];
|
||||
|
||||
function UserShare({ job }: { job: DownloadJob }) {
|
||||
const { t } = useTranslation();
|
||||
const recipients = useQuery({ queryKey: qk.shareRecipients(), queryFn: api.shareRecipients });
|
||||
const list = recipients.data ?? NO_RECIPIENTS;
|
||||
|
||||
const share = useMutation({
|
||||
mutationFn: (email: string) => api.shareDownload(job.id, email),
|
||||
onSuccess: (r) => {
|
||||
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
|
||||
},
|
||||
onError: (e) => notifyActionError(e, t("downloads.share.failed")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-1">
|
||||
<UserPlus className="w-4 h-4 text-muted" /> {t("downloads.share.userSection")}
|
||||
</div>
|
||||
<p className="text-xs text-muted mb-2">{t("downloads.share.userHint")}</p>
|
||||
<UserPicker
|
||||
users={list}
|
||||
loading={recipients.isLoading}
|
||||
error={recipients.isError}
|
||||
onRetry={() => void recipients.refetch()}
|
||||
disabled={share.isPending}
|
||||
onPick={(u) => {
|
||||
if (!share.isPending) share.mutate(u.email);
|
||||
}}
|
||||
label={t("downloads.share.userSection")}
|
||||
placeholder={t("downloads.share.pickPlaceholder")}
|
||||
/>
|
||||
{recipients.data && list.length === 0 && (
|
||||
<p className="text-xs text-muted mt-1">{t("downloads.share.noUsers")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- share by public link -------------------------------------------------------------------
|
||||
|
||||
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { copied, copy: copyValue } = useCopyFeedback();
|
||||
const [editPass, setEditPass] = useState(false);
|
||||
const [newPass, setNewPass] = useState("");
|
||||
const fullUrl = link.url && window.location.origin + link.url;
|
||||
|
||||
const copy = () => {
|
||||
if (fullUrl) void copyValue(fullUrl);
|
||||
};
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
||||
onSuccess: onChanged,
|
||||
meta: { failTitle: "errors.writeFailed.shareLinkDownload" },
|
||||
});
|
||||
const revoke = useMutation({
|
||||
mutationFn: () => api.revokeDownloadLink(link.id),
|
||||
onSuccess: onChanged,
|
||||
meta: { failTitle: "errors.writeFailed.shareLinkRevoke" },
|
||||
});
|
||||
// Set / change / clear the link password. Rotating (or clearing) it bumps the link's
|
||||
// password_version server-side, which invalidates any watch grant issued under the old password.
|
||||
const savePass = useMutation({
|
||||
mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }),
|
||||
meta: { failTitle: "errors.writeFailed.shareLinkPassword" },
|
||||
onSuccess: () => {
|
||||
setEditPass(false);
|
||||
setNewPass("");
|
||||
onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
const badges: string[] = [];
|
||||
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
|
||||
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
|
||||
if (link.expires_at)
|
||||
badges.push(
|
||||
t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${SURFACE.card} p-2.5 space-y-2`}>
|
||||
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
||||
<Link2 className="w-4 h-4 text-muted shrink-0" />
|
||||
{fullUrl ? (
|
||||
<>
|
||||
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
|
||||
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
|
||||
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
// The server keeps no copy of this link's address it can read: the link still opens for
|
||||
// whoever has it, but it cannot be shared again from here.
|
||||
<span className="flex-1 min-w-0 text-xs text-muted">
|
||||
{t("downloads.share.urlHidden")}
|
||||
</span>
|
||||
)}
|
||||
{/* All three on the same rung. Only Copy was migrated at first, which left one 2rem button
|
||||
between two 1.75rem ones with a different radius and hover fill — and, on a coarse
|
||||
pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive
|
||||
Revoke stayed small. */}
|
||||
<IconButton
|
||||
label={
|
||||
link.has_password
|
||||
? t("downloads.share.changePassword")
|
||||
: t("downloads.share.setPassword")
|
||||
}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setNewPass("");
|
||||
setEditPass((v) => !v);
|
||||
}}
|
||||
// On the ICON, not the button: `clsx` does not merge Tailwind, so a colour className loses
|
||||
// to the variant's own `text-muted`/`hover:text-fg` by stylesheet order and the state was
|
||||
// simply invisible. The Copy button above already does it this way.
|
||||
>
|
||||
<Lock className={`w-4 h-4 ${link.has_password ? "text-accent" : ""}`} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={t("downloads.share.revoke")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t("downloads.share.revoke"),
|
||||
message: t("downloads.share.revokeConfirm"),
|
||||
confirmLabel: t("downloads.share.revoke"),
|
||||
danger: true,
|
||||
})
|
||||
)
|
||||
revoke.mutate();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-danger" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" /> {t("downloads.share.views", { count: link.view_count })}
|
||||
</span>
|
||||
{badges.map((b) => (
|
||||
<span
|
||||
key={b}
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm bg-surface"
|
||||
>
|
||||
{b === t("downloads.share.hasPassword") && <Lock className="w-3 h-3" />}
|
||||
{b}
|
||||
</span>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<CheckRow checked={link.allow_download} onChange={() => toggle.mutate()}>
|
||||
{t("downloads.share.allowDownload")}
|
||||
</CheckRow>
|
||||
</div>
|
||||
{editPass && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPass}
|
||||
onChange={(e) => setNewPass(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && newPass.trim() && savePass.mutate(newPass.trim())
|
||||
}
|
||||
placeholder={t(
|
||||
link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => savePass.mutate(newPass.trim())}
|
||||
loading={savePass.isPending}
|
||||
disabled={!newPass.trim()}
|
||||
>
|
||||
{t("downloads.share.savePassword")}
|
||||
</Button>
|
||||
{link.has_password && (
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t("downloads.share.removePassword"),
|
||||
message: t("downloads.share.removePasswordConfirm"),
|
||||
confirmLabel: t("downloads.share.removePassword"),
|
||||
danger: true,
|
||||
})
|
||||
)
|
||||
savePass.mutate("");
|
||||
}}
|
||||
disabled={savePass.isPending}
|
||||
>
|
||||
{t("downloads.share.removePassword")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkShare({ target }: { target: LinkTarget }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const links = useQuery({
|
||||
queryKey: linksKey(target),
|
||||
queryFn: () => fetchLinks(target),
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
// Downloadable by default — matches the one-click Share (which relies on the backend
|
||||
// default) and the common "let them have the file" intent. The owner can uncheck it here.
|
||||
const [allowDownload, setAllowDownload] = useState(true);
|
||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const invalidate = () => invalidateLinks(qc, target);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
makeLink(target, {
|
||||
allow_download: allowDownload,
|
||||
expires_days: expiryDays,
|
||||
password: password.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
// Back to the form's own default for the next link: downloadable.
|
||||
setAllowDownload(true);
|
||||
setExpiryDays(null);
|
||||
setPassword("");
|
||||
},
|
||||
meta: { failTitle: "downloads.share.linkFailed" },
|
||||
});
|
||||
|
||||
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
||||
// (no password, no expiry, downloadable) link the moment the dialog finds none — as if the
|
||||
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||
const autoTried = useRef(false);
|
||||
// The mutation OBJECT is rebuilt every render; `mutate` is not (react-query memoises it), so the
|
||||
// two halves the effect actually uses can be named while `links.data` stays the only real trigger.
|
||||
// The autoTried ref is what makes it fire exactly once — and only on the list the dialog OPENED
|
||||
// with: revoking the last link empties the list too, and minting a fresh public link right after
|
||||
// the owner revoked one is the opposite of what they asked for.
|
||||
const { mutate: createLink } = create;
|
||||
useEffect(() => {
|
||||
if (!links.data || autoTried.current) return;
|
||||
autoTried.current = true;
|
||||
if (links.data.length === 0) createLink();
|
||||
}, [links.data, createLink]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-1">
|
||||
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
|
||||
</div>
|
||||
<p className="text-xs text-muted mb-2">
|
||||
{t(
|
||||
target.kind === "folder" ? "downloads.share.folderLinkHint" : "downloads.share.linkHint",
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* A list that did not load is said so, with Retry — never shown as "no links", and with no
|
||||
"New link" beside it, which would add a second public link next to ones not seen. */}
|
||||
{!links.data ? (
|
||||
links.isError ? (
|
||||
<StateMessage tone="error" compact onRetry={() => void links.refetch()}>
|
||||
{t("downloads.share.linksFailed")}
|
||||
</StateMessage>
|
||||
) : (
|
||||
<LoadingState label={t("common.loading")} className="py-4" />
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{links.data.map((l) => (
|
||||
<LinkRow key={l.id} link={l} onChanged={invalidate} />
|
||||
))}
|
||||
{links.data.length === 0 && !creating && !create.isPending && (
|
||||
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!links.data ? null : creating ? (
|
||||
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
|
||||
<CheckRow checked={allowDownload} onChange={setAllowDownload} className="text-sm">
|
||||
{t("downloads.share.allowDownload")}
|
||||
</CheckRow>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted">{t("downloads.share.expiry")}:</span>
|
||||
{EXPIRY_DAYS.map((d) => (
|
||||
<Button
|
||||
key={String(d)}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
pressed={expiryDays === d}
|
||||
onClick={() => setExpiryDays(d)}
|
||||
>
|
||||
{d === null
|
||||
? t("downloads.share.expiryNever")
|
||||
: t("downloads.share.days", { count: d })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("downloads.share.password")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setCreating(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => create.mutate()}
|
||||
disabled={create.isPending}
|
||||
>
|
||||
{t("downloads.share.createLink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreating(true)}
|
||||
icon={<Link2 className="w-4 h-4" />}
|
||||
className="mt-2"
|
||||
>
|
||||
{t("downloads.share.newLink")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A file is shared with a registered user and by link; a folder by link only — "Shared with me" lists
|
||||
// files, and a folder link already shows its files to whoever has it.
|
||||
type ShareDialogProps = { onClose: () => void } & (
|
||||
| { job: DownloadJob; folder?: never }
|
||||
| { folder: { id: number; name: string }; job?: never }
|
||||
);
|
||||
|
||||
export default function ShareDialog({ job, folder, onClose }: ShareDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
if (folder)
|
||||
return (
|
||||
<Modal
|
||||
initialFocus="close"
|
||||
title={t("downloads.share.folderTitle", { name: folder.name })}
|
||||
onClose={onClose}
|
||||
>
|
||||
<LinkShare target={{ kind: "folder", id: folder.id }} />
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal initialFocus="close" title={t("downloads.share.title")} onClose={onClose}>
|
||||
<div className="space-y-5">
|
||||
<UserShare job={job} />
|
||||
<div className="border-t border-border" />
|
||||
<LinkShare target={{ kind: "file", id: job.id }} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The local-upload dialog. It's a general file upload (any type, no format list): a picked
|
||||
// file surfaces its name+size, and Upload streams it through api.uploadFile (the XHR-progress
|
||||
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
|
||||
// actually reach the ingest call, and that the success toast points at the library. i18n is
|
||||
// key-passthrough.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
uploadFile: vi.fn(async (_f: File, _o: unknown) => ({ id: 42, status: "done" })),
|
||||
// The dialog reads the per-file upload cap so it can refuse an over-cap file before spending the
|
||||
// user's bandwidth on it. Generous by default; the cap test below lowers it.
|
||||
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
||||
notify: vi.fn(),
|
||||
showInTray: vi.fn(),
|
||||
uploadStarted: vi.fn(),
|
||||
uploadEnded: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
}));
|
||||
|
||||
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("../../lib/api", () => ({
|
||||
api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage },
|
||||
}));
|
||||
vi.mock("../../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
|
||||
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
notify: h.notify,
|
||||
}));
|
||||
vi.mock("../../lib/downloadQueue", () => ({
|
||||
showInTray: h.showInTray,
|
||||
uploadStarted: h.uploadStarted,
|
||||
uploadEnded: h.uploadEnded,
|
||||
}));
|
||||
|
||||
import UploadDialog from "./UploadDialog";
|
||||
import { FilesActionsContext } from "./filesContext";
|
||||
|
||||
const wrap = (
|
||||
onClose = vi.fn(),
|
||||
folderId: number | null = null,
|
||||
initialFile: File | null = null,
|
||||
) => {
|
||||
const qc = new QueryClient();
|
||||
const files = {
|
||||
setSearch: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
openFile: h.openFile,
|
||||
consumeFocus: vi.fn(),
|
||||
};
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FilesActionsContext.Provider value={files}>
|
||||
<UploadDialog folderId={folderId} initialFile={initialFile} onClose={onClose} />
|
||||
</FilesActionsContext.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
// The dialog portals into document.body, so query the whole document, not the render container.
|
||||
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
beforeEach(() => {
|
||||
h.uploadFile.mockClear();
|
||||
h.openFile.mockClear();
|
||||
h.showInTray.mockClear();
|
||||
h.notify.mockClear();
|
||||
h.filesUsage.mockClear();
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("UploadDialog", () => {
|
||||
it("shows the picked file's name and uploads it with the optional name", async () => {
|
||||
const onClose = vi.fn();
|
||||
wrap(onClose);
|
||||
const f = new File(["hello world"], "notes.pdf", { type: "application/pdf" });
|
||||
fireEvent.change(fileInput(), { target: { files: [f] } });
|
||||
|
||||
// The drop zone now surfaces the file name.
|
||||
expect(screen.getByText("notes.pdf")).toBeTruthy();
|
||||
|
||||
// Optional display name.
|
||||
const nameBox = screen.getByPlaceholderText("downloads.upload.namePlaceholder");
|
||||
fireEvent.change(nameBox, { target: { value: "My notes" } });
|
||||
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
|
||||
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
||||
const [passedFile, opts] = h.uploadFile.mock.calls[0]!;
|
||||
expect(passedFile).toBe(f);
|
||||
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
expect(h.notify.mock.calls[0]![0]).toMatchObject({ message: "downloads.upload.added" });
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("says so when the server queued the upload for its phone-safe conversion", async () => {
|
||||
// A video no phone could play as it is (an iPhone's HEVC .mov) comes back `queued`, not `done`:
|
||||
// "File uploaded" alone would send the user to a Library row that is still converting.
|
||||
h.uploadFile.mockResolvedValueOnce({ id: 43, status: "queued" });
|
||||
wrap();
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(["x"], "clip.mov", { type: "video/quicktime" })] },
|
||||
});
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
expect(h.notify.mock.calls[0]![0]).toMatchObject({
|
||||
message: "downloads.upload.addedConverting",
|
||||
});
|
||||
// Its finish is still to come, and the tray's to announce.
|
||||
expect(h.uploadEnded).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("an upload that failed or was cut off still says it ended, so later finishes are announced", async () => {
|
||||
h.uploadFile.mockRejectedValueOnce(new Error("network"));
|
||||
h.uploadStarted.mockClear();
|
||||
h.uploadEnded.mockClear();
|
||||
wrap();
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(["x"], "clip.mp4", { type: "video/mp4" })] },
|
||||
});
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.uploadEnded).toHaveBeenCalledTimes(1));
|
||||
expect(h.uploadEnded).toHaveBeenCalledWith(null);
|
||||
expect(h.uploadStarted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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: the tray opens on it.
|
||||
h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never);
|
||||
wrap(vi.fn(), 5);
|
||||
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } });
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
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();
|
||||
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
|
||||
expect(h.showInTray).not.toHaveBeenCalled();
|
||||
// Done and said so here: the queue's next look must not announce it again, as a download.
|
||||
expect(h.uploadStarted).toHaveBeenCalled();
|
||||
expect(h.uploadEnded).toHaveBeenCalledWith(42);
|
||||
|
||||
cleanup();
|
||||
h.notify.mockClear();
|
||||
h.uploadFile.mockResolvedValueOnce({ id: 43, status: "queued" });
|
||||
wrap();
|
||||
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "b.mov")] } });
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
||||
expect(h.showInTray).toHaveBeenLastCalledWith(43);
|
||||
});
|
||||
|
||||
it("opens ready to send a file dropped on the page", async () => {
|
||||
const dropped = new File(["dropped"], "recept.pdf");
|
||||
wrap(vi.fn(), null, dropped);
|
||||
expect(screen.getByText("recept.pdf")).toBeTruthy();
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
expect(btn.disabled).toBe(false);
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
||||
expect(h.uploadFile.mock.calls[0]![0]).toBe(dropped);
|
||||
});
|
||||
|
||||
it("keeps Upload disabled until a file is chosen", () => {
|
||||
wrap();
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
// `file.size > cap`, on the boundary in both directions — the cap is inclusive server-side
|
||||
// (backend test_upload.py pins that a body of EXACTLY the cap is accepted), so a dialog that
|
||||
// refused it would make a legal file un-uploadable with no way round. Two sizes either side of
|
||||
// one cap, because a single over-cap case passes just as happily with `>` loosened to `>=`.
|
||||
const CAP = 8;
|
||||
const pickFile = (bytes: number, name = "f.bin") =>
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(["x".repeat(bytes)], name)] },
|
||||
});
|
||||
|
||||
it("refuses a file one byte over the cap, naming the limit, instead of sending it", async () => {
|
||||
// Both server-side caps answer only after the transfer — and on a default install the one that
|
||||
// answers is the edge ceiling, whose refusal carries no number at all and may not even reach
|
||||
// the browser. So the dialog checks the size itself: the user learns the limit before a byte
|
||||
// moves.
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||
pickFile(CAP + 1, "big.bin");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("downloads.errors.upload.tooLarge")).toBeTruthy());
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
// The drop zone carries the reason, because a natively disabled Submit is not in the tab order
|
||||
// to be asked for it.
|
||||
const zone = screen.getByText("big.bin").closest("button")!;
|
||||
expect(zone.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(zone.getAttribute("aria-describedby")).toBe(screen.getByRole("alert").id);
|
||||
});
|
||||
|
||||
it("still highlights as a drop target while the picked file is over the cap", async () => {
|
||||
// The state a user is in when they see the refusal is "replace this file". With a file
|
||||
// already picked the zone's label shows its NAME rather than `dropActive`, so the border is
|
||||
// the only feedback that a drag is over a live target — and a danger border that outranks the
|
||||
// drag state reads as "drag-and-drop is off now", sending the user hunting for a control that
|
||||
// does not exist. The drop would have worked.
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||
pickFile(CAP + 1, "big.bin");
|
||||
const zone = screen.getByText("big.bin").closest("button")!;
|
||||
expect(zone.className).toContain("border-danger");
|
||||
|
||||
fireEvent.dragEnter(zone);
|
||||
expect(zone.className).toContain("border-accent");
|
||||
expect(zone.className).not.toContain("border-danger");
|
||||
});
|
||||
|
||||
it("swallows a Submit press that arrives while the button still holds focus", async () => {
|
||||
// The OTHER half of "never sent": the test above covers the natively disabled button (not
|
||||
// focused), this one covers the soft-disabled one. `ui/Button` is focus-safe, so a button that
|
||||
// turns unavailable while it HOLDS focus stays in the tab order and stays clickable, and it is
|
||||
// the primitive's own click guard that refuses — measured: deleting the `tooBig` term from
|
||||
// `submit()` changes nothing here, which is why that term is documented as belt and braces
|
||||
// rather than as the mechanism. What this pins is the user-visible contract: pressing Upload
|
||||
// after dropping a bigger file does not start a transfer the server will refuse.
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||
pickFile(CAP, "fits.bin");
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
btn.focus();
|
||||
fireEvent.focus(btn);
|
||||
pickFile(CAP + 1, "toobig.bin");
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
|
||||
expect(btn.disabled).toBe(false); // soft-disabled, still focusable — and still clickable
|
||||
fireEvent.click(btn);
|
||||
expect(h.uploadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a file of EXACTLY the cap, which the server accepts", async () => {
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||
pickFile(CAP, "exact.bin");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("re-announces the refusal when a SECOND over-cap file is picked", async () => {
|
||||
// Failing the same way again has to be announced again. The message names the CAP, not the
|
||||
// file, so two over-cap picks leave the node and its text identical — and a live region that
|
||||
// is never touched says nothing. See formAlerts.dom.test.tsx, which holds the same rule for
|
||||
// the three other alerts in this app that can repeat.
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||
pickFile(CAP + 1, "a.bin");
|
||||
const first = await screen.findByRole("alert");
|
||||
pickFile(CAP + 2, "b.bin");
|
||||
await waitFor(() => expect(screen.getByRole("alert")).not.toBe(first));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||
import Modal, { ModalFooter } from "../../components/ui/Modal";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { api } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { isAborted } from "../../lib/api/core";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { invalidateDownloads } from "../../lib/downloads";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { showInTray, uploadEnded, uploadStarted } from "../../lib/downloadQueue";
|
||||
import type { DownloadJob } from "../../lib/api";
|
||||
import { useFilesActions } from "./filesContext";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
|
||||
// Local-file ingest. A general upload — ANY file type, no format list: media
|
||||
// (video/audio) joins the library with the full function set, a generic file is download-only. The
|
||||
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
|
||||
// lands in the library (Files) as an already-`done` job — or, for media no phone could play as it is (an
|
||||
// iPhone's HEVC, a webm, an mkv), as a `queued` job the worker converts first — so we invalidate,
|
||||
// say which, and point the user there.
|
||||
// Out here, not inline in the upload handler: a `?:` inside its `try` makes the React Compiler
|
||||
// skip the whole dialog.
|
||||
const uploadedKey = (status: string) =>
|
||||
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
|
||||
// The job the upload's own notice announces (a done one), for the queue not to say it again.
|
||||
const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null);
|
||||
export default function UploadDialog({
|
||||
folderId = null,
|
||||
initialFile = null,
|
||||
onClose,
|
||||
}: {
|
||||
/** The library folder the file goes into (an own folder, or the Uploads folder); null = Uploads. */
|
||||
folderId?: number | null;
|
||||
/** A file already chosen — dropped on the Files page — so the dialog opens ready to send it. */
|
||||
initialFile?: File | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(initialFile);
|
||||
const [name, setName] = useState("");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// dragenter/dragleave fire as the cursor crosses the drop zone's OWN children (icon/text), so a
|
||||
// plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0.
|
||||
const dragDepth = useRef(0);
|
||||
const busy = pct !== null;
|
||||
// The per-file cap, so an over-cap file is refused HERE instead of after the user has pushed it up
|
||||
// the wire. Both server-side caps are still the boundary — this is only the pleasant half of it.
|
||||
// On a default install the one that answers is the edge ceiling (`bodylimit`), whose refusal
|
||||
// carries no number, so without this the user would be told "too large" and not what the limit is.
|
||||
// A cap that has not loaded yet blocks nothing — the server still refuses, honestly.
|
||||
const cap = useQuery({ queryKey: qk.storageUsage(), queryFn: api.filesUsage }).data
|
||||
?.upload_max_bytes;
|
||||
const tooBig = !!file && !!cap && file.size > cap;
|
||||
const refusalId = useId();
|
||||
// Every pick gets a new number, so the refusal below REMOUNTS rather than being reconciled. Two
|
||||
// over-cap picks in a row leave the node and its text identical — the message names the cap, not
|
||||
// the file — and a live region that is not touched is not announced, so the second refusal was
|
||||
// silent to a screen reader. `SetupWizard` and `Welcome` both key their alerts for the
|
||||
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
|
||||
const [pickSeq, setPickSeq] = useState(0);
|
||||
const { openFile } = useFilesActions();
|
||||
// A done file is in Files, opened on it; one the worker still converts is in the tray until it is.
|
||||
const openUploaded = (job: DownloadJob) => {
|
||||
if (job.status === "done") openFile(job.id, job.folder_id ?? null);
|
||||
else showInTray(job.id);
|
||||
};
|
||||
const pick_ = (f: File) => {
|
||||
setFile(f);
|
||||
setName("");
|
||||
setPickSeq((n) => n + 1);
|
||||
};
|
||||
|
||||
// Closing the dialog mid-upload (X / ESC / backdrop / browser Back all unmount us) must abort the
|
||||
// in-flight XHR — otherwise it keeps streaming in the background and still toasts success. Only the
|
||||
// footer Cancel wired the abort; this covers every other dismissal.
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const pick = () => inputRef.current?.click();
|
||||
|
||||
const submit = async () => {
|
||||
// `tooBig` here is belt and braces, NOT the mechanism: `ui/Button` is focus-safe, so while the
|
||||
// button holds focus it swallows the activation itself, and while it does not it is natively
|
||||
// disabled. Measured — deleting this term breaks nothing, which is exactly why it must not be
|
||||
// described as the guard. It is here for the next caller of `submit`, not for the only one.
|
||||
if (!file || busy || tooBig) return;
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
setPct(0);
|
||||
const displayName = name.trim() || undefined;
|
||||
uploadStarted();
|
||||
try {
|
||||
const job = await api.uploadFile(file, {
|
||||
displayName,
|
||||
folderId,
|
||||
onProgress: setPct,
|
||||
signal: ac.signal,
|
||||
});
|
||||
// Said here, so the queue does not say it again; one the worker still converts is the tray's
|
||||
// to announce when it is done.
|
||||
uploadEnded(announces(job));
|
||||
invalidateDownloads(qc);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t(uploadedKey(job.status)),
|
||||
action: {
|
||||
label: t("downloads.dialog.view"),
|
||||
onClick: () => openUploaded(job),
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
// Everything except the user's own Cancel is said out loud. `uploadReq` shares `reportFailure`
|
||||
// with `req`, which deliberately leaves 403/404/429 to the caller and answers a 502/503/504
|
||||
// with only the transient "connection lost" notice — so this empty catch meant a proxy 502 on
|
||||
// a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing
|
||||
// named and no job in the list. The comment here claimed the global error modal had it; it
|
||||
// had not since the shared `reportFailure` landed.
|
||||
uploadEnded(null);
|
||||
if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed"));
|
||||
// Re-arm the form so the user can retry or pick again.
|
||||
setPct(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal initialFocus="field" title={t("downloads.upload.title")} onClose={onClose}>
|
||||
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
|
||||
|
||||
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
|
||||
<Pressable
|
||||
align="center"
|
||||
onClick={pick}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => {
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDragOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) pick_(f);
|
||||
}}
|
||||
disabled={busy}
|
||||
// The refusal is tied to the control it invalidates, not left to a one-shot announcement:
|
||||
// the drop zone reads as invalid and points at the sentence, so a reader who tabs back to
|
||||
// it hears WHY. (Measured, because `aria-invalid` is not in `button`'s ARIA-supported
|
||||
// list: Chromium's accessibility node for a native button does carry `invalid` from this
|
||||
// attribute.) The signal that is NOT colour is the sentence below, visible to everyone —
|
||||
// the border only reinforces it.
|
||||
aria-invalid={tooBig}
|
||||
aria-describedby={tooBig ? refusalId : undefined}
|
||||
// `dragOver` outranks `tooBig`: a drag is transient and the refusal repaints the instant
|
||||
// the drop lands, whereas a zone that stays red under the pointer tells the user their
|
||||
// drop target is dead — in the one state where they are trying to replace the file. With
|
||||
// a file picked, the label below shows its name rather than `dropActive`, so this border
|
||||
// is the ONLY feedback that the zone will accept the drop.
|
||||
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
|
||||
dragOver
|
||||
? "border-accent bg-accent/5"
|
||||
: tooBig
|
||||
? "border-danger"
|
||||
: "border-border hover:border-fg/40"
|
||||
}`}
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<FileIcon className="w-6 h-6 text-muted" />
|
||||
<span className="text-sm font-medium break-all">{file.name}</span>
|
||||
<span className="text-xs text-muted">{formatBytes(file.size, lang)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="w-7 h-7 text-muted" />
|
||||
<span className="text-sm text-muted">
|
||||
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) pick_(f);
|
||||
e.target.value = ""; // let re-picking the same file fire onChange again
|
||||
}}
|
||||
/>
|
||||
|
||||
{tooBig && (
|
||||
<p key={pickSeq} id={refusalId} className="text-xs text-danger mt-2" role="alert">
|
||||
{t("downloads.errors.upload.tooLarge", { size: formatBytes(cap ?? 0, lang) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label htmlFor="upload-display-name" className="block text-sm font-medium mt-4 mb-1">
|
||||
{t("downloads.upload.nameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
className="w-full"
|
||||
id="upload-display-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("downloads.upload.namePlaceholder")}
|
||||
disabled={busy}
|
||||
// The column behind it is varchar(255) and the name rides in a REQUEST HEADER, where
|
||||
// nothing else bounds it; the server truncates to the same number. The number is not a
|
||||
// third copy: `downloads.contract.test.ts` holds DISPLAY_NAME_MAX against the backend's
|
||||
// own DISPLAY_TEXT_MAX.
|
||||
maxLength={DISPLAY_NAME_MAX}
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<div className="mt-5" aria-live="polite">
|
||||
<div
|
||||
className="h-2 rounded-full bg-surface overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-valuenow={pct ?? 0}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
// A STABLE descriptor as the name; the live percentage rides aria-valuenow (and the visible
|
||||
// <p> below re-announces it via the aria-live wrapper) — don't bake the value into the name.
|
||||
aria-label={t("downloads.upload.uploadProgress")}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-accent transition-[width] duration-150"
|
||||
style={{ width: `${pct ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted mt-1.5 text-center">
|
||||
{t("downloads.upload.uploading", { pct: pct ?? 0 })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
|
||||
>
|
||||
{t("downloads.actions.cancel")}
|
||||
</Button>
|
||||
{/* Plain `disabled` — `ui/Button` is a FocusSafeButton, which already expresses it as
|
||||
`aria-disabled` + a click guard for as long as the button HOLDS focus, so this cannot
|
||||
strand a keyboard user on <body> when the cap arrives late or a second file is dropped.
|
||||
`submit` refuses independently, which is what that soft state relies on. The REASON is
|
||||
reachable from the drop zone (aria-invalid + aria-describedby above) rather than from
|
||||
here, because a natively disabled button is not in the tab order to be asked. */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!file || busy || tooBig}
|
||||
aria-describedby={tooBig ? refusalId : undefined}
|
||||
>
|
||||
{t("downloads.upload.submit")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Combobox from "../../components/ui/Combobox";
|
||||
|
||||
// Pick one registered user by name or email: the download quota's "any user" picker and the share
|
||||
// dialog's recipient. Both used to hand-roll an input over an absolute list that only a mouse could
|
||||
// reach (no arrow keys, no listbox for a screen reader, closed by a blur timer); this is the shared
|
||||
// Combobox in single-select, which brings all of that. A pick is an ACTION here, not a value to show,
|
||||
// so nothing stays selected: the field clears for the next one.
|
||||
export interface PickableUser {
|
||||
id: number;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
}
|
||||
|
||||
export default function UserPicker<U extends PickableUser>({
|
||||
users,
|
||||
onPick,
|
||||
label,
|
||||
placeholder,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
disabled,
|
||||
}: {
|
||||
users: readonly U[];
|
||||
onPick: (u: U) => void;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
loading?: boolean;
|
||||
/** The user list failed to load: the picker says so instead of "No matches". */
|
||||
error?: boolean;
|
||||
/** Load the user list again after `error`. */
|
||||
onRetry?: () => void;
|
||||
/** The last pick is still being acted on: no second one meanwhile. */
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const byId = new Map(users.map((u) => [String(u.id), u]));
|
||||
return (
|
||||
<Combobox
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
multiple={false}
|
||||
loading={loading}
|
||||
error={error ? t("common.loadError") : undefined}
|
||||
onRetry={onRetry}
|
||||
disabled={disabled}
|
||||
selected={[]}
|
||||
// The label carries both, so the client filter matches a name OR an email.
|
||||
options={users.map((u) => ({
|
||||
value: String(u.id),
|
||||
label: u.display_name ? `${u.display_name} · ${u.email}` : u.email,
|
||||
}))}
|
||||
onChange={(next) => {
|
||||
const u = next[0] != null ? byId.get(next[0]) : undefined;
|
||||
if (u) onPick(u);
|
||||
}}
|
||||
renderOption={(o) => {
|
||||
const u = byId.get(o.value);
|
||||
return (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{u?.display_name || u?.email}</span>
|
||||
{u?.display_name && <span className="truncate text-xs text-muted">{u.email}</span>}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Clock, Pin, Smartphone } from "lucide-react";
|
||||
import type { LibraryFile } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { sourceIcon, sourceLabel } from "./fileFolders";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
|
||||
// What a library file says about itself — where it came from, whether a phone plays it, how long it
|
||||
// stays — drawn once for both paints of the list: the table's columns and the grid's cards.
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// A file this close to leaving says so in the warning colour (and, on a card, says it at all).
|
||||
const SOON_DAYS = 7;
|
||||
|
||||
function daysLeft(iso: string): number {
|
||||
return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS));
|
||||
}
|
||||
|
||||
export function SourceBadge({ file }: { file: LibraryFile }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Badge>
|
||||
{/* A looked-up icon drawn as an element: a component picked during render would remount. */}
|
||||
{createElement(sourceIcon(file.source_key))}
|
||||
{sourceLabel(file.source_key, t)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** Every library video and sound is phone-safe by construction (backend downloads/playable.py); a
|
||||
* plain file is not a thing a phone plays, so it has no badge. */
|
||||
export function PhoneBadge({ file }: { file: LibraryFile }) {
|
||||
const { t } = useTranslation();
|
||||
if (file.asset?.media_kind === "file") return null;
|
||||
return (
|
||||
<Badge title={t("files.phoneOk")}>
|
||||
<Smartphone />
|
||||
<span className="text-success">✓</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** How long the file stays. `quiet`: only what needs saying — kept, or leaving within a week (a
|
||||
* card has no column to hold the rest); otherwise every state, as the table's column. */
|
||||
export function RetentionBadge({ file, quiet = false }: { file: LibraryFile; quiet?: boolean }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
if (file.kept)
|
||||
return (
|
||||
<Badge>
|
||||
<Pin />
|
||||
{t("files.retention.kept")}
|
||||
</Badge>
|
||||
);
|
||||
if (!file.expires_at)
|
||||
return quiet ? null : (
|
||||
<span className="text-xs text-muted" title={t("files.retention.never")}>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
const days = daysLeft(file.expires_at);
|
||||
const when = formatDateTime(file.expires_at, i18n.language);
|
||||
if (days <= SOON_DAYS)
|
||||
return (
|
||||
<Badge tone="warn" title={t("files.retention.until", { when })}>
|
||||
<Clock />
|
||||
{t("files.retention.days", { count: days })}
|
||||
</Badge>
|
||||
);
|
||||
return quiet ? null : (
|
||||
<span className="text-xs tabular-nums text-muted" title={t("files.retention.until", { when })}>
|
||||
{t("files.retention.days", { count: days })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import type { FileFolder } from "../../lib/api";
|
||||
|
||||
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
|
||||
// (`youtube`, `plex`, `upload`, `site:<host>`, see backend downloads/folders.py) and leaves
|
||||
// its name to the client, in the reader's language; an own folder carries the name its user gave it;
|
||||
// the Hub folder (one per user, made by a hub token) is named by the client too.
|
||||
|
||||
const SITE_PREFIX = "site:";
|
||||
|
||||
type T = (key: string) => string;
|
||||
|
||||
/** The name of a source: a site is its host, the rest are words of the UI. */
|
||||
export function sourceLabel(key: string, t: T): string {
|
||||
if (key.startsWith(SITE_PREFIX)) return key.slice(SITE_PREFIX.length);
|
||||
if (key === "youtube" || key === "plex" || key === "upload") return t(`files.source.${key}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
|
||||
if (key === "youtube") return YouTubeIcon;
|
||||
if (key === "plex") return Film;
|
||||
if (key === "upload") return Upload;
|
||||
return Globe;
|
||||
}
|
||||
|
||||
type Folderish = Pick<FileFolder, "kind" | "source_key" | "name">;
|
||||
|
||||
export function folderLabel(folder: Folderish, t: T): string {
|
||||
if (folder.kind === "hub") return t("files.folder.hub");
|
||||
return folder.kind === "own" ? (folder.name ?? "") : sourceLabel(folder.source_key ?? "", t);
|
||||
}
|
||||
|
||||
export function folderIcon(folder: Folderish): LucideIcon | typeof YouTubeIcon {
|
||||
if (folder.kind === "hub") return Server;
|
||||
return folder.kind === "own" ? Folder : sourceIcon(folder.source_key ?? "");
|
||||
}
|
||||
|
||||
/** Whether an upload may land in `folder` (the backend's `folders.upload_target`): an own folder,
|
||||
* the Hub folder, or Uploads — a file put in a site's folder would claim an origin it does not have. */
|
||||
export function takesUploads(folder: Folderish): boolean {
|
||||
return folder.kind !== "source" || folder.source_key === "upload";
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// The Files module's contexts + hooks, split out of FilesProvider so that file exports only
|
||||
// components (Fast Refresh).
|
||||
|
||||
/** What the Files page lists: every file of mine, the ones shared with me, or one folder's. */
|
||||
export type FilesView = { kind: "all" } | { kind: "shared" } | { kind: "folder"; id: number };
|
||||
|
||||
export const ALL_FILES: FilesView = { kind: "all" };
|
||||
|
||||
/** The view as one string — the `?folder=` bookmark and the remembered view share it. */
|
||||
export function viewParam(view: FilesView): string {
|
||||
return view.kind === "folder" ? String(view.id) : view.kind;
|
||||
}
|
||||
|
||||
/** The view a stored or bookmarked string names; anything else is the whole library. */
|
||||
export function parseView(raw: string | null | undefined): FilesView | null {
|
||||
if (raw === "all") return ALL_FILES;
|
||||
if (raw === "shared") return { kind: "shared" };
|
||||
const id = raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
|
||||
return id !== null && Number.isSafeInteger(id) ? { kind: "folder", id } : null;
|
||||
}
|
||||
|
||||
export const sameView = (a: FilesView, b: FilesView): boolean => viewParam(a) === viewParam(b);
|
||||
|
||||
/** The file to bring into view on arrival (`?item=`, a notice's "Open"); `token` makes a second
|
||||
* arrival at the same file a new one. */
|
||||
export type FilesFocus = { id: number; token: number };
|
||||
|
||||
/** The `?item=` a link carries: a positive id, else nothing. */
|
||||
export function parseItem(raw: string | null | undefined): number | null {
|
||||
const id = raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
|
||||
return id !== null && Number.isSafeInteger(id) ? id : null;
|
||||
}
|
||||
|
||||
/** An arrival the page has found: the file, in the view it was found in (`viewParam`). */
|
||||
export type FilesArrival = FilesFocus & { view: string };
|
||||
|
||||
export type FilesState = {
|
||||
search: string;
|
||||
view: FilesView;
|
||||
/** Pending until the page has looked for it (`consumeFocus`). */
|
||||
focus: FilesFocus | null;
|
||||
/** The last arrival the page found, while the Files page is open: turned to and ringed. */
|
||||
arrived: FilesArrival | null;
|
||||
};
|
||||
|
||||
export interface FilesActions {
|
||||
setSearch: (q: string) => void;
|
||||
setView: (view: FilesView) => void;
|
||||
/** Open the Files page on one file: its folder (null = every file) with the file in view. */
|
||||
openFile: (id: number, folderId: number | null) => void;
|
||||
/** The page has looked for the focused file: found in the view `at` (a `viewParam`), or nowhere
|
||||
* (null). Either way the arrival is spent. */
|
||||
consumeFocus: (at: string | null) => void;
|
||||
}
|
||||
|
||||
export const FilesStateContext = createContext<FilesState>({
|
||||
search: "",
|
||||
view: ALL_FILES,
|
||||
focus: null,
|
||||
arrived: null,
|
||||
});
|
||||
export const FilesActionsContext = createContext<FilesActions>({
|
||||
setSearch: () => {},
|
||||
setView: () => {},
|
||||
openFile: () => {},
|
||||
consumeFocus: () => {},
|
||||
});
|
||||
|
||||
export function useFiles(): FilesState {
|
||||
return useContext(FilesStateContext);
|
||||
}
|
||||
export function useFilesActions(): FilesActions {
|
||||
return useContext(FilesActionsContext);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
FolderInput,
|
||||
Info,
|
||||
Link,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
Share2,
|
||||
SkipBack,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { invalidateDownloads, quickShareLink } from "../../lib/downloads";
|
||||
import { forgetFinished } from "../../lib/downloadQueue";
|
||||
import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { folderLabel } from "./fileFolders";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
|
||||
// The Files rows' ONE menu, and the actions the rows show as icons (the same ones, so a row's every
|
||||
// action is reachable by right click — DESIGN.md "Library list columns"). My own files and the ones
|
||||
// shared with me get different sets: a shared file is the owner's, so it is played, saved, cut or
|
||||
// dropped from my list, never renamed, moved or deleted.
|
||||
|
||||
// A destructive confirm names the file (DESIGN.md "Copy"); what the delete takes depends on the kind:
|
||||
// a download's file may stay for others who have it, an upload or a clip is its owner's alone.
|
||||
const DELETE_BODY: Record<DownloadJobKind, string> = {
|
||||
download: "downloads.confirm.deleteBody",
|
||||
upload: "downloads.confirm.deleteUploadBody",
|
||||
edit: "downloads.confirm.deleteEditBody",
|
||||
clip: "downloads.confirm.deleteEditBody",
|
||||
};
|
||||
|
||||
type Patch = { name?: string | null; folder_id?: number; kept?: boolean };
|
||||
// Which failure title a PATCH speaks under: what it was asked to change.
|
||||
const patchFail = (p: Patch) =>
|
||||
"folder_id" in p
|
||||
? "errors.writeFailed.file.move"
|
||||
: "kept" in p
|
||||
? "errors.writeFailed.file.keep"
|
||||
: "errors.writeFailed.file.rename";
|
||||
|
||||
export function useFileActions({
|
||||
folders,
|
||||
onPlay,
|
||||
onView,
|
||||
onEdit,
|
||||
onRename,
|
||||
onEditDetails,
|
||||
onManageShares,
|
||||
}: {
|
||||
/** The caller's folders — the Move submenu's places; null while they are not loaded. */
|
||||
folders: readonly FileFolder[] | null;
|
||||
onPlay: (file: LibraryFile, fromStart?: boolean) => void;
|
||||
/** Open a file that is not media in the Files viewer. */
|
||||
onView: (file: LibraryFile) => void;
|
||||
onEdit: (file: LibraryFile) => void;
|
||||
onRename: (file: LibraryFile) => void;
|
||||
onEditDetails: (file: LibraryFile) => void;
|
||||
onManageShares: (file: LibraryFile) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const copy = useCopyItems();
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, patch }: { id: number; patch: Patch }) => api.updateFile(id, patch),
|
||||
onSuccess: () => invalidateDownloads(qc),
|
||||
onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api.deleteFile(id),
|
||||
onSuccess: (_r, id) => {
|
||||
forgetFinished(id);
|
||||
void invalidateDownloads(qc);
|
||||
},
|
||||
meta: { failTitle: "errors.writeFailed.file.delete" },
|
||||
});
|
||||
const redownload = useMutation({
|
||||
mutationFn: (id: number) => api.redownloadDownload(id),
|
||||
onSuccess: () => invalidateDownloads(qc),
|
||||
meta: { failTitle: "errors.writeFailed.job.redownload" },
|
||||
});
|
||||
const removeShared = useMutation({
|
||||
mutationFn: (id: number) => api.removeSharedFile(id),
|
||||
onSuccess: () => invalidateDownloads(qc),
|
||||
meta: { failTitle: "errors.writeFailed.removeShared" },
|
||||
});
|
||||
|
||||
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
|
||||
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
|
||||
};
|
||||
|
||||
const [sharingId, setSharingId] = useState<number | null>(null);
|
||||
const quickShare = async (file: LibraryFile) => {
|
||||
if (sharingId !== null) return;
|
||||
setSharingId(file.id);
|
||||
await quickShareLink(qc, { kind: "file", id: file.id }, t).finally(() => setSharingId(null));
|
||||
};
|
||||
|
||||
const icon = (I: typeof PlayCircle) => <I className="w-4 h-4" />;
|
||||
const play = (file: LibraryFile): ActionMenuItem[] => {
|
||||
const started = (file.position_seconds ?? 0) > 0;
|
||||
return [
|
||||
{
|
||||
id: "play",
|
||||
label: started ? t("card.continue") : t("downloads.actions.play"),
|
||||
icon: icon(PlayCircle),
|
||||
onSelect: () => onPlay(file),
|
||||
},
|
||||
...(started
|
||||
? [
|
||||
{
|
||||
id: "restart",
|
||||
label: t("card.restartTitle"),
|
||||
icon: icon(SkipBack),
|
||||
onSelect: () => onPlay(file, true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
};
|
||||
const view = (file: LibraryFile): ActionMenuItem => ({
|
||||
id: "view",
|
||||
label: t("files.actions.view"),
|
||||
icon: icon(Eye),
|
||||
onSelect: () => onView(file),
|
||||
});
|
||||
const save = (file: LibraryFile): ActionMenuItem => ({
|
||||
id: "save",
|
||||
label: t("downloads.actions.download"),
|
||||
icon: icon(Download),
|
||||
inGesture: true,
|
||||
onSelect: () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = api.fileUrl(file.id);
|
||||
a.click();
|
||||
},
|
||||
});
|
||||
const copyLink = (file: LibraryFile): ActionMenuItem => ({
|
||||
id: "share-link",
|
||||
label: t("files.actions.copyLink"),
|
||||
icon: icon(Link),
|
||||
disabled: sharingId !== null,
|
||||
onSelect: () => void quickShare(file),
|
||||
});
|
||||
const edit = (file: LibraryFile): ActionMenuItem => ({
|
||||
id: "edit",
|
||||
label: t("downloads.actions.edit"),
|
||||
icon: icon(Scissors),
|
||||
onSelect: () => onEdit(file),
|
||||
});
|
||||
// "Copy link" is the share link here (the library's daily errand); the page it came from is the
|
||||
// source link, named so.
|
||||
const copies = (file: LibraryFile) =>
|
||||
copy(jobTitle(file), file.source_url, "title", t("files.actions.copySourceLink"));
|
||||
|
||||
// Move: every own folder (the one it is in, ticked), then home — its own source folder — when it
|
||||
// has left it. The server refuses any other place (`folders.move`), so none is offered.
|
||||
const moveTo = (file: LibraryFile): ActionMenuItem => {
|
||||
// Unknown (a failed read) is not "none": the row says the folders are missing, not that there are none.
|
||||
if (folders === null)
|
||||
return {
|
||||
id: "move",
|
||||
label: t("files.actions.move"),
|
||||
icon: icon(FolderInput),
|
||||
items: [
|
||||
{
|
||||
id: "move-unknown",
|
||||
label: t("files.actions.foldersUnknown"),
|
||||
disabled: true,
|
||||
onSelect: () => {},
|
||||
},
|
||||
],
|
||||
};
|
||||
// The user's own folders, then the Hub folder: the ones a file may be moved into from anywhere.
|
||||
const own = [
|
||||
...folders.filter((f) => f.kind === "own"),
|
||||
...folders.filter((f) => f.kind === "hub"),
|
||||
];
|
||||
const home = folders.find((f) => f.kind === "source" && f.source_key === file.source_key);
|
||||
const to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } });
|
||||
const items: ActionMenuItem[] = own.map((f) => ({
|
||||
id: `move-${f.id}`,
|
||||
label: folderLabel(f, t),
|
||||
check: { kind: "one", checked: f.id === file.folder_id },
|
||||
onSelect: to(f),
|
||||
}));
|
||||
if (home && home.id !== file.folder_id)
|
||||
items.push({
|
||||
id: "move-home",
|
||||
label: t("files.actions.moveHome", { folder: folderLabel(home, t) }),
|
||||
separatorBefore: items.length > 0,
|
||||
onSelect: to(home),
|
||||
});
|
||||
if (items.length === 0)
|
||||
items.push({
|
||||
id: "move-none",
|
||||
label: t("files.actions.noOwnFolders"),
|
||||
disabled: true,
|
||||
onSelect: () => {},
|
||||
});
|
||||
return { id: "move", label: t("files.actions.move"), icon: icon(FolderInput), items };
|
||||
};
|
||||
|
||||
function mineItems(file: LibraryFile, inViewer: boolean): ActionMenuItem[] {
|
||||
const a = libraryRowActions(file);
|
||||
return menuGroups(
|
||||
// The row's icons first, in the row's order.
|
||||
[
|
||||
...(a.play ? play(file) : []),
|
||||
...(a.view && !inViewer ? [view(file)] : []),
|
||||
...(a.play ? [copyLink(file)] : []),
|
||||
{
|
||||
id: "share",
|
||||
label: t("files.actions.share"),
|
||||
icon: icon(Share2),
|
||||
onSelect: () => onManageShares(file),
|
||||
},
|
||||
save(file),
|
||||
],
|
||||
[
|
||||
...(a.edit ? [edit(file)] : []),
|
||||
// Renamed in place, in its row: not from the viewer that covers the row.
|
||||
...(inViewer
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "rename",
|
||||
label: t("files.actions.rename"),
|
||||
icon: icon(Pencil),
|
||||
onSelect: () => onRename(file),
|
||||
},
|
||||
]),
|
||||
moveTo(file),
|
||||
{
|
||||
id: "keep",
|
||||
label: t(file.kept ? "files.actions.unkeep" : "files.actions.keep"),
|
||||
icon: icon(file.kept ? PinOff : Pin),
|
||||
onSelect: () => update.mutate({ id: file.id, patch: { kept: !file.kept } }),
|
||||
},
|
||||
],
|
||||
[
|
||||
...(a.editDetails
|
||||
? [
|
||||
{
|
||||
id: "details",
|
||||
label: t("files.actions.details"),
|
||||
icon: icon(Info),
|
||||
onSelect: () => onEditDetails(file),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(a.redownload
|
||||
? [
|
||||
{
|
||||
id: "redownload",
|
||||
label: t("files.actions.redownload"),
|
||||
icon: icon(RotateCcw),
|
||||
onSelect: () =>
|
||||
void confirmThen(
|
||||
t("downloads.confirm.redownloadTitle", { name: jobTitle(file) }),
|
||||
t("downloads.confirm.redownloadBody"),
|
||||
t("downloads.confirm.redownloadAction"),
|
||||
() => redownload.mutate(file.id),
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...copies(file),
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "delete",
|
||||
label: t("files.actions.delete"),
|
||||
icon: icon(Trash2),
|
||||
danger: true,
|
||||
onSelect: () =>
|
||||
void confirmThen(
|
||||
t("downloads.confirm.deleteTitle", { name: jobTitle(file) }),
|
||||
t(DELETE_BODY[file.job_kind ?? "download"]),
|
||||
t("downloads.actions.delete"),
|
||||
() => remove.mutate(file.id),
|
||||
),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function sharedItems(file: LibraryFile, inViewer: boolean): ActionMenuItem[] {
|
||||
const a = sharedRowActions(file);
|
||||
return menuGroups(
|
||||
[
|
||||
...(a.play ? play(file) : []),
|
||||
...(a.view && !inViewer ? [view(file)] : []),
|
||||
...(a.save ? [save(file)] : []),
|
||||
],
|
||||
a.edit ? [edit(file)] : [],
|
||||
copies(file),
|
||||
[
|
||||
{
|
||||
id: "remove-shared",
|
||||
label: t("files.actions.removeShared"),
|
||||
icon: icon(Trash2),
|
||||
danger: true,
|
||||
onSelect: () =>
|
||||
void confirmThen(
|
||||
t("downloads.confirm.removeSharedTitle", { name: jobTitle(file) }),
|
||||
t("downloads.confirm.removeSharedBody"),
|
||||
t("downloads.actions.removeShared"),
|
||||
() => removeShared.mutate(file.id),
|
||||
),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** The file's menu rows; `inViewer`: the viewer's "…", which leaves out what the viewer is or
|
||||
* what needs the row it covers. */
|
||||
const items = (file: LibraryFile, inViewer = false): ActionMenuItem[] =>
|
||||
file.shared ? sharedItems(file, inViewer) : mineItems(file, inViewer);
|
||||
|
||||
/** The row's Copy-link icon: the menu's row, one click. */
|
||||
const share = (file: LibraryFile) => void quickShare(file);
|
||||
|
||||
/** Save the inline rename: an empty name gives the file its own title back. The field starts on
|
||||
* the name the file shows, so leaving it as it was changes nothing — not even a name never set. */
|
||||
const rename = (file: LibraryFile, name: string) => {
|
||||
const next = name.trim();
|
||||
if (next === jobTitle(file) || next === (file.display_name ?? "")) return;
|
||||
update.mutate({ id: file.id, patch: { name: next || null } });
|
||||
};
|
||||
|
||||
return { items, share, rename, sharing: sharingId };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user