diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f65e075..5867d50 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -182,6 +182,10 @@ export default function App() { const saveMsgTimer = useRef(undefined); const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); const [page, setPageState] = useState(loadInitialPage); + // Client-side name filters for the Channels + Playlists lists — lifted here so the shared top + // SearchBar can drive them (the modules read/seed them via props). + const [channelsSearch, setChannelsSearch] = useState(""); + const [playlistsSearch, setPlaylistsSearch] = useState(""); // Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL, // so a reload returns to the normal feed (a search spends quota, so we never auto-replay it). // The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the @@ -803,9 +807,20 @@ export default function App() { setFilters={setFilters} plexQ={plexQ} setPlexQ={setPlexQ} + channelsSearch={channelsSearch} + setChannelsSearch={setChannelsSearch} + playlistsSearch={playlistsSearch} + setPlaylistsSearch={setPlaylistsSearch} page={page} + setPage={setPage} onYtSearch={enterYtSearch} /> + {/* The header floats (fixed) over the content, so it no longer occupies flow space. This + wrapper clears it with a top pad — EXCEPT on Playlists, whose own left rail must reach + the very top (it pads only its content pane internally instead). */} +
{meQuery.data!.is_demo && } openReleaseNotes(CURRENT_VERSION)} />
@@ -814,6 +829,8 @@ export default function App() { ) : page === "playlists" ? ( - + ) : page === "notifications" ? ( ) : page === "messages" && !meQuery.data!.is_demo ? ( @@ -884,6 +901,7 @@ export default function App() { )}
+
)} {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 0353e5a..0eb5c31 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -17,10 +17,13 @@ import { useConfirm } from "./ConfirmProvider"; // 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; }) { @@ -67,7 +70,10 @@ export default function ChannelDiscovery({ if (ok) subscribe.mutate(c); }; - const rows = query.data ?? []; + const q = search.trim().toLowerCase(); + const rows = (query.data ?? []).filter( + (c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q) + ); const columns: Column[] = [ { diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index d41af77..b405605 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -12,7 +12,6 @@ import { Pencil, Plus, RefreshCw, - Search, UserMinus, X, } from "lucide-react"; @@ -44,6 +43,8 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ export default function Channels({ canWrite, isAdmin, + search, + setSearch, onViewChannel, onFilterByTag, onFocusChannel, @@ -58,6 +59,9 @@ export default function Channels({ }: { canWrite: boolean; isAdmin: boolean; + // The channel-name filter, lifted to App so the shared top SearchBar drives it. + search: string; + setSearch: (q: string) => void; onViewChannel: (id: string, name: string) => void; onFilterByTag: (tagId: number, name: string) => void; onFocusChannel: (name: string) => void; @@ -190,9 +194,8 @@ export default function Channels({ onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard), }); - // Channel-name search + tag-chip filtering, applied client-side over the status-filtered list - // (prominent controls instead of the DataTable's hidden per-column popovers). - const [search, setSearch] = useState(""); + // Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied + // client-side over the status-filtered list. const [tagFilter, setTagFilter] = useState([]); // A focus-channel intent (header "without full history" / tag manager) seeds the search box. useEffect(() => { @@ -399,7 +402,12 @@ export default function Channels({ return ( <> {tabs} - + ); } @@ -482,25 +490,7 @@ export default function Channels({ />

- {/* Channel-name search */} -
- - setSearch(e.target.value)} - placeholder={t("channels.searchPlaceholder")} - className="w-full bg-card border border-border rounded-lg pl-8 pr-8 py-2 text-sm outline-none focus:border-accent" - /> - {search && ( - - )} -
+ {/* The channel-name search now lives in the shared top SearchBar (driven via props). */} {/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 2038bbf..9ee22d1 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,20 +1,28 @@ +import { type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Search, X, Youtube } from "lucide-react"; +import { Youtube } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; -import { pageTitleKey } from "../lib/pageMeta"; -import PageTitle from "./PageTitle"; +import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules"; +import ModuleName from "./ModuleName"; +import SearchBar from "./SearchBar"; -// Contextual top bar over the content column. Primary navigation, account and the per-user sync -// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar. -// The header carries just the feed search (or the current page title). +// The floating top header: a fixed-position row of glass pills over the content — a ModuleName +// pill (label + accent dot + back/forward arrows) and, where a module searches, a SearchBar pill. +// The row's left edge is a constant (as if the nav rail AND filter sidebar were both open), so it +// never shifts when either collapses or when paging between modules; the right edge leaves ~10%. export default function Header({ me, filters, setFilters, plexQ, setPlexQ, + channelsSearch, + setChannelsSearch, + playlistsSearch, + setPlaylistsSearch, page, + setPage, onYtSearch, }: { me: Me; @@ -23,78 +31,88 @@ export default function Header({ // Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not. plexQ: string; setPlexQ: (q: string) => void; + channelsSearch: string; + setChannelsSearch: (q: string) => void; + playlistsSearch: string; + setPlaylistsSearch: (q: string) => void; page: Page; - // Trigger a live YouTube search for the current term (hidden for the demo account, which - // can't spend the shared quota). + setPage: (p: Page) => void; + // Trigger a live YouTube search for the current term (feed only; hidden for the demo account, + // which can't spend the shared quota). onYtSearch: (q: string) => void; }) { const { t } = useTranslation(); - const canSearchYt = !me.is_demo; - // The search box serves both the YouTube feed and the Plex module (integrated search); the live - // YouTube-search escalation (Enter / button) is feed-only. On Plex it drives `plexQ`, on the feed - // the persisted `filters.q`. - const isSearchPage = page === "feed" || page === "plex"; - const isPlex = page === "plex"; - const isYtCapable = page === "feed" && canSearchYt; - const searchValue = isPlex ? plexQ : filters.q; - const trimmedQ = searchValue.trim(); + // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so + // it stays correct as modules are added/gated). Labels of every active module feed ModuleName's + // dynamic width so the pill is sized to the widest reachable title in the current language. + const order = moduleOrder(me); + const moduleLabels = order.map((p) => t(moduleLabelKey[p])); + const multiModule = order.length > 1; + // Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube). + const blur = () => (document.activeElement as HTMLElement | null)?.blur?.(); + + // Per-page SearchBar wiring — null for modules without a search. + let search: ReactNode = null; + if (page === "feed") { + const trimmed = filters.q.trim(); + search = ( + { + // When a search first appears, rank the feed by relevance — set atomically with the + // query (race-free vs. per-keystroke updates). Only overrides the default "newest" + // sort; a custom sort the user chose is left alone. Cleared in Feed. + const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest"; + setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) }); + }} + onGo={() => onYtSearch(trimmed)} + goLabel={t("header.searchYoutube")} + goTitle={t("header.searchYoutubeTip")} + goIcon={} + goDisabled={!trimmed || me.is_demo} + /> + ); + } else if (page === "plex" && me.plex_enabled) { + search = ( + + ); + } else if (page === "channels") { + search = ( + + ); + } else if (page === "playlists") { + search = ( + + ); + } return ( -
- {isSearchPage ? ( -
-
- - { - const q = e.target.value; - if (isPlex) { - setPlexQ(q); - return; - } - // When a search first appears, rank the feed by relevance — set atomically with - // the query (race-free vs. per-keystroke updates). Only overrides the default - // "newest" sort; a custom sort the user chose is left alone. Cleared in Feed. - const startSearch = - !filters.q.trim() && !!q.trim() && filters.sort === "newest"; - setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) }); - }} - onKeyDown={(e) => { - // On the feed, Enter escalates the typed term to a live YouTube search (the box - // itself filters the local catalog / Plex library as you type). - if (e.key === "Enter" && trimmedQ && isYtCapable) onYtSearch(trimmedQ); - }} - placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")} - className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent" - /> - {searchValue && ( - - )} -
- {trimmedQ && isYtCapable && ( - - )} -
- ) : ( -
- -
- )} -
+
+ setPage(stepModule(me, page, -1))} + onNext={() => setPage(stepModule(me, page, 1))} + canPrev={multiModule} + canNext={multiModule} + /> + {search} +
); } diff --git a/frontend/src/components/ModuleName.tsx b/frontend/src/components/ModuleName.tsx new file mode 100644 index 0000000..304e4e5 --- /dev/null +++ b/frontend/src/components/ModuleName.tsx @@ -0,0 +1,90 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +// The floating module-name pill in the top header. The label area is sized to the WIDEST +// reachable module title (measured live, in the current language only — so dropping a locale or +// gaining/losing a module just changes the input, nothing is hard-coded), which keeps the label +// column a constant width: the name never shifts and the arrows stay put when paging between +// modules. Carries the back/forward arrows that step cyclically through the active modules, plus +// the accent dot (the former PageTitle "eyebrow" look, folded in here). +export default function ModuleName({ + label, + labels, + onPrev, + onNext, + canPrev, + canNext, +}: { + label: string; + // Every reachable module's title in the current language — drives the fixed label width. + labels: string[]; + onPrev: () => void; + onNext: () => void; + canPrev: boolean; + canNext: boolean; +}) { + const { t } = useTranslation(); + const labelRef = useRef(null); + const canvasRef = useRef(null); + const [labelW, setLabelW] = useState(); + + // Measure the widest label using the label span's own rendered font (respects font-scale, + // tracking and the uppercase transform). Re-runs whenever the label set changes — i.e. on a + // language switch or an account switch that changes which modules are reachable. + useLayoutEffect(() => { + const el = labelRef.current; + if (!el || labels.length === 0) return; + const cs = getComputedStyle(el); + const canvas = canvasRef.current ?? (canvasRef.current = document.createElement("canvas")); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + const ls = parseFloat(cs.letterSpacing) || 0; + const upper = cs.textTransform === "uppercase"; + let max = 0; + for (const s of labels) { + const txt = upper ? s.toUpperCase() : s; + const w = ctx.measureText(txt).width + ls * txt.length; + if (w > max) max = w; + } + setLabelW(Math.ceil(max) + 2); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [labels.join("")]); + + const arrow = + "p-1 rounded-lg text-muted transition hover:text-fg hover:bg-card disabled:opacity-25 disabled:pointer-events-none"; + return ( +
+ + + + + + + {label} + + +
+ ); +} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 6a2267f..2e38008 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -28,6 +28,7 @@ import * as e2ee from "../lib/e2ee"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; +import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; @@ -168,35 +169,41 @@ export default function NavSidebar({ const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length; type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; + // Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the + // shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift + // and a new/gated module updates both at once. Badges live here (nav-only concern). + // Icon + badge per page; the label comes from the shared moduleLabelKey so the rail and the + // header's ModuleName pill always read the same name. + const ICON: Record = { + feed: Home, + channels: Tv, + playlists: ListVideo, + plex: Clapperboard, + notifications: Bell, + messages: MessageSquare, + downloads: Download, + stats: BarChart3, + scheduler: Activity, + config: SlidersHorizontal, + users: Users, + audit: ScrollText, + settings: Settings, + }; + const BADGE: Partial> = { + notifications: unread, + messages: msgUnread, + downloads: dlActive, + }; + const META = (p: Page): Omit => ({ + icon: ICON[p], + label: t(moduleLabelKey[p]), + badge: BADGE[p], + }); + const sys = new Set(SYSTEM_PAGES); + const order = moduleOrder(me); // User-facing content modules vs. admin/system modules, separated by a divider in the rail. - const userItems: NavItem[] = [ - { page: "feed", icon: Home, label: t("header.account.feed") }, - { page: "channels", icon: Tv, label: t("header.account.channels") }, - { page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, - // Optional Plex module — browse/play your Plex library. Shown only when the admin enabled it. - ...(me.plex_enabled ? [{ page: "plex" as Page, icon: Clapperboard, label: t("plex.navLabel") }] : []), - { page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread }, - // Direct messaging — its own module; hidden for the shared demo account. - ...(me.is_demo - ? [] - : [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]), - // Download center — YouTube → server → your device. Hidden for the shared demo account - // (spends server disk + needs a real identity). - ...(me.is_demo - ? [] - : [{ page: "downloads" as Page, icon: Download, label: t("downloads.navLabel"), badge: dlActive }]), - // Per-user sync status + your own API usage (admins get an extra system-wide tab inside). - { page: "stats", icon: BarChart3, label: t("header.account.stats") }, - ]; - const systemItems: NavItem[] = - me.role === "admin" - ? [ - { page: "scheduler", icon: Activity, label: t("header.account.scheduler") }, - { page: "config", icon: SlidersHorizontal, label: t("header.account.config") }, - { page: "users", icon: Users, label: t("header.account.users") }, - { page: "audit", icon: ScrollText, label: t("header.account.audit") }, - ] - : []; + const userItems: NavItem[] = order.filter((p) => !sys.has(p)).map((p) => ({ page: p, ...META(p) })); + const systemItems: NavItem[] = order.filter((p) => sys.has(p)).map((p) => ({ page: p, ...META(p) })); const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; diff --git a/frontend/src/components/PageTitle.tsx b/frontend/src/components/PageTitle.tsx deleted file mode 100644 index 1ea61ae..0000000 --- a/frontend/src/components/PageTitle.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// The centered module title in the top bar. Every non-feed module renders through this one -// component, so its styling lives in a single place. Design element (user-picked): a tracked -// small-caps "eyebrow" with a leading accent dot. -export default function PageTitle({ label }: { label: string }) { - return ( - - - {label} - - ); -} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 1e9e466..ee7f9b3 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -183,7 +183,14 @@ function Row({ ); } -export default function Playlists({ canWrite }: { canWrite: boolean }) { +export default function Playlists({ + canWrite, + search, +}: { + canWrite: boolean; + // The playlist-name filter, from the shared top SearchBar (App-owned state). + search: string; +}) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); @@ -321,6 +328,14 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [playlists, plSort]); + // Client-side name filter driven by the shared top SearchBar. + const q = search.trim().toLowerCase(); + const visiblePlaylists = useMemo( + () => (q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists), + // eslint-disable-next-line react-hooks/exhaustive-deps + [sortedPlaylists, q] + ); + // Scroll the restored selection into view once after the rail first renders. useEffect(() => { if (!scrolledRef.current && selectedRef.current) { @@ -599,8 +614,11 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { {playlists.length === 0 && !listQuery.isLoading && (
{t("playlists.noneYet")}
)} + {playlists.length > 0 && visiblePlaylists.length === 0 && ( +
{t("playlists.noMatches")}
+ )}
- {sortedPlaylists.map((pl) => ( + {visiblePlaylists.map((pl) => ( + )} + {onGo && ( + + )} +
+ ); +} diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json index 7e22b54..a8987bd 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -4,6 +4,10 @@ "clearSearch": "Suche löschen", "searchYoutube": "Auf YouTube suchen", "searchYoutubeTip": "Live auf YouTube nach diesem Begriff suchen (verbraucht gemeinsames API-Kontingent). Du kannst auch Enter drücken.", + "go": "Los", + "goTip": "Suchen (oder Enter drücken)", + "prevModule": "Vorheriges Modul", + "nextModule": "Nächstes Modul", "channelManager": "Kanalverwaltung", "usageStats": "Nutzung & Statistik", "scheduler": "Planer", diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 706522b..859bb1e 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -7,6 +7,8 @@ "ytReadonly": "Von YouTube synchronisiert — Änderungen werden zurücksynchronisiert", "noneYet": "Noch keine Wiedergabelisten", "newPlaylist": "Neue Liste…", + "searchPlaceholder": "Wiedergabelisten suchen…", + "noMatches": "Keine passende Wiedergabeliste.", "itemCount_one": "{{count}} Video", "itemCount_other": "{{count}} Videos", "pickOne": "Wähle links eine Liste.", diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index d5f29f5..94bc154 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -4,6 +4,10 @@ "clearSearch": "Clear search", "searchYoutube": "Search YouTube", "searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.", + "go": "Go", + "goTip": "Search (or press Enter)", + "prevModule": "Previous module", + "nextModule": "Next module", "channelManager": "Channel manager", "usageStats": "Usage & stats", "scheduler": "Scheduler", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 179101e..f9e827a 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -7,6 +7,8 @@ "ytReadonly": "Synced from YouTube — edits sync back", "noneYet": "No playlists yet", "newPlaylist": "New playlist…", + "searchPlaceholder": "Search playlists…", + "noMatches": "No playlists match.", "itemCount_one": "{{count}} video", "itemCount_other": "{{count}} videos", "pickOne": "Pick a playlist on the left.", diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 8aa29c4..6aea6c4 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -4,6 +4,10 @@ "clearSearch": "Keresés törlése", "searchYoutube": "Keresés a YouTube-on", "searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre (a közös API-kvótát fogyasztja). Entert is nyomhatsz.", + "go": "Mehet", + "goTip": "Keresés (vagy Enter)", + "prevModule": "Előző modul", + "nextModule": "Következő modul", "channelManager": "Csatornakezelő", "usageStats": "Használat és statisztika", "scheduler": "Ütemező", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index c7104c9..c32abdc 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -7,6 +7,8 @@ "ytReadonly": "YouTube-ról szinkronizálva — a módosítások visszaszinkronizálhatók", "noneYet": "Még nincs lejátszási lista", "newPlaylist": "Új lista…", + "searchPlaceholder": "Listák keresése…", + "noMatches": "Nincs találó lista.", "itemCount_one": "{{count}} videó", "itemCount_other": "{{count}} videó", "pickOne": "Válassz egy listát a bal oldalon.", diff --git a/frontend/src/index.css b/frontend/src/index.css index 6a3a9ab..48b913c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5,6 +5,11 @@ :root { --font-scale: 1.06; + /* Vertical space the floating top header needs to clear (top offset 12px + pill height 44px + + bottom breathing room ~12px). Module content pads its top by this so nothing hides under the + header; kept in one place since App and Playlists both reference it. */ + --hdr-h: 68px; + /* ===== Glass tunables — every value that materially drives the look lives here so it can be tuned in ONE place. ===== */ /* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts new file mode 100644 index 0000000..05ef101 --- /dev/null +++ b/frontend/src/lib/modules.ts @@ -0,0 +1,50 @@ +import type { Me } from "./api"; +import type { Page } from "./urlState"; + +// Admin/system modules — rendered in their own section (below a divider) in the nav rail. +export const SYSTEM_PAGES: readonly Page[] = ["scheduler", "config", "users", "audit"]; + +// The i18n key for each module's SHORT display name — the same label the nav rail shows. Single +// source so the nav rail and the header's ModuleName pill always read identically (the longer, +// descriptive `pageTitleKey` names are kept only for the browser tab title). +export const moduleLabelKey: Record = { + feed: "header.account.feed", + channels: "header.account.channels", + playlists: "header.account.playlists", + plex: "plex.navLabel", + notifications: "inbox.navLabel", + messages: "messages.navLabel", + downloads: "downloads.navLabel", + stats: "header.account.stats", + scheduler: "header.account.scheduler", + config: "header.account.config", + users: "header.account.users", + audit: "header.account.audit", + settings: "header.account.settings", +}; + +// The ordered list of primary module pages available to THIS user, in nav-rail order. Single +// source of truth for both the nav rail (NavSidebar) and the header's ◀/▶ module stepper, so a +// new module (or a newly-gated one) shows up in both at once — nothing hard-codes the sequence. +// Availability is derived from the account (plex toggle, demo restrictions, admin role), so the +// reachable set changes with the language-independent account state, not the UI. +export function moduleOrder(me: Me): Page[] { + const pages: Page[] = ["feed", "channels", "playlists"]; + if (me.plex_enabled) pages.push("plex"); + pages.push("notifications"); + if (!me.is_demo) pages.push("messages", "downloads"); + pages.push("stats"); + if (me.role === "admin") pages.push(...SYSTEM_PAGES); + return pages; +} + +// Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back. +// If the current page isn't a primary module (e.g. Settings), forward lands on the first module +// and back on the last. +export function stepModule(me: Me, current: Page, dir: 1 | -1): Page { + const order = moduleOrder(me); + if (order.length === 0) return current; + const i = order.indexOf(current); + if (i === -1) return dir === 1 ? order[0] : order[order.length - 1]; + return order[(i + dir + order.length) % order.length]; +}