The post-delete auto-select effect re-picked the just-deleted id from the still-stale playlists cache, so its detail lingered. Select the next remaining playlist directly on delete (or null), drop the deleted detail from the cache, and make the effect re-validate the selection against the current list (clear when empty, reselect when the current id is gone). Also fix the header title showing "Channel manager" on the Playlists page.
230 lines
7.7 KiB
TypeScript
230 lines
7.7 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { BarChart3, Home, Info, Library, ListVideo, LogOut, Search, Settings, Shield, Tv, 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";
|
|
import AvatarImg from "./Avatar";
|
|
|
|
export default function Header({
|
|
me,
|
|
filters,
|
|
setFilters,
|
|
page,
|
|
setPage,
|
|
onOpenSettings,
|
|
onOpenAbout,
|
|
onChangeLanguage,
|
|
onGoToFullHistory,
|
|
}: {
|
|
me: Me;
|
|
filters: FeedFilters;
|
|
setFilters: (f: FeedFilters) => void;
|
|
page: Page;
|
|
setPage: (p: Page) => void;
|
|
onOpenSettings: () => void;
|
|
onOpenAbout: () => void;
|
|
onChangeLanguage: (code: LangCode) => void;
|
|
onGoToFullHistory: () => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
|
|
async function logout() {
|
|
await fetch("/auth/logout", { method: "POST", credentials: "include" });
|
|
location.reload();
|
|
}
|
|
|
|
return (
|
|
<header className="h-14 shrink-0 border-b border-border bg-surface/95 flex items-center gap-3 px-4 z-20">
|
|
<button
|
|
onClick={() => setPage("feed")}
|
|
className="text-xl font-bold tracking-tight select-none"
|
|
title={t("header.feed")}
|
|
>
|
|
Sift<span className="text-accent">lode</span>
|
|
</button>
|
|
|
|
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
|
|
|
{page === "feed" && (
|
|
<div
|
|
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs shrink-0"
|
|
role="group"
|
|
aria-label={t("header.scope.label")}
|
|
>
|
|
{(["my", "all"] as const).map((s) => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setFilters({ ...filters, scope: s })}
|
|
title={t("header.scope." + s + "Tip")}
|
|
aria-pressed={filters.scope === s}
|
|
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full transition ${
|
|
filters.scope === s
|
|
? "bg-accent text-accent-fg"
|
|
: "text-muted hover:text-fg"
|
|
}`}
|
|
>
|
|
{s === "my" ? (
|
|
<User className="w-3.5 h-3.5" />
|
|
) : (
|
|
<Library className="w-3.5 h-3.5" />
|
|
)}
|
|
<span className="hidden sm:inline">{t("header.scope." + s)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{page === "feed" ? (
|
|
<div className="flex-1 max-w-xl mx-auto relative">
|
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
|
<input
|
|
value={filters.q}
|
|
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
|
|
placeholder={t("header.searchPlaceholder")}
|
|
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 text-center text-sm font-semibold">
|
|
{page === "stats"
|
|
? t("header.usageStats")
|
|
: page === "playlists"
|
|
? t("header.account.playlists")
|
|
: t("header.channelManager")}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-1">
|
|
<LanguageSwitcher value={i18n.language as LangCode} onChange={onChangeLanguage} />
|
|
<NotificationCenter filters={filters} setFilters={setFilters} />
|
|
<div className="pl-1">
|
|
<AccountMenu
|
|
me={me}
|
|
logout={logout}
|
|
page={page}
|
|
setPage={setPage}
|
|
onOpenSettings={onOpenSettings}
|
|
onOpenAbout={onOpenAbout}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function Avatar({ me, className = "" }: { me: Me; className?: string }) {
|
|
return (
|
|
<AvatarImg
|
|
src={me.avatar_url}
|
|
fallback={me.display_name ?? me.email}
|
|
className={`rounded-full ${className}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function AccountMenu({
|
|
me,
|
|
logout,
|
|
page,
|
|
setPage,
|
|
onOpenSettings,
|
|
onOpenAbout,
|
|
}: {
|
|
me: Me;
|
|
logout: () => void;
|
|
page: Page;
|
|
setPage: (p: Page) => void;
|
|
onOpenSettings: () => void;
|
|
onOpenAbout: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
function openNow() {
|
|
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
setOpen(true);
|
|
}
|
|
function closeSoon() {
|
|
closeTimer.current = setTimeout(() => setOpen(false), 220);
|
|
}
|
|
|
|
const itemClass =
|
|
"w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition";
|
|
|
|
return (
|
|
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
|
|
<button
|
|
onClick={() => setOpen((o) => !o)}
|
|
title={me.email}
|
|
className="block rounded-full ring-2 ring-transparent hover:ring-border focus:outline-none focus:ring-accent transition"
|
|
>
|
|
<Avatar me={me} className="w-8 h-8 text-xs" />
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="glass absolute right-0 mt-2 w-64 rounded-xl p-3 z-30 animate-[popIn_0.16s_ease]">
|
|
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
|
<Avatar me={me} className="w-10 h-10 text-sm shrink-0" />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold truncate">
|
|
{me.display_name ?? me.email.split("@")[0]}
|
|
</div>
|
|
<div className="text-xs text-muted truncate">{me.email}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{me.role === "admin" && (
|
|
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
|
|
<Shield className="w-3.5 h-3.5" />
|
|
{t("header.account.admin")}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-2 flex flex-col">
|
|
{page !== "feed" && (
|
|
<button onClick={() => { setPage("feed"); setOpen(false); }} className={itemClass}>
|
|
<Home className="w-4 h-4" />
|
|
{t("header.account.feed")}
|
|
</button>
|
|
)}
|
|
{page !== "channels" && (
|
|
<button onClick={() => { setPage("channels"); setOpen(false); }} className={itemClass}>
|
|
<Tv className="w-4 h-4" />
|
|
{t("header.account.channels")}
|
|
</button>
|
|
)}
|
|
{page !== "playlists" && (
|
|
<button onClick={() => { setPage("playlists"); setOpen(false); }} className={itemClass}>
|
|
<ListVideo className="w-4 h-4" />
|
|
{t("header.account.playlists")}
|
|
</button>
|
|
)}
|
|
{me.role === "admin" && page !== "stats" && (
|
|
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
|
|
<BarChart3 className="w-4 h-4" />
|
|
{t("header.account.stats")}
|
|
</button>
|
|
)}
|
|
<button onClick={() => { onOpenSettings(); setOpen(false); }} className={itemClass}>
|
|
<Settings className="w-4 h-4" />
|
|
{t("header.account.settings")}
|
|
</button>
|
|
<button onClick={() => { onOpenAbout(); setOpen(false); }} className={itemClass}>
|
|
<Info className="w-4 h-4" />
|
|
{t("header.account.about")}
|
|
</button>
|
|
<button onClick={logout} className={itemClass}>
|
|
<LogOut className="w-4 h-4" />
|
|
{t("header.account.signOut")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|