- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share, usage, index, admin storage/quota) - format.ts: formatBytes / formatSpeed - i18n: en/hu/de downloads.json (trilingual) - Downloads nav module (Download icon, active-count badge, hidden for demo) placed after Messages; urlState page + App render + Header title - DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the per-user download index: downloaded / in-queue), opens the preset dialog - DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast - ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock) - DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share, admin storage dashboard + per-user quota editor - wired DownloadButton into VideoCard + PlayerModal - lib/nav.ts: decoupled navigator so the download toast can jump to the page tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library shows a real download with usage bar + actions, no console errors.
444 lines
18 KiB
TypeScript
444 lines
18 KiB
TypeScript
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Activity,
|
|
BarChart3,
|
|
Bell,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Download,
|
|
Home,
|
|
Info,
|
|
ListVideo,
|
|
LogOut,
|
|
MessageSquare,
|
|
Settings,
|
|
Shield,
|
|
SlidersHorizontal,
|
|
Users,
|
|
Tv,
|
|
UserPlus,
|
|
} from "lucide-react";
|
|
import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
|
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
|
import { getUnreadCount, subscribe } from "../lib/notifications";
|
|
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
|
|
// a thin icon-only rail. Account actions sit at the bottom in a popover.
|
|
export default function NavSidebar({
|
|
me,
|
|
page,
|
|
setPage,
|
|
onOpenAbout,
|
|
onChangeLanguage,
|
|
language,
|
|
collapsed,
|
|
onToggleCollapse,
|
|
onGoToFullHistory,
|
|
}: {
|
|
me: Me;
|
|
page: Page;
|
|
setPage: (p: Page) => void;
|
|
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 [acctOpen, setAcctOpen] = useState(false);
|
|
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
|
const acctPanelRef = useRef<HTMLDivElement | null>(null);
|
|
// Popover position (fixed, viewport coords). It's portaled to <body> so it escapes the
|
|
// nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let
|
|
// clicks fall through to the controls behind it.
|
|
const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
|
|
|
|
function toggleAccount() {
|
|
if (!acctOpen) {
|
|
const r = acctBtnRef.current?.getBoundingClientRect();
|
|
if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
|
|
}
|
|
setAcctOpen((o) => !o);
|
|
}
|
|
|
|
// Dismiss on outside click / Escape via document listeners — NOT a full-screen backdrop
|
|
// div, which (sitting between the popover and the page) would break the popover's
|
|
// backdrop-filter and make it look solid.
|
|
useEffect(() => {
|
|
if (!acctOpen) return;
|
|
function onDoc(e: MouseEvent) {
|
|
const t = e.target as Node;
|
|
if (acctPanelRef.current?.contains(t) || acctBtnRef.current?.contains(t)) return;
|
|
setAcctOpen(false);
|
|
}
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") setAcctOpen(false);
|
|
}
|
|
document.addEventListener("mousedown", onDoc);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDoc);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [acctOpen]);
|
|
|
|
async function logout() {
|
|
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
|
|
// drops this tab's override and reloads onto whatever account remains the default.
|
|
try {
|
|
await api.logout();
|
|
} catch {
|
|
/* clear locally and reload regardless */
|
|
}
|
|
clearActiveAccount();
|
|
location.reload();
|
|
}
|
|
|
|
// Accounts that have signed in on this browser (loaded only while the popover is open).
|
|
const accountsQuery = useQuery({
|
|
queryKey: ["accounts"],
|
|
queryFn: api.accounts,
|
|
enabled: acctOpen,
|
|
});
|
|
const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active);
|
|
|
|
function switchTo(id: number) {
|
|
// Per-tab switch: point THIS tab at the account and reload; other tabs keep their own
|
|
// identity. No server round-trip — req() sends the account header (validated against the
|
|
// browser wallet server-side), so the cookie's default account is left untouched.
|
|
setActiveAccount(id);
|
|
// Drop the previous account's in-module sub-view/overlay before the reload (which would
|
|
// otherwise preserve history.state across the swap). Otherwise the new account lands on a
|
|
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
|
|
// (often the new account's own id → an empty self-thread). Start at the module root instead.
|
|
const st = { ...(window.history.state || {}) };
|
|
delete st._sub;
|
|
delete st._ov;
|
|
window.history.replaceState(st, "");
|
|
location.reload(); // cleanest way to reload all per-user state for the new account
|
|
}
|
|
|
|
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
|
|
// tab is hidden); coexists with the client-side transient bell at the bottom of the rail.
|
|
const unreadQuery = useLiveQuery(
|
|
["notif-unread"],
|
|
api.notificationUnreadCount,
|
|
{ intervalMs: 30000 }
|
|
);
|
|
// One indicator for both layers: durable server notifications + the client-side transient
|
|
// events (the former separate bell is now folded into the inbox page).
|
|
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
|
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
|
|
// Direct messages are their own module with their own unread badge (demo can't message).
|
|
const msgUnreadQuery = useLiveQuery(
|
|
["message-unread"],
|
|
api.messageUnreadCount,
|
|
{ intervalMs: 30000, enabled: !me.is_demo }
|
|
);
|
|
const msgUnread = msgUnreadQuery.data?.count ?? 0;
|
|
// Download center badge = items still working (queued/running/paused). Reuses the same
|
|
// lightweight per-user index the feed cards poll, so no extra request shape.
|
|
const dlIndexQuery = useLiveQuery(["download-index"], api.downloadIndex, {
|
|
intervalMs: 30000,
|
|
enabled: !me.is_demo,
|
|
});
|
|
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
|
|
|
|
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
|
|
// 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") },
|
|
{ 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") },
|
|
]
|
|
: [];
|
|
|
|
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;
|
|
// On the active row the background is the accent colour, so a same-accent badge would be
|
|
// red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast.
|
|
const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg";
|
|
const show = !!badge && badge > 0;
|
|
return (
|
|
<button
|
|
key={p}
|
|
onClick={() => setPage(p)}
|
|
title={collapsed ? label : undefined}
|
|
aria-current={active ? "page" : undefined}
|
|
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} relative ${
|
|
active ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
|
|
}`}
|
|
>
|
|
<span className="relative shrink-0">
|
|
<Icon className="w-[18px] h-[18px]" />
|
|
{/* Collapsed rail: a numeric badge centred on a circle at the icon corner; the ring
|
|
matches the row background so it reads cleanly whether the row is active or not. */}
|
|
{show && collapsed && (
|
|
<span
|
|
className={`absolute -top-2 -right-2 min-w-[16px] h-4 px-1 rounded-full text-[10px] font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
|
|
active ? "ring-accent" : "ring-bg"
|
|
}`}
|
|
>
|
|
{badge > 9 ? "9+" : badge}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{!collapsed && <span className="truncate">{label}</span>}
|
|
{show && !collapsed && (
|
|
<span
|
|
className={`ml-auto min-w-[18px] h-[18px] px-1.5 rounded-full text-[11px] font-semibold leading-none inline-flex items-center justify-center tabular-nums ${badgeColor}`}
|
|
>
|
|
{badge > 99 ? "99+" : badge}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<nav
|
|
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${
|
|
collapsed ? "w-[56px] px-2" : "w-52 px-3"
|
|
}`}
|
|
aria-label={t("nav.primary")}
|
|
>
|
|
<div className={`flex items-center mb-3 ${collapsed ? "justify-center" : "justify-between"}`}>
|
|
{!collapsed && (
|
|
<button
|
|
onClick={() => setPage("feed")}
|
|
className="text-lg font-bold tracking-tight select-none"
|
|
title={t("header.feed")}
|
|
>
|
|
Sift<span className="text-accent">lode</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onToggleCollapse}
|
|
title={collapsed ? t("nav.expand") : t("nav.collapse")}
|
|
aria-label={collapsed ? t("nav.expand") : t("nav.collapse")}
|
|
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
|
>
|
|
{collapsed ? (
|
|
<ChevronRight className="w-4 h-4" />
|
|
) : (
|
|
<ChevronLeft className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Per-user sync status (video counts + live sync state), moved out of the old top bar. */}
|
|
<div className="mb-3 pb-3 border-b border-border">
|
|
<SyncStatus
|
|
isAdmin={me.role === "admin"}
|
|
onGoToFullHistory={onGoToFullHistory}
|
|
variant="rail"
|
|
collapsed={collapsed}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
|
|
{userItems.map(renderItem)}
|
|
{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.
|
|
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
|
|
) : (
|
|
<div className="mt-4 mb-1 px-2.5 flex items-center gap-2">
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted/70">
|
|
{t("nav.adminSection")}
|
|
</span>
|
|
<span className="flex-1 border-t border-border" />
|
|
</div>
|
|
))}
|
|
{systemItems.map(renderItem)}
|
|
</div>
|
|
|
|
<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>
|
|
</div>
|
|
<button
|
|
onClick={() => setPage("settings")}
|
|
title={collapsed ? t("header.account.settings") : undefined}
|
|
aria-current={page === "settings" ? "page" : undefined}
|
|
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
|
|
page === "settings"
|
|
? "bg-accent text-accent-fg"
|
|
: "text-muted hover:text-fg hover:bg-card"
|
|
}`}
|
|
>
|
|
<Settings className="w-[18px] h-[18px] shrink-0" />
|
|
{!collapsed && <span className="truncate">{t("header.account.settings")}</span>}
|
|
</button>
|
|
|
|
<div className="relative">
|
|
<button
|
|
ref={acctBtnRef}
|
|
onClick={toggleAccount}
|
|
title={collapsed ? name : undefined}
|
|
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
|
|
>
|
|
<span className="relative shrink-0">
|
|
<AvatarImg
|
|
src={me.avatar_url}
|
|
fallback={name}
|
|
className="w-[22px] h-[22px] rounded-full text-[10px]"
|
|
/>
|
|
{collapsed && (
|
|
<span
|
|
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full ring-2 ring-bg ${roleDotCls}`}
|
|
title={t(`nav.role.${roleKey}`)}
|
|
/>
|
|
)}
|
|
</span>
|
|
{!collapsed && <span className="truncate flex-1 text-left">{name}</span>}
|
|
{!collapsed && (
|
|
<span
|
|
className={`shrink-0 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${roleChipCls}`}
|
|
>
|
|
{t(`nav.role.${roleKey}`)}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{acctOpen &&
|
|
createPortal(
|
|
<div
|
|
ref={acctPanelRef}
|
|
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
|
|
className="glass w-60 rounded-xl p-3 z-50 animate-[popIn_0.16s_ease]"
|
|
>
|
|
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
|
<AvatarImg
|
|
src={me.avatar_url}
|
|
fallback={name}
|
|
className="w-10 h-10 rounded-full text-sm shrink-0"
|
|
/>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold truncate">{name}</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>
|
|
)}
|
|
{otherAccounts.length > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
|
|
{otherAccounts.map((a) => (
|
|
<button
|
|
key={a.id}
|
|
onClick={() => switchTo(a.id)}
|
|
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
|
|
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
|
|
>
|
|
<AvatarImg
|
|
src={a.avatar_url}
|
|
fallback={a.display_name ?? a.email}
|
|
className="w-6 h-6 rounded-full text-[10px] shrink-0"
|
|
/>
|
|
<span className="min-w-0 text-left">
|
|
<span className="block text-[13px] text-fg truncate">
|
|
{a.display_name ?? a.email.split("@")[0]}
|
|
</span>
|
|
<span className="block text-[11px] text-muted truncate">{a.email}</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="mt-2 pt-2 border-t border-border flex flex-col">
|
|
<button
|
|
onClick={() => {
|
|
// Adding an account switches THIS tab to it: drop the tab's pin so that on
|
|
// return from Google it adopts the freshly-added account (the new default)
|
|
// and pins that. Other tabs keep their own pinned account.
|
|
clearActiveAccount();
|
|
window.location.href = "/auth/login";
|
|
}}
|
|
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"
|
|
>
|
|
<UserPlus className="w-4 h-4" />
|
|
{t("header.account.addAccount")}
|
|
</button>
|
|
<button
|
|
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"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
{t("header.account.signOut")}
|
|
</button>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|