refactor(api): honest FeedResponse; derive pageTitleKey from the registry (R7 S3a·2-3)
- FeedResponse claimed a required `limit`, but /api/search/youtube never returns it (only /api/feed does) and nothing in the app reads it. Drop the field so searchYoutube's Promise<FeedResponse> stops lying; documented for re-add if a consumer appears. - pageMeta's hand-written 13-case Page->title switch now derives from the ModuleDef registry via a new optional `titleKey` (the longer tab title, e.g. "Channel manager"; falls back to the short nav labelKey). A new page is one touch (add it to MODULES) instead of also editing a switch. A test pins all 13 title keys byte-identical to the old switch (suite 74).
This commit is contained in:
@@ -123,10 +123,12 @@ export interface FeedResponse {
|
|||||||
has_more: boolean;
|
has_more: boolean;
|
||||||
// Opaque keyset cursor for the next page, or null when this is the last page.
|
// Opaque keyset cursor for the next page, or null when this is the last page.
|
||||||
next_cursor: string | null;
|
next_cursor: string | null;
|
||||||
limit: number;
|
|
||||||
// Only set by the live YouTube search: "scrape" (InnerTube, no API quota) or "api"
|
// Only set by the live YouTube search: "scrape" (InnerTube, no API quota) or "api"
|
||||||
// (search.list, 100 units/page) — lets the UI drop the quota warning in scrape mode.
|
// (search.list, 100 units/page) — lets the UI drop the quota warning in scrape mode.
|
||||||
source?: "scrape" | "api";
|
source?: "scrape" | "api";
|
||||||
|
// NOTE: /api/feed also returns an echoed `limit`, but /api/search/youtube does not and nothing
|
||||||
|
// in the app reads it, so it's deliberately omitted rather than typed required (a lie for search)
|
||||||
|
// or optional (dead surface). Re-add if a consumer ever needs it.
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlaylistKind = "user" | "watch_later";
|
export type PlaylistKind = "user" | "watch_later";
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export type ModuleDef = {
|
|||||||
id: Page;
|
id: Page;
|
||||||
// i18n key for the SHORT display name — the same label the nav rail and the header pill show.
|
// i18n key for the SHORT display name — the same label the nav rail and the header pill show.
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
|
// i18n key for the LONGER, descriptive browser-tab title (e.g. "Channel manager" vs the rail's
|
||||||
|
// "Channels"). Absent → the tab title falls back to `labelKey`. Read via `pageTitleKey`.
|
||||||
|
titleKey?: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
// Admin/system modules render in their own section (below a divider) in the nav rail.
|
// Admin/system modules render in their own section (below a divider) in the nav rail.
|
||||||
system?: boolean;
|
system?: boolean;
|
||||||
@@ -44,7 +47,12 @@ const notDemo = (me: Me) => !me.is_demo;
|
|||||||
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
|
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
|
||||||
const MODULES: readonly ModuleDef[] = [
|
const MODULES: readonly ModuleDef[] = [
|
||||||
{ id: "feed", labelKey: "header.account.feed", icon: Home },
|
{ id: "feed", labelKey: "header.account.feed", icon: Home },
|
||||||
{ id: "channels", labelKey: "header.account.channels", icon: Tv },
|
{
|
||||||
|
id: "channels",
|
||||||
|
labelKey: "header.account.channels",
|
||||||
|
titleKey: "header.channelManager",
|
||||||
|
icon: Tv,
|
||||||
|
},
|
||||||
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
|
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
|
||||||
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
|
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
|
||||||
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
|
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
|
||||||
@@ -62,10 +70,11 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
hasBadge: true,
|
hasBadge: true,
|
||||||
gate: notDemo,
|
gate: notDemo,
|
||||||
},
|
},
|
||||||
{ id: "stats", labelKey: "header.account.stats", icon: BarChart3 },
|
{ id: "stats", labelKey: "header.account.stats", titleKey: "header.usageStats", icon: BarChart3 },
|
||||||
{
|
{
|
||||||
id: "scheduler",
|
id: "scheduler",
|
||||||
labelKey: "header.account.scheduler",
|
labelKey: "header.account.scheduler",
|
||||||
|
titleKey: "header.scheduler",
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
system: true,
|
system: true,
|
||||||
gate: isAdmin,
|
gate: isAdmin,
|
||||||
@@ -73,12 +82,27 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
{
|
{
|
||||||
id: "config",
|
id: "config",
|
||||||
labelKey: "header.account.config",
|
labelKey: "header.account.config",
|
||||||
|
titleKey: "header.configuration",
|
||||||
icon: SlidersHorizontal,
|
icon: SlidersHorizontal,
|
||||||
system: true,
|
system: true,
|
||||||
gate: isAdmin,
|
gate: isAdmin,
|
||||||
},
|
},
|
||||||
{ id: "users", labelKey: "header.account.users", icon: Users, system: true, gate: isAdmin },
|
{
|
||||||
{ id: "audit", labelKey: "header.account.audit", icon: ScrollText, system: true, gate: isAdmin },
|
id: "users",
|
||||||
|
labelKey: "header.account.users",
|
||||||
|
titleKey: "header.users",
|
||||||
|
icon: Users,
|
||||||
|
system: true,
|
||||||
|
gate: isAdmin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "audit",
|
||||||
|
labelKey: "header.account.audit",
|
||||||
|
titleKey: "audit.title",
|
||||||
|
icon: ScrollText,
|
||||||
|
system: true,
|
||||||
|
gate: isAdmin,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in
|
// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in
|
||||||
@@ -86,6 +110,7 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
const SETTINGS_MODULE: ModuleDef = {
|
const SETTINGS_MODULE: ModuleDef = {
|
||||||
id: "settings",
|
id: "settings",
|
||||||
labelKey: "header.account.settings",
|
labelKey: "header.account.settings",
|
||||||
|
titleKey: "settings.title",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { pageTitleKey } from "./pageMeta";
|
||||||
|
import type { Page } from "./urlState";
|
||||||
|
|
||||||
|
// pageTitleKey now derives from the ModuleDef registry (titleKey ?? labelKey). These are the exact
|
||||||
|
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
|
||||||
|
// rename (or a dropped titleKey) can't silently change a tab title.
|
||||||
|
const EXPECTED: Record<Page, string> = {
|
||||||
|
feed: "header.account.feed",
|
||||||
|
channels: "header.channelManager",
|
||||||
|
playlists: "header.account.playlists",
|
||||||
|
notifications: "inbox.navLabel",
|
||||||
|
messages: "messages.navLabel",
|
||||||
|
downloads: "downloads.navLabel",
|
||||||
|
stats: "header.usageStats",
|
||||||
|
plex: "plex.navLabel",
|
||||||
|
scheduler: "header.scheduler",
|
||||||
|
config: "header.configuration",
|
||||||
|
users: "header.users",
|
||||||
|
audit: "audit.title",
|
||||||
|
settings: "settings.title",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("pageTitleKey", () => {
|
||||||
|
it("returns the exact title key for every page (registry-derived)", () => {
|
||||||
|
for (const [page, key] of Object.entries(EXPECTED)) {
|
||||||
|
expect(pageTitleKey(page as Page)).toBe(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,36 +1,12 @@
|
|||||||
import type { Page } from "./urlState";
|
import type { Page } from "./urlState";
|
||||||
|
import { moduleDef } from "./modules";
|
||||||
|
|
||||||
// The i18n key for a page's human title — shared by the top-bar header and the browser tab title
|
// The i18n key for a page's human title — shared by the top-bar header and the browser tab title so
|
||||||
// so the two never drift. Keep in sync with the nav modules in NavSidebar / App's page switch.
|
// the two never drift. Derived from the single ModuleDef registry (modules.ts): a module's optional
|
||||||
|
// `titleKey` is the longer, descriptive tab title (e.g. "Channel manager"), falling back to its
|
||||||
|
// short nav `labelKey` when the two are the same. Registering a new Page is then one touch (add it
|
||||||
|
// to MODULES) instead of also editing a switch here.
|
||||||
export function pageTitleKey(page: Page): string {
|
export function pageTitleKey(page: Page): string {
|
||||||
switch (page) {
|
const def = moduleDef(page);
|
||||||
case "feed":
|
return def.titleKey ?? def.labelKey;
|
||||||
return "header.account.feed";
|
|
||||||
case "channels":
|
|
||||||
return "header.channelManager";
|
|
||||||
case "playlists":
|
|
||||||
return "header.account.playlists";
|
|
||||||
case "notifications":
|
|
||||||
return "inbox.navLabel";
|
|
||||||
case "messages":
|
|
||||||
return "messages.navLabel";
|
|
||||||
case "downloads":
|
|
||||||
return "downloads.navLabel";
|
|
||||||
case "stats":
|
|
||||||
return "header.usageStats";
|
|
||||||
case "plex":
|
|
||||||
return "plex.navLabel";
|
|
||||||
case "scheduler":
|
|
||||||
return "header.scheduler";
|
|
||||||
case "config":
|
|
||||||
return "header.configuration";
|
|
||||||
case "users":
|
|
||||||
return "header.users";
|
|
||||||
case "audit":
|
|
||||||
return "audit.title";
|
|
||||||
case "settings":
|
|
||||||
return "settings.title";
|
|
||||||
default:
|
|
||||||
return "header.account.feed";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user