feat(header): floating module header with cyclic nav and shared search
Replace the full-width top bar with a fixed floating header of glass pills: a fixed-position ModuleName (accent dot + label + prev/next cyclic module stepper) and a shared SearchBar. The header's left edge is anchored as if the nav rail and filter sidebar were both open, so it never shifts on collapse or module change; the right edge leaves a 10% margin. - ModuleName label width is measured live from the reachable modules' titles in the current language, recomputing on a language or account change. - Module names come from a shared moduleLabelKey (the same short labels as the nav rail); NavSidebar and the header now read from one source (lib/modules.ts). - The arrows step cyclically through moduleOrder(me) — dynamic, no hard-coded targets, wrapping at both ends. - Search is hoisted into the SearchBar for feed (YouTube search), plex, channels (including the Discovery tab) and playlists (new); Go button, Enter triggers it. - The Playlists left rail now reaches the top; header clearance via --hdr-h.
This commit is contained in:
parent
36c27250c3
commit
2739f25e6c
17 changed files with 420 additions and 141 deletions
|
|
@ -182,6 +182,10 @@ export default function App() {
|
||||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
const saveMsgTimer = useRef<number | undefined>(undefined);
|
||||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
const [page, setPageState] = useState<Page>(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,
|
// 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).
|
// 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
|
// 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}
|
setFilters={setFilters}
|
||||||
plexQ={plexQ}
|
plexQ={plexQ}
|
||||||
setPlexQ={setPlexQ}
|
setPlexQ={setPlexQ}
|
||||||
|
channelsSearch={channelsSearch}
|
||||||
|
setChannelsSearch={setChannelsSearch}
|
||||||
|
playlistsSearch={playlistsSearch}
|
||||||
|
setPlaylistsSearch={setPlaylistsSearch}
|
||||||
page={page}
|
page={page}
|
||||||
|
setPage={setPage}
|
||||||
onYtSearch={enterYtSearch}
|
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). */}
|
||||||
|
<div
|
||||||
|
className={`flex-1 min-w-0 flex flex-col ${page === "playlists" ? "" : "pt-[var(--hdr-h)]"}`}
|
||||||
|
>
|
||||||
{meQuery.data!.is_demo && <DemoBanner />}
|
{meQuery.data!.is_demo && <DemoBanner />}
|
||||||
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
||||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||||
|
|
@ -814,6 +829,8 @@ export default function App() {
|
||||||
<Channels
|
<Channels
|
||||||
canWrite={meQuery.data!.can_write}
|
canWrite={meQuery.data!.can_write}
|
||||||
isAdmin={meQuery.data!.role === "admin"}
|
isAdmin={meQuery.data!.role === "admin"}
|
||||||
|
search={channelsSearch}
|
||||||
|
setSearch={setChannelsSearch}
|
||||||
focusChannelName={focusChannelName}
|
focusChannelName={focusChannelName}
|
||||||
focusChannelToken={focusChannelToken}
|
focusChannelToken={focusChannelToken}
|
||||||
onFocusChannel={focusChannel}
|
onFocusChannel={focusChannel}
|
||||||
|
|
@ -841,7 +858,7 @@ export default function App() {
|
||||||
) : page === "audit" && meQuery.data!.role === "admin" ? (
|
) : page === "audit" && meQuery.data!.role === "admin" ? (
|
||||||
<AuditLog />
|
<AuditLog />
|
||||||
) : page === "playlists" ? (
|
) : page === "playlists" ? (
|
||||||
<Playlists canWrite={meQuery.data!.can_write} />
|
<Playlists canWrite={meQuery.data!.can_write} search={playlistsSearch} />
|
||||||
) : page === "notifications" ? (
|
) : page === "notifications" ? (
|
||||||
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
|
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
|
||||||
) : page === "messages" && !meQuery.data!.is_demo ? (
|
) : page === "messages" && !meQuery.data!.is_demo ? (
|
||||||
|
|
@ -884,6 +901,7 @@ export default function App() {
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
|
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,13 @@ import { useConfirm } from "./ConfirmProvider";
|
||||||
// the channel's metadata — the scheduler pulls its videos on its next run (see backend).
|
// the channel's metadata — the scheduler pulls its videos on its next run (see backend).
|
||||||
export default function ChannelDiscovery({
|
export default function ChannelDiscovery({
|
||||||
canWrite,
|
canWrite,
|
||||||
|
search,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
onViewChannel,
|
onViewChannel,
|
||||||
}: {
|
}: {
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
|
// Client-side name filter from the shared top SearchBar (matches title + handle).
|
||||||
|
search: string;
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
onViewChannel: (id: string, name: string) => void;
|
onViewChannel: (id: string, name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -67,7 +70,10 @@ export default function ChannelDiscovery({
|
||||||
if (ok) subscribe.mutate(c);
|
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<DiscoveredChannel>[] = [
|
const columns: Column<DiscoveredChannel>[] = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
|
||||||
UserMinus,
|
UserMinus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -44,6 +43,8 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||||
export default function Channels({
|
export default function Channels({
|
||||||
canWrite,
|
canWrite,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
onViewChannel,
|
onViewChannel,
|
||||||
onFilterByTag,
|
onFilterByTag,
|
||||||
onFocusChannel,
|
onFocusChannel,
|
||||||
|
|
@ -58,6 +59,9 @@ export default function Channels({
|
||||||
}: {
|
}: {
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
isAdmin: 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;
|
onViewChannel: (id: string, name: string) => void;
|
||||||
onFilterByTag: (tagId: number, name: string) => void;
|
onFilterByTag: (tagId: number, name: string) => void;
|
||||||
onFocusChannel: (name: string) => void;
|
onFocusChannel: (name: string) => void;
|
||||||
|
|
@ -190,9 +194,8 @@ export default function Channels({
|
||||||
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
|
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Channel-name search + tag-chip filtering, applied client-side over the status-filtered list
|
// Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied
|
||||||
// (prominent controls instead of the DataTable's hidden per-column popovers).
|
// client-side over the status-filtered list.
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
||||||
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -399,7 +402,12 @@ export default function Channels({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{tabs}
|
{tabs}
|
||||||
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} onViewChannel={onViewChannel} />
|
<ChannelDiscovery
|
||||||
|
canWrite={canWrite}
|
||||||
|
search={search}
|
||||||
|
onOpenWizard={onOpenWizard}
|
||||||
|
onViewChannel={onViewChannel}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -482,25 +490,7 @@ export default function Channels({
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Channel-name search */}
|
{/* The channel-name search now lives in the shared top SearchBar (driven via props). */}
|
||||||
<div className="relative mb-3 max-w-sm">
|
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted pointer-events-none" />
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => 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 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearch("")}
|
|
||||||
aria-label={t("common.cancel")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-fg"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
|
{/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,28 @@
|
||||||
|
import { type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { FeedFilters, Me } from "../lib/api";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { pageTitleKey } from "../lib/pageMeta";
|
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||||
import PageTitle from "./PageTitle";
|
import ModuleName from "./ModuleName";
|
||||||
|
import SearchBar from "./SearchBar";
|
||||||
|
|
||||||
// Contextual top bar over the content column. Primary navigation, account and the per-user sync
|
// The floating top header: a fixed-position row of glass pills over the content — a ModuleName
|
||||||
// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar.
|
// pill (label + accent dot + back/forward arrows) and, where a module searches, a SearchBar pill.
|
||||||
// The header carries just the feed search (or the current page title).
|
// 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({
|
export default function Header({
|
||||||
me,
|
me,
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
plexQ,
|
plexQ,
|
||||||
setPlexQ,
|
setPlexQ,
|
||||||
|
channelsSearch,
|
||||||
|
setChannelsSearch,
|
||||||
|
playlistsSearch,
|
||||||
|
setPlaylistsSearch,
|
||||||
page,
|
page,
|
||||||
|
setPage,
|
||||||
onYtSearch,
|
onYtSearch,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
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.
|
// Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not.
|
||||||
plexQ: string;
|
plexQ: string;
|
||||||
setPlexQ: (q: string) => void;
|
setPlexQ: (q: string) => void;
|
||||||
|
channelsSearch: string;
|
||||||
|
setChannelsSearch: (q: string) => void;
|
||||||
|
playlistsSearch: string;
|
||||||
|
setPlaylistsSearch: (q: string) => void;
|
||||||
page: Page;
|
page: Page;
|
||||||
// Trigger a live YouTube search for the current term (hidden for the demo account, which
|
setPage: (p: Page) => void;
|
||||||
// can't spend the shared quota).
|
// 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;
|
onYtSearch: (q: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const canSearchYt = !me.is_demo;
|
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
||||||
// The search box serves both the YouTube feed and the Plex module (integrated search); the live
|
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
|
||||||
// YouTube-search escalation (Enter / button) is feed-only. On Plex it drives `plexQ`, on the feed
|
// dynamic width so the pill is sized to the widest reachable title in the current language.
|
||||||
// the persisted `filters.q`.
|
const order = moduleOrder(me);
|
||||||
const isSearchPage = page === "feed" || page === "plex";
|
const moduleLabels = order.map((p) => t(moduleLabelKey[p]));
|
||||||
const isPlex = page === "plex";
|
const multiModule = order.length > 1;
|
||||||
const isYtCapable = page === "feed" && canSearchYt;
|
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
|
||||||
const searchValue = isPlex ? plexQ : filters.q;
|
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
||||||
const trimmedQ = searchValue.trim();
|
|
||||||
|
|
||||||
return (
|
// Per-page SearchBar wiring — null for modules without a search.
|
||||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
let search: ReactNode = null;
|
||||||
{isSearchPage ? (
|
if (page === "feed") {
|
||||||
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
const trimmed = filters.q.trim();
|
||||||
<div className="flex-1 relative">
|
search = (
|
||||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
<SearchBar
|
||||||
<input
|
value={filters.q}
|
||||||
value={searchValue}
|
placeholder={t("header.searchPlaceholder")}
|
||||||
onChange={(e) => {
|
onChange={(q) => {
|
||||||
const q = e.target.value;
|
// When a search first appears, rank the feed by relevance — set atomically with the
|
||||||
if (isPlex) {
|
// query (race-free vs. per-keystroke updates). Only overrides the default "newest"
|
||||||
setPlexQ(q);
|
// sort; a custom sort the user chose is left alone. Cleared in Feed.
|
||||||
return;
|
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
||||||
}
|
|
||||||
// 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" } : {}) });
|
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onGo={() => onYtSearch(trimmed)}
|
||||||
// On the feed, Enter escalates the typed term to a live YouTube search (the box
|
goLabel={t("header.searchYoutube")}
|
||||||
// itself filters the local catalog / Plex library as you type).
|
goTitle={t("header.searchYoutubeTip")}
|
||||||
if (e.key === "Enter" && trimmedQ && isYtCapable) onYtSearch(trimmedQ);
|
goIcon={<Youtube className="w-4 h-4" />}
|
||||||
}}
|
goDisabled={!trimmed || me.is_demo}
|
||||||
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 && (
|
);
|
||||||
<button
|
} else if (page === "plex" && me.plex_enabled) {
|
||||||
onClick={() => (isPlex ? setPlexQ("") : setFilters({ ...filters, q: "" }))}
|
search = (
|
||||||
title={t("header.clearSearch")}
|
<SearchBar
|
||||||
aria-label={t("header.clearSearch")}
|
value={plexQ}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-muted hover:text-fg hover:bg-border/60 transition"
|
placeholder={t("plex.searchPlaceholder")}
|
||||||
>
|
onChange={setPlexQ}
|
||||||
<X className="w-4 h-4" />
|
onGo={blur}
|
||||||
</button>
|
/>
|
||||||
)}
|
);
|
||||||
|
} else if (page === "channels") {
|
||||||
|
search = (
|
||||||
|
<SearchBar
|
||||||
|
value={channelsSearch}
|
||||||
|
placeholder={t("channels.searchPlaceholder")}
|
||||||
|
onChange={setChannelsSearch}
|
||||||
|
onGo={blur}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (page === "playlists") {
|
||||||
|
search = (
|
||||||
|
<SearchBar
|
||||||
|
value={playlistsSearch}
|
||||||
|
placeholder={t("playlists.searchPlaceholder")}
|
||||||
|
onChange={setPlaylistsSearch}
|
||||||
|
onGo={blur}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed top-3 left-4 right-[10%] z-30 flex items-center gap-3 md:left-[480px]">
|
||||||
|
<ModuleName
|
||||||
|
label={t(moduleLabelKey[page])}
|
||||||
|
labels={moduleLabels}
|
||||||
|
onPrev={() => setPage(stepModule(me, page, -1))}
|
||||||
|
onNext={() => setPage(stepModule(me, page, 1))}
|
||||||
|
canPrev={multiModule}
|
||||||
|
canNext={multiModule}
|
||||||
|
/>
|
||||||
|
{search}
|
||||||
</div>
|
</div>
|
||||||
{trimmedQ && isYtCapable && (
|
|
||||||
<button
|
|
||||||
onClick={() => onYtSearch(trimmedQ)}
|
|
||||||
title={t("header.searchYoutubeTip")}
|
|
||||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 text-xs font-medium text-muted hover:text-accent hover:border-accent transition"
|
|
||||||
>
|
|
||||||
<Youtube className="w-4 h-4" />
|
|
||||||
<span className="hidden md:inline">{t("header.searchYoutube")}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 flex justify-center">
|
|
||||||
<PageTitle label={t(pageTitleKey(page))} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
frontend/src/components/ModuleName.tsx
Normal file
90
frontend/src/components/ModuleName.tsx
Normal file
|
|
@ -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<HTMLSpanElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const [labelW, setLabelW] = useState<number>();
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="pointer-events-auto shrink-0 max-w-[55vw] h-11 px-2.5 rounded-2xl glass-menu flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={!canPrev}
|
||||||
|
title={t("header.prevModule")}
|
||||||
|
aria-label={t("header.prevModule")}
|
||||||
|
className={arrow}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canNext}
|
||||||
|
title={t("header.nextModule")}
|
||||||
|
aria-label={t("header.nextModule")}
|
||||||
|
className={arrow}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="w-px h-5 bg-border/70 mx-1 shrink-0" />
|
||||||
|
<span className="min-w-0 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-fg">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
|
||||||
|
<span
|
||||||
|
ref={labelRef}
|
||||||
|
className="min-w-0 truncate"
|
||||||
|
style={labelW ? { width: labelW } : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import * as e2ee from "../lib/e2ee";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
|
import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
||||||
import { type LangCode } from "../i18n";
|
import { type LangCode } from "../i18n";
|
||||||
import AvatarImg from "./Avatar";
|
import AvatarImg from "./Avatar";
|
||||||
import LanguageSwitcher from "./LanguageSwitcher";
|
import LanguageSwitcher from "./LanguageSwitcher";
|
||||||
|
|
@ -168,35 +169,41 @@ export default function NavSidebar({
|
||||||
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
|
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
|
||||||
|
|
||||||
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
|
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<Page, typeof Home> = {
|
||||||
|
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<Record<Page, number>> = {
|
||||||
|
notifications: unread,
|
||||||
|
messages: msgUnread,
|
||||||
|
downloads: dlActive,
|
||||||
|
};
|
||||||
|
const META = (p: Page): Omit<NavItem, "page"> => ({
|
||||||
|
icon: ICON[p],
|
||||||
|
label: t(moduleLabelKey[p]),
|
||||||
|
badge: BADGE[p],
|
||||||
|
});
|
||||||
|
const sys = new Set<Page>(SYSTEM_PAGES);
|
||||||
|
const order = moduleOrder(me);
|
||||||
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
|
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
|
||||||
const userItems: NavItem[] = [
|
const userItems: NavItem[] = order.filter((p) => !sys.has(p)).map((p) => ({ page: p, ...META(p) }));
|
||||||
{ page: "feed", icon: Home, label: t("header.account.feed") },
|
const systemItems: NavItem[] = order.filter((p) => sys.has(p)).map((p) => ({ page: p, ...META(p) }));
|
||||||
{ 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 rowBase =
|
const rowBase =
|
||||||
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
|
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
|
||||||
<span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-fg">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
|
@ -321,6 +328,14 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [playlists, plSort]);
|
}, [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.
|
// Scroll the restored selection into view once after the rail first renders.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scrolledRef.current && selectedRef.current) {
|
if (!scrolledRef.current && selectedRef.current) {
|
||||||
|
|
@ -599,8 +614,11 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
{playlists.length === 0 && !listQuery.isLoading && (
|
{playlists.length === 0 && !listQuery.isLoading && (
|
||||||
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
|
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
|
||||||
)}
|
)}
|
||||||
|
{playlists.length > 0 && visiblePlaylists.length === 0 && (
|
||||||
|
<div className="text-xs text-muted px-1 py-2">{t("playlists.noMatches")}</div>
|
||||||
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{sortedPlaylists.map((pl) => (
|
{visiblePlaylists.map((pl) => (
|
||||||
<button
|
<button
|
||||||
key={pl.id}
|
key={pl.id}
|
||||||
ref={pl.id === selectedId ? selectedRef : null}
|
ref={pl.id === selectedId ? selectedRef : null}
|
||||||
|
|
@ -636,7 +654,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex-1 min-w-0 overflow-y-auto p-4">
|
<main className="flex-1 min-w-0 overflow-y-auto px-4 pb-4 pt-[var(--hdr-h)]">
|
||||||
{selectedId == null || !detail ? (
|
{selectedId == null || !detail ? (
|
||||||
<div className="text-muted text-sm p-4">
|
<div className="text-muted text-sm p-4">
|
||||||
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
|
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
|
||||||
|
|
|
||||||
70
frontend/src/components/SearchBar.tsx
Normal file
70
frontend/src/components/SearchBar.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
|
||||||
|
// The floating search pill in the top header. One component for every module that searches
|
||||||
|
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
|
||||||
|
// clear button, and a "Go" trigger (Enter also fires it). The feed passes its live-YouTube
|
||||||
|
// escalation as the Go action; the other modules filter as you type and Go just commits/blurs.
|
||||||
|
export default function SearchBar({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
onGo,
|
||||||
|
goLabel,
|
||||||
|
goIcon,
|
||||||
|
goDisabled,
|
||||||
|
goTitle,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (q: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
// Enter / the Go button call this. Omit to hide the Go button entirely.
|
||||||
|
onGo?: () => void;
|
||||||
|
goLabel?: string;
|
||||||
|
goIcon?: ReactNode;
|
||||||
|
goDisabled?: boolean;
|
||||||
|
goTitle?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fire = () => {
|
||||||
|
if (onGo && !goDisabled) onGo();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2">
|
||||||
|
<Search className="w-4 h-4 shrink-0 text-muted" />
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") fire();
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 min-w-0 bg-transparent outline-none text-sm placeholder:text-muted"
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
title={t("header.clearSearch")}
|
||||||
|
aria-label={t("header.clearSearch")}
|
||||||
|
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onGo && (
|
||||||
|
<button
|
||||||
|
onClick={fire}
|
||||||
|
disabled={goDisabled}
|
||||||
|
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
{goIcon}
|
||||||
|
<span className={goIcon ? "hidden md:inline" : undefined}>
|
||||||
|
{goLabel ?? t("header.go")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
"clearSearch": "Suche löschen",
|
"clearSearch": "Suche löschen",
|
||||||
"searchYoutube": "Auf YouTube suchen",
|
"searchYoutube": "Auf YouTube suchen",
|
||||||
"searchYoutubeTip": "Live auf YouTube nach diesem Begriff suchen (verbraucht gemeinsames API-Kontingent). Du kannst auch Enter drücken.",
|
"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",
|
"channelManager": "Kanalverwaltung",
|
||||||
"usageStats": "Nutzung & Statistik",
|
"usageStats": "Nutzung & Statistik",
|
||||||
"scheduler": "Planer",
|
"scheduler": "Planer",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
"ytReadonly": "Von YouTube synchronisiert — Änderungen werden zurücksynchronisiert",
|
"ytReadonly": "Von YouTube synchronisiert — Änderungen werden zurücksynchronisiert",
|
||||||
"noneYet": "Noch keine Wiedergabelisten",
|
"noneYet": "Noch keine Wiedergabelisten",
|
||||||
"newPlaylist": "Neue Liste…",
|
"newPlaylist": "Neue Liste…",
|
||||||
|
"searchPlaceholder": "Wiedergabelisten suchen…",
|
||||||
|
"noMatches": "Keine passende Wiedergabeliste.",
|
||||||
"itemCount_one": "{{count}} Video",
|
"itemCount_one": "{{count}} Video",
|
||||||
"itemCount_other": "{{count}} Videos",
|
"itemCount_other": "{{count}} Videos",
|
||||||
"pickOne": "Wähle links eine Liste.",
|
"pickOne": "Wähle links eine Liste.",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
"clearSearch": "Clear search",
|
"clearSearch": "Clear search",
|
||||||
"searchYoutube": "Search YouTube",
|
"searchYoutube": "Search YouTube",
|
||||||
"searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.",
|
"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",
|
"channelManager": "Channel manager",
|
||||||
"usageStats": "Usage & stats",
|
"usageStats": "Usage & stats",
|
||||||
"scheduler": "Scheduler",
|
"scheduler": "Scheduler",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
"ytReadonly": "Synced from YouTube — edits sync back",
|
"ytReadonly": "Synced from YouTube — edits sync back",
|
||||||
"noneYet": "No playlists yet",
|
"noneYet": "No playlists yet",
|
||||||
"newPlaylist": "New playlist…",
|
"newPlaylist": "New playlist…",
|
||||||
|
"searchPlaceholder": "Search playlists…",
|
||||||
|
"noMatches": "No playlists match.",
|
||||||
"itemCount_one": "{{count}} video",
|
"itemCount_one": "{{count}} video",
|
||||||
"itemCount_other": "{{count}} videos",
|
"itemCount_other": "{{count}} videos",
|
||||||
"pickOne": "Pick a playlist on the left.",
|
"pickOne": "Pick a playlist on the left.",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
"clearSearch": "Keresés törlése",
|
"clearSearch": "Keresés törlése",
|
||||||
"searchYoutube": "Keresés a YouTube-on",
|
"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.",
|
"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ő",
|
"channelManager": "Csatornakezelő",
|
||||||
"usageStats": "Használat és statisztika",
|
"usageStats": "Használat és statisztika",
|
||||||
"scheduler": "Ütemező",
|
"scheduler": "Ütemező",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
"ytReadonly": "YouTube-ról szinkronizálva — a módosítások visszaszinkronizálhatók",
|
"ytReadonly": "YouTube-ról szinkronizálva — a módosítások visszaszinkronizálhatók",
|
||||||
"noneYet": "Még nincs lejátszási lista",
|
"noneYet": "Még nincs lejátszási lista",
|
||||||
"newPlaylist": "Új lista…",
|
"newPlaylist": "Új lista…",
|
||||||
|
"searchPlaceholder": "Listák keresése…",
|
||||||
|
"noMatches": "Nincs találó lista.",
|
||||||
"itemCount_one": "{{count}} videó",
|
"itemCount_one": "{{count}} videó",
|
||||||
"itemCount_other": "{{count}} videó",
|
"itemCount_other": "{{count}} videó",
|
||||||
"pickOne": "Válassz egy listát a bal oldalon.",
|
"pickOne": "Válassz egy listát a bal oldalon.",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
:root {
|
:root {
|
||||||
--font-scale: 1.06;
|
--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
|
/* ===== Glass tunables — every value that materially drives the look lives here so it can be
|
||||||
tuned in ONE place. ===== */
|
tuned in ONE place. ===== */
|
||||||
/* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome
|
/* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome
|
||||||
|
|
|
||||||
50
frontend/src/lib/modules.ts
Normal file
50
frontend/src/lib/modules.ts
Normal file
|
|
@ -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<Page, string> = {
|
||||||
|
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];
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue