Merge improvement/sidebar-chrome-reorg: grouped rail + chrome controls in sidebar + toast relocation
This commit is contained in:
commit
9a9d700cf3
7 changed files with 202 additions and 56 deletions
|
|
@ -86,7 +86,7 @@ function loadInitialFilters(): FeedFilters {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
const [view, setView] = useState<"grid" | "list">("grid");
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
|
|
@ -235,14 +235,17 @@ export default function App() {
|
||||||
page={page}
|
page={page}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
onOpenAbout={() => setAboutOpen(true)}
|
onOpenAbout={() => setAboutOpen(true)}
|
||||||
|
onChangeLanguage={changeLanguage}
|
||||||
|
language={i18n.language as LangCode}
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="relative flex-1 min-w-0 flex flex-col">
|
||||||
<Header
|
<Header
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
page={page}
|
page={page}
|
||||||
onChangeLanguage={changeLanguage}
|
|
||||||
onGoToFullHistory={() => {
|
onGoToFullHistory={() => {
|
||||||
setChannelFilter("needs_full");
|
setChannelFilter("needs_full");
|
||||||
setPage("channels");
|
setPage("channels");
|
||||||
|
|
@ -298,6 +301,10 @@ export default function App() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 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. */}
|
||||||
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
||||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||||
|
|
@ -314,7 +321,6 @@ export default function App() {
|
||||||
{notesOpen && (
|
{notesOpen && (
|
||||||
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
||||||
)}
|
)}
|
||||||
<Toaster />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Library, Search, User } from "lucide-react";
|
import { Library, Search, User } 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 { type LangCode } from "../i18n";
|
|
||||||
import SyncStatus from "./SyncStatus";
|
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
|
// 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
|
// header carries the global sync status, the feed's scope toggle + search (or the current
|
||||||
|
|
@ -15,17 +12,15 @@ export default function Header({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
page,
|
page,
|
||||||
onChangeLanguage,
|
|
||||||
onGoToFullHistory,
|
onGoToFullHistory,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
page: Page;
|
page: Page;
|
||||||
onChangeLanguage: (code: LangCode) => void;
|
|
||||||
onGoToFullHistory: () => void;
|
onGoToFullHistory: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
||||||
|
|
@ -83,11 +78,6 @@ export default function Header({
|
||||||
: t("header.channelManager")}
|
: t("header.channelManager")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<LanguageSwitcher value={i18n.language as LangCode} onChange={onChangeLanguage} />
|
|
||||||
<NotificationCenter filters={filters} setFilters={setFilters} />
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { Check, Globe } from "lucide-react";
|
||||||
import { LANGUAGES, type LangCode } from "../i18n";
|
import { LANGUAGES, type LangCode } from "../i18n";
|
||||||
|
|
||||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||||
// changing the language does (set it; persist server-side when signed in).
|
// 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 <body> 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({
|
export default function LanguageSwitcher({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
align = "right",
|
align = "right",
|
||||||
|
variant = "header",
|
||||||
}: {
|
}: {
|
||||||
value: LangCode;
|
value: LangCode;
|
||||||
onChange: (code: LangCode) => void;
|
onChange: (code: LangCode) => void;
|
||||||
align?: "left" | "right";
|
align?: "left" | "right";
|
||||||
|
variant?: "header" | "rail";
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
|
||||||
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
|
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
|
||||||
|
|
||||||
function openNow() {
|
function openNow() {
|
||||||
|
|
@ -24,6 +35,77 @@ export default function LanguageSwitcher({
|
||||||
function closeSoon() {
|
function closeSoon() {
|
||||||
closeTimer.current = setTimeout(() => setOpen(false), 200);
|
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 = (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||||
|
>
|
||||||
|
{LANGUAGES.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.code}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(l.code);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
|
>
|
||||||
|
<span>{l.label}</span>
|
||||||
|
{l.code === value && <Check className="w-4 h-4 text-accent" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === "rail") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
onClick={toggleRail}
|
||||||
|
title={current.label}
|
||||||
|
aria-label={current.label}
|
||||||
|
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
|
>
|
||||||
|
<Globe className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}>
|
||||||
|
{menu}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
|
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,12 @@ import {
|
||||||
Tv,
|
Tv,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from "lucide-react";
|
} 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 { Page } from "../lib/urlState";
|
||||||
|
import { type LangCode } from "../i18n";
|
||||||
import AvatarImg from "./Avatar";
|
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
|
// 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
|
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
|
||||||
|
|
@ -28,11 +31,19 @@ export default function NavSidebar({
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
onOpenAbout,
|
onOpenAbout,
|
||||||
|
onChangeLanguage,
|
||||||
|
language,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
page: Page;
|
page: Page;
|
||||||
setPage: (p: Page) => void;
|
setPage: (p: Page) => void;
|
||||||
onOpenAbout: () => void;
|
onOpenAbout: () => void;
|
||||||
|
onChangeLanguage: (code: LangCode) => void;
|
||||||
|
language: LangCode;
|
||||||
|
filters: FeedFilters;
|
||||||
|
setFilters: (f: FeedFilters) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(
|
const [collapsed, setCollapsed] = useState<boolean>(
|
||||||
|
|
@ -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: "feed", icon: Home, label: t("header.account.feed") },
|
||||||
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
||||||
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
||||||
];
|
];
|
||||||
if (me.role === "admin") {
|
const systemItems: NavItem[] =
|
||||||
items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") });
|
me.role === "admin"
|
||||||
items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") });
|
? [
|
||||||
}
|
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
||||||
|
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
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";
|
||||||
const name = me.display_name ?? me.email.split("@")[0];
|
const name = me.display_name ?? me.email.split("@")[0];
|
||||||
|
|
||||||
|
const renderItem = ({ page: p, icon: Icon, label }: NavItem) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
title={collapsed ? label : undefined}
|
||||||
|
aria-current={page === p ? "page" : undefined}
|
||||||
|
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
|
||||||
|
page === p ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-[18px] h-[18px] shrink-0" />
|
||||||
|
{!collapsed && <span className="truncate">{label}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${
|
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${
|
||||||
|
|
@ -151,25 +182,30 @@ export default function NavSidebar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
|
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
|
||||||
{items.map(({ page: p, icon: Icon, label }) => (
|
{userItems.map(renderItem)}
|
||||||
<button
|
{systemItems.length > 0 && (
|
||||||
key={p}
|
<div className="my-1.5 border-t border-border/70" />
|
||||||
onClick={() => setPage(p)}
|
)}
|
||||||
title={collapsed ? label : undefined}
|
{systemItems.map(renderItem)}
|
||||||
aria-current={page === p ? "page" : undefined}
|
|
||||||
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
|
|
||||||
page === p
|
|
||||||
? "bg-accent text-accent-fg"
|
|
||||||
: "text-muted hover:text-fg hover:bg-card"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-[18px] h-[18px] shrink-0" />
|
|
||||||
{!collapsed && <span className="truncate">{label}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${
|
||||||
|
collapsed ? "flex-col" : "flex-row"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
|
||||||
|
<button
|
||||||
|
onClick={onOpenAbout}
|
||||||
|
title={t("header.account.about")}
|
||||||
|
aria-label={t("header.account.about")}
|
||||||
|
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
|
>
|
||||||
|
<Info className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<NotificationCenter variant="rail" filters={filters} setFilters={setFilters} />
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage("settings")}
|
onClick={() => setPage("settings")}
|
||||||
title={collapsed ? t("header.account.settings") : undefined}
|
title={collapsed ? t("header.account.settings") : undefined}
|
||||||
|
|
@ -257,16 +293,6 @@ export default function NavSidebar({
|
||||||
<UserPlus className="w-4 h-4" />
|
<UserPlus className="w-4 h-4" />
|
||||||
{t("header.account.addAccount")}
|
{t("header.account.addAccount")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onOpenAbout();
|
|
||||||
setAcctOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
|
||||||
>
|
|
||||||
<Info className="w-4 h-4" />
|
|
||||||
{t("header.account.about")}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -31,26 +32,44 @@ function relTime(ts: number, t: TFunction): string {
|
||||||
export default function NotificationCenter({
|
export default function NotificationCenter({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
|
variant = "header",
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
|
variant?: "header" | "rail";
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
|
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
|
||||||
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const wrap = useRef<HTMLDivElement>(null);
|
const wrap = useRef<HTMLDivElement>(null);
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Rail variant anchors the panel right + above the button (fixed, viewport coords) and
|
||||||
|
// portals it to <body> so it escapes the nav's backdrop-filter.
|
||||||
|
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
// Close when clicking anywhere outside the panel.
|
// Close when clicking anywhere outside the button + panel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
function onDown(e: MouseEvent) {
|
function onDown(e: MouseEvent) {
|
||||||
if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
|
const target = e.target as Node;
|
||||||
|
if (btnRef.current?.contains(target) || panelRef.current?.contains(target)) return;
|
||||||
|
if (variant === "header" && wrap.current?.contains(target)) return;
|
||||||
|
setOpen(false);
|
||||||
}
|
}
|
||||||
document.addEventListener("mousedown", onDown);
|
document.addEventListener("mousedown", onDown);
|
||||||
return () => document.removeEventListener("mousedown", onDown);
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
}, [open]);
|
}, [open, variant]);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (!open && variant === "rail") {
|
||||||
|
const r = btnRef.current?.getBoundingClientRect();
|
||||||
|
if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
|
||||||
|
}
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
|
||||||
// Opening the center marks everything read.
|
// Opening the center marks everything read.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -81,10 +100,21 @@ export default function NotificationCenter({
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPanel(el: JSX.Element) {
|
||||||
|
if (variant !== "rail") return el;
|
||||||
|
return createPortal(
|
||||||
|
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}>
|
||||||
|
{el}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={wrap}>
|
<div className="relative" ref={wrap}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((o) => !o)}
|
ref={btnRef}
|
||||||
|
onClick={toggle}
|
||||||
title={t("notifications.title")}
|
title={t("notifications.title")}
|
||||||
className="relative 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"
|
||||||
>
|
>
|
||||||
|
|
@ -96,8 +126,14 @@ export default function NotificationCenter({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open &&
|
||||||
<div className="glass-menu absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl z-30 flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease]">
|
renderPanel(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className={`glass-menu w-80 max-w-[calc(100vw-2rem)] rounded-xl flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease] ${
|
||||||
|
variant === "rail" ? "" : "absolute right-0 mt-2 z-30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||||
<div className="text-sm font-semibold">{t("notifications.title")}</div>
|
<div className="text-sm font-semibold">{t("notifications.title")}</div>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,13 @@ export default function Toaster() {
|
||||||
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
|
<div className="absolute bottom-4 left-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
|
||||||
{toasts.map((toast) => {
|
{toasts.map((toast) => {
|
||||||
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
className="glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||||
>
|
>
|
||||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@ html[data-theme="dark"] .glass-menu {
|
||||||
backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
|
backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
|
||||||
-webkit-backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
|
-webkit-backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
|
||||||
}
|
}
|
||||||
|
/* Toasts surface bottom-left (by the nav's notification bell), off the conventional
|
||||||
|
top-right; a brighter rim draws the eye there. Most needed in dark mode, where the
|
||||||
|
default glass border (dark navy) all but vanishes against the backdrop. */
|
||||||
|
html[data-theme="dark"] .toast-card {
|
||||||
|
border-color: color-mix(in srgb, #fff 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
|
/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
|
||||||
html[data-perf="1"] .glass,
|
html[data-perf="1"] .glass,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue