diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7501839..35b2825 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -86,7 +86,7 @@ function loadInitialFilters(): FeedFilters { } export default function App() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(loadInitialFilters); const [view, setView] = useState<"grid" | "list">("grid"); @@ -235,14 +235,17 @@ export default function App() { page={page} setPage={setPage} onOpenAbout={() => setAboutOpen(true)} + onChangeLanguage={changeLanguage} + language={i18n.language as LangCode} + filters={filters} + setFilters={setFilters} /> -
+
{ setChannelFilter("needs_full"); setPage("channels"); @@ -298,6 +301,10 @@ export default function App() { )}
+ {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. + Anchored inside the content column so they clear the sidebar automatically + (collapsed or expanded) without tracking its width. */} +
{wizardOpen && meQuery.data && !meQuery.data.is_demo && ( setWizardOpen(false)} /> @@ -314,7 +321,6 @@ export default function App() { {notesOpen && ( setNotesOpen(false)} highlight={notesHighlight} /> )} - ); } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 5afe782..72d498e 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -2,10 +2,7 @@ import { useTranslation } from "react-i18next"; import { Library, Search, User } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; -import { type LangCode } from "../i18n"; import SyncStatus from "./SyncStatus"; -import NotificationCenter from "./NotificationCenter"; -import LanguageSwitcher from "./LanguageSwitcher"; // Contextual top bar. Primary navigation + account now live in the left NavSidebar; the // header carries the global sync status, the feed's scope toggle + search (or the current @@ -15,17 +12,15 @@ export default function Header({ filters, setFilters, page, - onChangeLanguage, onGoToFullHistory, }: { me: Me; filters: FeedFilters; setFilters: (f: FeedFilters) => void; page: Page; - onChangeLanguage: (code: LangCode) => void; onGoToFullHistory: () => void; }) { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); return (
@@ -83,11 +78,6 @@ export default function Header({ : t("header.channelManager")} )} - -
- - -
); } diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index 62e90cf..598f3d6 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -1,20 +1,31 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Check, Globe } from "lucide-react"; import { LANGUAGES, type LangCode } from "../i18n"; // Compact language picker (globe + current code). Presentational: the parent decides what // changing the language does (set it; persist server-side when signed in). +// +// Two variants: "header" (legacy inline dropdown, opens below) and "rail" (used in the left +// nav's bottom icon cluster — an icon-only button whose menu is portaled to and +// anchored to the right + above the button, escaping the nav's backdrop-filter which would +// otherwise trap an absolutely-positioned popover). export default function LanguageSwitcher({ value, onChange, align = "right", + variant = "header", }: { value: LangCode; onChange: (code: LangCode) => void; align?: "left" | "right"; + variant?: "header" | "rail"; }) { const [open, setOpen] = useState(false); const closeTimer = useRef | null>(null); + const btnRef = useRef(null); + const panelRef = useRef(null); + const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0]; function openNow() { @@ -24,6 +35,77 @@ export default function LanguageSwitcher({ function closeSoon() { closeTimer.current = setTimeout(() => setOpen(false), 200); } + function toggleRail() { + if (!open) { + const r = btnRef.current?.getBoundingClientRect(); + if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); + } + setOpen((o) => !o); + } + + // Rail popover: dismiss on outside click / Escape (it's portaled, so a contains() check + // spans both the button and the floating panel). + useEffect(() => { + if (variant !== "rail" || !open) return; + function onDoc(e: MouseEvent) { + const target = e.target as Node; + if (panelRef.current?.contains(target) || btnRef.current?.contains(target)) return; + setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + }; + }, [variant, open]); + + const menu = ( +
+ {LANGUAGES.map((l) => ( + + ))} +
+ ); + + if (variant === "rail") { + return ( + <> + + {open && + createPortal( +
+ {menu} +
, + document.body + )} + + ); + } return (
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index cb6d989..d9ce0fe 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -16,9 +16,12 @@ import { Tv, UserPlus, } from "lucide-react"; -import { api, type Me } from "../lib/api"; +import { api, type FeedFilters, type Me } from "../lib/api"; import type { Page } from "../lib/urlState"; +import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; +import LanguageSwitcher from "./LanguageSwitcher"; +import NotificationCenter from "./NotificationCenter"; // Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes @@ -28,11 +31,19 @@ export default function NavSidebar({ page, setPage, onOpenAbout, + onChangeLanguage, + language, + filters, + setFilters, }: { me: Me; page: Page; setPage: (p: Page) => void; onOpenAbout: () => void; + onChangeLanguage: (code: LangCode) => void; + language: LangCode; + filters: FeedFilters; + setFilters: (f: FeedFilters) => void; }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState( @@ -105,20 +116,40 @@ export default function NavSidebar({ } } - const items: { page: Page; icon: typeof Home; label: string }[] = [ + type NavItem = { page: Page; icon: typeof Home; label: string }; + // 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") }, ]; - if (me.role === "admin") { - items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") }); - items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }); - } + const systemItems: NavItem[] = + me.role === "admin" + ? [ + { page: "stats", icon: BarChart3, label: t("header.account.stats") }, + { page: "scheduler", icon: Activity, label: t("header.account.scheduler") }, + ] + : []; const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; const name = me.display_name ?? me.email.split("@")[0]; + const renderItem = ({ page: p, icon: Icon, label }: NavItem) => ( + + ); + return (