From b59208e006557167e97f824ad47fcb3c74087d2f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 1 Jul 2026 22:50:59 +0200 Subject: [PATCH 1/9] feat(nav): admin section label + role chip beside the user name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A clearer 'Admin' section header (thicker centred rule when collapsed) above the admin-only modules (Scheduler/Configuration/Users), replacing the faint hairline. - A small role chip (admin/user/demo) next to the account name — an avatar-corner dot when the rail is collapsed. --- frontend/src/components/NavSidebar.tsx | 56 ++++++++++++++++++++++---- frontend/src/i18n/locales/de/nav.json | 8 +++- frontend/src/i18n/locales/en/nav.json | 8 +++- frontend/src/i18n/locales/hu/nav.json | 8 +++- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index c3ef325..0800b6a 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -171,6 +171,21 @@ export default function NavSidebar({ 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]; + // Role shown next to the name (expanded) / as an avatar dot (collapsed). Demo isn't a real + // role (it's a flag on a `user` row), so surface it first. + const roleKey: "admin" | "user" | "demo" = me.is_demo + ? "demo" + : me.role === "admin" + ? "admin" + : "user"; + const roleChipCls = + roleKey === "admin" + ? "bg-accent/15 text-accent" + : roleKey === "demo" + ? "bg-amber-500/20 text-amber-500" + : "bg-muted/15 text-muted"; + const roleDotCls = + roleKey === "admin" ? "bg-accent" : roleKey === "demo" ? "bg-amber-500" : "bg-muted"; const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => { const active = page === p; @@ -247,9 +262,19 @@ export default function NavSidebar({
{userItems.map(renderItem)} - {systemItems.length > 0 && ( -
- )} + {systemItems.length > 0 && + (collapsed ? ( + // Collapsed rail: a short, thicker centred rule is the clearest "new section" cue + // when there's no room for a label. +
+ ) : ( +
+ + {t("nav.adminSection")} + + +
+ ))} {systemItems.map(renderItem)}
@@ -290,12 +315,27 @@ export default function NavSidebar({ title={collapsed ? name : undefined} className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`} > - + + + {collapsed && ( + + )} + {!collapsed && {name}} + {!collapsed && ( + + {t(`nav.role.${roleKey}`)} + + )} {acctOpen && diff --git a/frontend/src/i18n/locales/de/nav.json b/frontend/src/i18n/locales/de/nav.json index 5892e3f..c3135ba 100644 --- a/frontend/src/i18n/locales/de/nav.json +++ b/frontend/src/i18n/locales/de/nav.json @@ -1,5 +1,11 @@ { "primary": "Hauptnavigation", "collapse": "Seitenleiste einklappen", - "expand": "Seitenleiste ausklappen" + "expand": "Seitenleiste ausklappen", + "adminSection": "Admin", + "role": { + "admin": "Admin", + "user": "Benutzer", + "demo": "Demo" + } } diff --git a/frontend/src/i18n/locales/en/nav.json b/frontend/src/i18n/locales/en/nav.json index 621159b..5437cf7 100644 --- a/frontend/src/i18n/locales/en/nav.json +++ b/frontend/src/i18n/locales/en/nav.json @@ -1,5 +1,11 @@ { "primary": "Primary navigation", "collapse": "Collapse sidebar", - "expand": "Expand sidebar" + "expand": "Expand sidebar", + "adminSection": "Admin", + "role": { + "admin": "Admin", + "user": "User", + "demo": "Demo" + } } diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json index 5c18135..be53d87 100644 --- a/frontend/src/i18n/locales/hu/nav.json +++ b/frontend/src/i18n/locales/hu/nav.json @@ -1,5 +1,11 @@ { "primary": "Fő navigáció", "collapse": "Oldalsáv összecsukása", - "expand": "Oldalsáv kinyitása" + "expand": "Oldalsáv kinyitása", + "adminSection": "Admin", + "role": { + "admin": "Admin", + "user": "Felhasználó", + "demo": "Demó" + } } From c6fe94450b5e3335ebd37283c70083dfa6d05dc7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 1 Jul 2026 22:50:59 +0200 Subject: [PATCH 2/9] feat(nav): current-language badge (HU/EN/DE) on the rail language switcher The icon-only rail switcher now shows the active language code as a small corner badge, so the current language reads at a glance without opening the menu. --- frontend/src/components/LanguageSwitcher.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index 598f3d6..eaaf244 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -92,9 +92,14 @@ export default function LanguageSwitcher({ onClick={toggleRail} title={current.label} aria-label={current.label} - className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" + className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" > + {/* Current-language badge (HU/EN/DE) so the active language reads at a glance without + opening the menu. The ring matches the page background to detach it from the icon. */} + + {current.code.toUpperCase()} + {open && createPortal( From 072b3296a3ebafa3281e6542162fbe7d8a02b3fd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 1 Jul 2026 23:08:25 +0200 Subject: [PATCH 3/9] feat(layout): full-height collapsible filter sidebar; sync status + scope relocated Restructure the app shell into three top-level columns: - The per-user sync status (video counts + live sync state) moves from the top bar to a compact block at the top of the left nav rail (icon-only with a tooltip when collapsed). - The feed's Mine/Library scope toggle moves to the top of the filter sidebar. - The filter sidebar becomes a full-height sibling column with its own collapse control (a thin rail carrying the active-filter count), mirroring the nav rail. The top bar is now just the feed search / page title. - Both panels' collapsed state is persisted to the user's preferences (server-side, so it follows the account across devices), seeded from a localStorage cache to avoid a flash. Default: both panels open. --- frontend/src/App.tsx | 70 ++++++++++++----- frontend/src/components/Header.tsx | 42 +---------- frontend/src/components/NavSidebar.tsx | 33 ++++---- frontend/src/components/Sidebar.tsx | 91 +++++++++++++++++++++-- frontend/src/components/SyncStatus.tsx | 90 ++++++++++++++++++++++ frontend/src/i18n/locales/de/sidebar.json | 2 + frontend/src/i18n/locales/en/sidebar.json | 2 + frontend/src/i18n/locales/hu/sidebar.json | 2 + frontend/src/lib/storage.ts | 1 + 9 files changed, 255 insertions(+), 78 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5897f07..b03b2e9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,7 +19,7 @@ import { } from "./lib/sidebarLayout"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { hintsEnabled, setHintsEnabled } from "./lib/hints"; -import { LS, readJSON, readMerged, usePersistedState } from "./lib/storage"; +import { LS, readJSON, readMerged, usePersistedState, writeJSON } from "./lib/storage"; import { useConfirm } from "./components/ConfirmProvider"; import type { PrefsController } from "./components/SettingsPanel"; import Welcome from "./components/Welcome"; @@ -257,6 +257,26 @@ export default function App() { api.savePrefs({ sidebarLayout: next }).catch(() => {}); } + // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the + // user's preferences so it follows the account across devices; a localStorage cache seeds the + // initial render synchronously so nothing flashes open before the server prefs arrive. + const [navCollapsed, setNavCollapsedState] = useState(() => + Boolean(readJSON(LS.navCollapsed, false)) + ); + const [filterCollapsed, setFilterCollapsedState] = useState(() => + Boolean(readJSON(LS.filterCollapsed, false)) + ); + function setNavCollapsed(next: boolean) { + setNavCollapsedState(next); + writeJSON(LS.navCollapsed, next); + api.savePrefs({ navCollapsed: next }).catch(() => {}); + } + function setFilterCollapsed(next: boolean) { + setFilterCollapsedState(next); + writeJSON(LS.filterCollapsed, next); + api.savePrefs({ filterCollapsed: next }).catch(() => {}); + } + useEffect(() => applyTheme(theme), [theme]); // Apply the draft prefs locally for instant preview (their localStorage mirrors update via @@ -423,6 +443,14 @@ export default function App() { setSidebarLayoutState(l); saveLayoutLocal(l); } + if (typeof prefs.navCollapsed === "boolean") { + setNavCollapsedState(prefs.navCollapsed); + writeJSON(LS.navCollapsed, prefs.navCollapsed); + } + if (typeof prefs.filterCollapsed === "boolean") { + setFilterCollapsedState(prefs.filterCollapsed); + writeJSON(LS.filterCollapsed, prefs.filterCollapsed); + } if (isSupported(prefs.language)) setLanguage(prefs.language); // The demo account is shared: there are no subscriptions, so default it to the whole // library (the "my" feed would be empty). The communal-state warning is a permanent @@ -564,7 +592,29 @@ export default function App() { onOpenAbout={() => setAboutOpen(true)} onChangeLanguage={changeLanguage} language={i18n.language as LangCode} + collapsed={navCollapsed} + onToggleCollapse={() => setNavCollapsed(!navCollapsed)} + onGoToFullHistory={() => { + setChannelFilter("needs_full"); + setChannelsView("subscribed"); // the status filter applies to the subscriptions tab + setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows + setPage("channels"); + }} /> + {/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the + content column. Sits beside the nav rail as its own collapsible column. */} + {page === "feed" && !channelView && ( + setFilterCollapsed(!filterCollapsed)} + /> + )}
{channelView ? ( { - setChannelFilter("needs_full"); - setChannelsView("subscribed"); // the status filter applies to the subscriptions tab - setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows - setPage("channels"); - }} /> {meQuery.data!.is_demo && } openReleaseNotes(CURRENT_VERSION)} /> -
- {page === "feed" && ( - - )}
{page === "channels" ? ( )}
-
)} {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 9938a48..2dc8024 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,25 +1,22 @@ import { useTranslation } from "react-i18next"; -import { Library, Search, User, X, Youtube } from "lucide-react"; +import { Search, X, Youtube } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; -import SyncStatus from "./SyncStatus"; -// 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 -// page title), the language switcher and the notification bell. +// 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). export default function Header({ me, filters, setFilters, page, - onGoToFullHistory, onYtSearch, }: { me: Me; filters: FeedFilters; setFilters: (f: FeedFilters) => void; page: Page; - onGoToFullHistory: () => void; // Trigger a live YouTube search for the current term (hidden for the demo account, which // can't spend the shared quota). onYtSearch: (q: string) => void; @@ -30,37 +27,6 @@ export default function Header({ return (
- - - {page === "feed" && ( -
- {(["my", "all"] as const).map((s) => ( - - ))} -
- )} - {page === "feed" ? (
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 0800b6a..0518be8 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -23,11 +23,11 @@ import { import { api, type Me } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; -import { LS } from "../lib/storage"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; +import SyncStatus from "./SyncStatus"; // 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 @@ -39,6 +39,9 @@ export default function NavSidebar({ onOpenAbout, onChangeLanguage, language, + collapsed, + onToggleCollapse, + onGoToFullHistory, }: { me: Me; page: Page; @@ -46,11 +49,13 @@ export default function NavSidebar({ onOpenAbout: () => void; onChangeLanguage: (code: LangCode) => void; language: LangCode; + // Collapse state is owned by App (persisted to the user's preferences); the rail just reflects + // it and asks App to flip it. + collapsed: boolean; + onToggleCollapse: () => void; + onGoToFullHistory: () => void; }) { const { t } = useTranslation(); - const [collapsed, setCollapsed] = useState( - () => localStorage.getItem(LS.navCollapsed) === "1" - ); const [acctOpen, setAcctOpen] = useState(false); const acctBtnRef = useRef(null); const acctPanelRef = useRef(null); @@ -88,14 +93,6 @@ export default function NavSidebar({ }; }, [acctOpen]); - function toggleCollapsed() { - setCollapsed((c) => { - const next = !c; - localStorage.setItem(LS.navCollapsed, next ? "1" : "0"); - return next; - }); - } - async function logout() { await fetch("/auth/logout", { method: "POST", credentials: "include" }); location.reload(); @@ -247,7 +244,7 @@ export default function NavSidebar({ )}
+ {/* Per-user sync status (video counts + live sync state), moved out of the old top bar. */} +
+ +
+
{userItems.map(renderItem)} {systemItems.length > 0 && diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 17d2b65..c835ef6 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -4,13 +4,17 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Check, ChevronDown, + ChevronLeft, + ChevronRight, Eye, EyeOff, GripVertical, + Library, Pencil, - RefreshCw, RotateCcw, Share2, + SlidersHorizontal, + User, X, } from "lucide-react"; import { @@ -120,6 +124,8 @@ export default function Sidebar({ setLayout, onFocusChannel, isDemo = false, + collapsed, + onToggleCollapse, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; @@ -127,6 +133,10 @@ export default function Sidebar({ setLayout: (l: SidebarLayout) => void; onFocusChannel: (name: string) => void; isDemo?: boolean; + // Full-height collapse (state owned by App, persisted to the user's preferences), mirroring + // the left nav rail. + collapsed: boolean; + onToggleCollapse: () => void; }) { const { t } = useTranslation(); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); @@ -464,16 +474,81 @@ export default function Sidebar({ ? orderedAvailable : orderedAvailable.filter((id) => !layout.hidden[id]); - return ( -