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
|
|
@ -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<DiscoveredChannel>[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<number[]>([]);
|
||||
// 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}
|
||||
<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>
|
||||
|
||||
{/* Channel-name search */}
|
||||
<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>
|
||||
{/* 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). */}
|
||||
<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 { 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 = (
|
||||
<SearchBar
|
||||
value={filters.q}
|
||||
placeholder={t("header.searchPlaceholder")}
|
||||
onChange={(q) => {
|
||||
// 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={<Youtube className="w-4 h-4" />}
|
||||
goDisabled={!trimmed || me.is_demo}
|
||||
/>
|
||||
);
|
||||
} else if (page === "plex" && me.plex_enabled) {
|
||||
search = (
|
||||
<SearchBar
|
||||
value={plexQ}
|
||||
placeholder={t("plex.searchPlaceholder")}
|
||||
onChange={setPlexQ}
|
||||
onGo={blur}
|
||||
/>
|
||||
);
|
||||
} 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 (
|
||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
||||
{isSearchPage ? (
|
||||
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<button
|
||||
onClick={() => (isPlex ? setPlexQ("") : setFilters({ ...filters, q: "" }))}
|
||||
title={t("header.clearSearch")}
|
||||
aria-label={t("header.clearSearch")}
|
||||
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"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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 { 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<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.
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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 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 && (
|
||||
<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">
|
||||
{sortedPlaylists.map((pl) => (
|
||||
{visiblePlaylists.map((pl) => (
|
||||
<button
|
||||
key={pl.id}
|
||||
ref={pl.id === selectedId ? selectedRef : null}
|
||||
|
|
@ -636,7 +654,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
|||
</div>
|
||||
</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 ? (
|
||||
<div className="text-muted text-sm p-4">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue