merge: M4 polish — sidebar widgets, URL state, notification center, account menu
Account hover-popup, upload-date chips, top clear-filters bar, filters reflected in the URL, a customizable (collapsible/reorderable/toggleable) sidebar, and a client-side notification center with leveled toasts, countdown bars and in-app hide/unhide actions.
This commit is contained in:
commit
1630a3d8c9
16 changed files with 4004 additions and 240 deletions
2826
frontend/package-lock.json
generated
Normal file
2826
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,9 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.408.0",
|
"lucide-react": "^0.408.0",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ import {
|
||||||
saveLocalTheme,
|
saveLocalTheme,
|
||||||
type ThemePrefs,
|
type ThemePrefs,
|
||||||
} from "./lib/theme";
|
} from "./lib/theme";
|
||||||
|
import { hasFilterParams, paramsToFilters, syncUrl } from "./lib/urlState";
|
||||||
|
import {
|
||||||
|
loadLayout,
|
||||||
|
normalizeLayout,
|
||||||
|
saveLayoutLocal,
|
||||||
|
type SidebarLayout,
|
||||||
|
} from "./lib/sidebarLayout";
|
||||||
import Login from "./components/Login";
|
import Login from "./components/Login";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
|
|
@ -27,7 +34,7 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||||
|
|
||||||
const FILTERS_KEY = "subfeed.filters";
|
const FILTERS_KEY = "subfeed.filters";
|
||||||
|
|
||||||
function loadFilters(): FeedFilters {
|
function loadStoredFilters(): FeedFilters {
|
||||||
try {
|
try {
|
||||||
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
|
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -35,18 +42,37 @@ function loadFilters(): FeedFilters {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URL wins over localStorage so a pasted link reproduces the exact view.
|
||||||
|
function loadInitialFilters(): FeedFilters {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
|
||||||
|
return loadStoredFilters();
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
const [view, setView] = useState<"grid" | "list">("grid");
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
|
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||||
|
|
||||||
function setFilters(next: FeedFilters) {
|
function setFilters(next: FeedFilters) {
|
||||||
setFiltersState(next);
|
setFiltersState(next);
|
||||||
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
||||||
|
syncUrl(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSidebarLayout(next: SidebarLayout) {
|
||||||
|
setSidebarLayoutState(next);
|
||||||
|
saveLayoutLocal(next);
|
||||||
|
api.savePrefs({ sidebarLayout: next }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => applyTheme(theme), [theme]);
|
useEffect(() => applyTheme(theme), [theme]);
|
||||||
|
|
||||||
|
// Reflect the initial filters (from localStorage or URL) into the address bar
|
||||||
|
// so the URL is always shareable, even before the first filter change.
|
||||||
|
useEffect(() => syncUrl(filters), []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
||||||
|
|
||||||
// On login, adopt server-stored preferences.
|
// On login, adopt server-stored preferences.
|
||||||
|
|
@ -59,6 +85,11 @@ export default function App() {
|
||||||
saveLocalTheme(merged);
|
saveLocalTheme(merged);
|
||||||
}
|
}
|
||||||
if (prefs.view === "grid" || prefs.view === "list") setView(prefs.view);
|
if (prefs.view === "grid" || prefs.view === "list") setView(prefs.view);
|
||||||
|
if (prefs.sidebarLayout) {
|
||||||
|
const l = normalizeLayout(prefs.sidebarLayout);
|
||||||
|
setSidebarLayoutState(l);
|
||||||
|
saveLayoutLocal(l);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [meQuery.data?.id]);
|
}, [meQuery.data?.id]);
|
||||||
|
|
||||||
|
|
@ -95,7 +126,12 @@ export default function App() {
|
||||||
setView={changeView}
|
setView={changeView}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<Sidebar filters={filters} setFilters={setFilters} />
|
<Sidebar
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
layout={sidebarLayout}
|
||||||
|
setLayout={setSidebarLayout}
|
||||||
|
/>
|
||||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||||
<Feed filters={filters} setFilters={setFilters} view={view} />
|
<Feed filters={filters} setFilters={setFilters} view={view} />
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
48
frontend/src/components/ErrorBoundary.tsx
Normal file
48
frontend/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
interface State {
|
||||||
|
crashed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catches render-time crashes anywhere in the tree, logs a fatal notification (kept
|
||||||
|
// in the center across the reload) and offers a recovery action.
|
||||||
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { crashed: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): State {
|
||||||
|
return { crashed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error) {
|
||||||
|
notify({
|
||||||
|
level: "fatal",
|
||||||
|
title: "Something broke",
|
||||||
|
message: error.message || "Unexpected error",
|
||||||
|
requiresInteraction: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.crashed) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-fg mb-1">Something went wrong.</div>
|
||||||
|
<div className="text-sm mb-3">The app hit an unexpected error.</div>
|
||||||
|
<button
|
||||||
|
onClick={() => location.reload()}
|
||||||
|
className="text-accent hover:underline text-sm font-semibold"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, type FeedFilters, type Video } from "../lib/api";
|
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||||
import { toast } from "../lib/toast";
|
import { notify } from "../lib/notifications";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
|
|
||||||
const PAGE = 60;
|
const PAGE = 60;
|
||||||
|
|
@ -73,7 +73,18 @@ export default function Feed({
|
||||||
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
|
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
if (status === "hidden") {
|
if (status === "hidden") {
|
||||||
toast("Video hidden", { label: "Undo", onClick: () => onState(id, "new") });
|
const v = (query.data?.pages ?? []).flatMap((p) => p.items).find((x) => x.id === id);
|
||||||
|
notify({
|
||||||
|
message: v?.title ? `Hidden “${v.title}”` : "Video hidden",
|
||||||
|
action: { label: "Undo", onClick: () => onState(id, "new") },
|
||||||
|
meta: {
|
||||||
|
kind: "video-hidden",
|
||||||
|
videoId: id,
|
||||||
|
title: v?.title ?? "this video",
|
||||||
|
channelId: v?.channel_id ?? "",
|
||||||
|
channelName: v?.channel_title ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
|
|
@ -6,11 +6,13 @@ import {
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
Search,
|
Search,
|
||||||
|
Shield,
|
||||||
Sun,
|
Sun,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import type { FeedFilters, Me } from "../lib/api";
|
import type { FeedFilters, Me } from "../lib/api";
|
||||||
import SyncStatus from "./SyncStatus";
|
import SyncStatus from "./SyncStatus";
|
||||||
|
import NotificationCenter from "./NotificationCenter";
|
||||||
|
|
||||||
function IconBtn(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
function IconBtn(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
const { className = "", ...rest } = props;
|
const { className = "", ...rest } = props;
|
||||||
|
|
@ -129,19 +131,80 @@ export default function Header({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pl-2">
|
<NotificationCenter filters={filters} setFilters={setFilters} />
|
||||||
{me.avatar_url ? (
|
|
||||||
<img src={me.avatar_url} alt="" className="w-8 h-8 rounded-full" />
|
<div className="pl-1">
|
||||||
) : (
|
<AccountMenu me={me} logout={logout} />
|
||||||
<div className="w-8 h-8 rounded-full bg-card grid place-items-center text-xs">
|
|
||||||
{me.email[0]?.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<IconBtn onClick={logout} title="Sign out">
|
|
||||||
<LogOut className="w-5 h-5" />
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Avatar({ me, className = "" }: { me: Me; className?: string }) {
|
||||||
|
return me.avatar_url ? (
|
||||||
|
<img src={me.avatar_url} alt="" className={`rounded-full ${className}`} />
|
||||||
|
) : (
|
||||||
|
<div className={`rounded-full bg-card grid place-items-center font-semibold ${className}`}>
|
||||||
|
{(me.display_name?.[0] ?? me.email[0] ?? "?").toUpperCase()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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="absolute right-0 mt-2 w-64 rounded-xl border border-border bg-surface shadow-2xl p-3 z-30">
|
||||||
|
<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" />
|
||||||
|
Admin
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="mt-2 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" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
177
frontend/src/components/NotificationCenter.tsx
Normal file
177
frontend/src/components/NotificationCenter.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Bell, Eye, Search, Trash2 } from "lucide-react";
|
||||||
|
import { api, type FeedFilters } from "../lib/api";
|
||||||
|
import {
|
||||||
|
clearAll,
|
||||||
|
dismiss,
|
||||||
|
getNotifications,
|
||||||
|
getUnreadCount,
|
||||||
|
markAllRead,
|
||||||
|
notify,
|
||||||
|
subscribe,
|
||||||
|
type NotifMeta,
|
||||||
|
} from "../lib/notifications";
|
||||||
|
import { LEVEL_STYLE } from "./Toaster";
|
||||||
|
|
||||||
|
function relTime(ts: number): string {
|
||||||
|
const s = Math.round((Date.now() - ts) / 1000);
|
||||||
|
if (s < 60) return "just now";
|
||||||
|
const m = Math.round(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.round(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.round(h / 24);
|
||||||
|
return `${d}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationCenter({
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
}: {
|
||||||
|
filters: FeedFilters;
|
||||||
|
setFilters: (f: FeedFilters) => void;
|
||||||
|
}) {
|
||||||
|
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
|
||||||
|
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrap = useRef<HTMLDivElement>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
// Close when clicking anywhere outside the panel.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onDown(e: MouseEvent) {
|
||||||
|
if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Opening the center marks everything read.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) markAllRead();
|
||||||
|
}, [open, notifications.length]);
|
||||||
|
|
||||||
|
function locateHidden(meta: NotifMeta) {
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
show: "hidden",
|
||||||
|
channelId: meta.channelId || undefined,
|
||||||
|
channelName: meta.channelName || undefined,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unhide(meta: NotifMeta) {
|
||||||
|
api
|
||||||
|
.setState(meta.videoId, "new")
|
||||||
|
.then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed-count"] });
|
||||||
|
notify({ level: "success", message: `Unhidden “${meta.title}”` });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={wrap}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
title="Notifications"
|
||||||
|
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold grid place-items-center">
|
||||||
|
{unread > 9 ? "9+" : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-border bg-surface shadow-2xl z-30 flex flex-col max-h-[70vh]">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||||
|
<div className="text-sm font-semibold">Notifications</div>
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted hover:text-accent transition"
|
||||||
|
title="Clear all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="px-3 py-8 text-center text-sm text-muted">No notifications yet.</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((n) => {
|
||||||
|
const { icon: Icon, color } = LEVEL_STYLE[n.level];
|
||||||
|
const needsAction = n.requiresInteraction && !n.dismissed;
|
||||||
|
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className={`flex items-start gap-2.5 px-3 py-2.5 border-b border-border/60 ${
|
||||||
|
needsAction ? "bg-accent/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
|
||||||
|
<div className="text-sm break-words">{n.message}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-[11px] text-muted">{relTime(n.ts)}</span>
|
||||||
|
{needsAction && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
|
||||||
|
Action needed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hidden ? (
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => locateHidden(hidden)}
|
||||||
|
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5" />
|
||||||
|
Find in feed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => unhide(hidden)}
|
||||||
|
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
Unhide
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
n.action &&
|
||||||
|
!n.dismissed && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
n.action!.onClick();
|
||||||
|
dismiss(n.id);
|
||||||
|
}}
|
||||||
|
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
{n.action.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,37 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { X } from "lucide-react";
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
GripVertical,
|
||||||
|
Pencil,
|
||||||
|
RotateCcw,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { api, type FeedFilters, type Tag } from "../lib/api";
|
import { api, type FeedFilters, type Tag } from "../lib/api";
|
||||||
|
import {
|
||||||
|
DEFAULT_LAYOUT,
|
||||||
|
WIDGET_TITLES,
|
||||||
|
type SidebarLayout,
|
||||||
|
type WidgetId,
|
||||||
|
} from "../lib/sidebarLayout";
|
||||||
|
|
||||||
const SORTS = [
|
const SORTS = [
|
||||||
{ id: "newest", label: "Newest" },
|
{ id: "newest", label: "Newest" },
|
||||||
|
|
@ -21,6 +52,25 @@ const SHOWS = [
|
||||||
{ id: "hidden", label: "Hidden" },
|
{ id: "hidden", label: "Hidden" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DATE_PRESETS: { days: number; label: string }[] = [
|
||||||
|
{ days: 1, label: "24h" },
|
||||||
|
{ days: 7, label: "1 week" },
|
||||||
|
{ days: 30, label: "1 month" },
|
||||||
|
{ days: 180, label: "6 months" },
|
||||||
|
{ days: 365, label: "1 year" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter values owned by the sidebar (everything except the header search `q`).
|
||||||
|
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q"> = {
|
||||||
|
tags: [],
|
||||||
|
tagMode: "or",
|
||||||
|
sort: "newest",
|
||||||
|
includeNormal: true,
|
||||||
|
includeShorts: false,
|
||||||
|
includeLive: false,
|
||||||
|
show: "unwatched",
|
||||||
|
};
|
||||||
|
|
||||||
function TagChip({
|
function TagChip({
|
||||||
tag,
|
tag,
|
||||||
active,
|
active,
|
||||||
|
|
@ -48,14 +98,24 @@ function TagChip({
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
|
layout,
|
||||||
|
setLayout,
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
|
layout: SidebarLayout;
|
||||||
|
setLayout: (l: SidebarLayout) => void;
|
||||||
}) {
|
}) {
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
const tags = tagsQuery.data ?? [];
|
const tags = tagsQuery.data ?? [];
|
||||||
const languages = tags.filter((t) => t.category === "language");
|
const languages = tags.filter((t) => t.category === "language");
|
||||||
const topics = tags.filter((t) => t.category === "topic");
|
const topics = tags.filter((t) => t.category === "topic");
|
||||||
|
const [customDates, setCustomDates] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
|
||||||
|
);
|
||||||
|
|
||||||
function toggleTag(id: number) {
|
function toggleTag(id: number) {
|
||||||
const has = filters.tags.includes(id);
|
const has = filters.tags.includes(id);
|
||||||
|
|
@ -65,118 +125,192 @@ export default function Sidebar({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const active =
|
const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo;
|
||||||
filters.tags.length > 0 ||
|
const contentChanged =
|
||||||
!filters.includeNormal ||
|
!filters.includeNormal || filters.includeShorts || filters.includeLive;
|
||||||
filters.includeShorts ||
|
const activeCount =
|
||||||
filters.includeLive ||
|
filters.tags.length +
|
||||||
filters.show !== "unwatched" ||
|
(filters.show !== "unwatched" ? 1 : 0) +
|
||||||
filters.sort !== "newest" ||
|
(filters.sort !== "newest" ? 1 : 0) +
|
||||||
!!filters.channelId ||
|
(contentChanged ? 1 : 0) +
|
||||||
!!filters.dateFrom ||
|
(filters.channelId ? 1 : 0) +
|
||||||
!!filters.dateTo;
|
(dateActive ? 1 : 0);
|
||||||
|
const active = activeCount > 0;
|
||||||
|
|
||||||
return (
|
function clearAll() {
|
||||||
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-5 hidden md:block">
|
setCustomDates(false);
|
||||||
{filters.channelId && (
|
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q });
|
||||||
<Section title="Channel">
|
}
|
||||||
<button
|
|
||||||
onClick={() => setFilters({ ...filters, channelId: undefined, channelName: undefined })}
|
const available: Record<WidgetId, boolean> = {
|
||||||
className="w-full flex items-center justify-between gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
|
show: true,
|
||||||
|
sort: true,
|
||||||
|
date: true,
|
||||||
|
content: true,
|
||||||
|
language: languages.length > 0,
|
||||||
|
topic: topics.length > 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function toggleCollapse(id: WidgetId) {
|
||||||
|
setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } });
|
||||||
|
}
|
||||||
|
function toggleHidden(id: WidgetId) {
|
||||||
|
setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } });
|
||||||
|
}
|
||||||
|
function onDragEnd(e: DragEndEvent) {
|
||||||
|
const { active: a, over } = e;
|
||||||
|
if (!over || a.id === over.id) return;
|
||||||
|
const oldIndex = layout.order.indexOf(a.id as WidgetId);
|
||||||
|
const newIndex = layout.order.indexOf(over.id as WidgetId);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
|
setLayout({ ...layout, order: arrayMove(layout.order, oldIndex, newIndex) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function widgetBody(id: WidgetId): React.ReactNode {
|
||||||
|
switch (id) {
|
||||||
|
case "show":
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{SHOWS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setFilters({ ...filters, show: s.id })}
|
||||||
|
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
|
||||||
|
filters.show === s.id
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "sort":
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={filters.sort}
|
||||||
|
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
|
||||||
|
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
>
|
>
|
||||||
<span className="truncate">{filters.channelName ?? "This channel"}</span>
|
{SORTS.map((s) => (
|
||||||
<X className="w-4 h-4 shrink-0" />
|
<option key={s.id} value={s.id}>
|
||||||
</button>
|
{s.label}
|
||||||
</Section>
|
</option>
|
||||||
)}
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{DATE_PRESETS.map((p) => {
|
||||||
|
const activePreset = filters.maxAgeDays === p.days && !customDates;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.days}
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
maxAgeDays: activePreset ? undefined : p.days,
|
||||||
|
dateFrom: undefined,
|
||||||
|
dateTo: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
|
||||||
|
activePreset
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = !customDates;
|
||||||
|
setCustomDates(next);
|
||||||
|
if (next) setFilters({ ...filters, maxAgeDays: undefined });
|
||||||
|
}}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
|
||||||
|
customDates || filters.dateFrom || filters.dateTo
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Section title="Show">
|
{(customDates || filters.dateFrom || filters.dateTo) && (
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="flex flex-col gap-1.5 mt-2">
|
||||||
{SHOWS.map((s) => (
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
<button
|
<span className="text-muted">From</span>
|
||||||
key={s.id}
|
<input
|
||||||
onClick={() => setFilters({ ...filters, show: s.id })}
|
type="date"
|
||||||
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
|
value={filters.dateFrom ?? ""}
|
||||||
filters.show === s.id
|
onChange={(e) =>
|
||||||
? "bg-accent text-accent-fg border-accent"
|
setFilters({
|
||||||
: "bg-card border-border hover:border-accent"
|
...filters,
|
||||||
}`}
|
dateFrom: e.target.value || undefined,
|
||||||
>
|
maxAgeDays: undefined,
|
||||||
{s.label}
|
})
|
||||||
</button>
|
}
|
||||||
))}
|
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
</div>
|
/>
|
||||||
</Section>
|
</label>
|
||||||
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
<Section title="Sort">
|
<span className="text-muted">To</span>
|
||||||
<select
|
<input
|
||||||
value={filters.sort}
|
type="date"
|
||||||
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
|
value={filters.dateTo ?? ""}
|
||||||
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
onChange={(e) =>
|
||||||
>
|
setFilters({
|
||||||
{SORTS.map((s) => (
|
...filters,
|
||||||
<option key={s.id} value={s.id}>
|
dateTo: e.target.value || undefined,
|
||||||
{s.label}
|
maxAgeDays: undefined,
|
||||||
</option>
|
})
|
||||||
))}
|
}
|
||||||
</select>
|
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
</Section>
|
/>
|
||||||
|
</label>
|
||||||
<Section title="Upload date">
|
{(filters.dateFrom || filters.dateTo) && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<button
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
onClick={() =>
|
||||||
<span className="text-muted">From</span>
|
setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })
|
||||||
<input
|
}
|
||||||
type="date"
|
className="text-[11px] text-muted hover:text-accent self-end"
|
||||||
value={filters.dateFrom ?? ""}
|
>
|
||||||
onChange={(e) =>
|
clear dates
|
||||||
setFilters({ ...filters, dateFrom: e.target.value || undefined })
|
</button>
|
||||||
}
|
)}
|
||||||
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "content":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toggle
|
||||||
|
label="Normal"
|
||||||
|
checked={filters.includeNormal}
|
||||||
|
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
|
||||||
/>
|
/>
|
||||||
</label>
|
<Toggle
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
label="Shorts"
|
||||||
<span className="text-muted">To</span>
|
checked={filters.includeShorts}
|
||||||
<input
|
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
||||||
type="date"
|
|
||||||
value={filters.dateTo ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilters({ ...filters, dateTo: e.target.value || undefined })
|
|
||||||
}
|
|
||||||
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<Toggle
|
||||||
{(filters.dateFrom || filters.dateTo) && (
|
label="Live / Upcoming"
|
||||||
<button
|
checked={filters.includeLive}
|
||||||
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
|
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
||||||
className="text-[11px] text-muted hover:text-accent self-end"
|
/>
|
||||||
>
|
</>
|
||||||
clear dates
|
);
|
||||||
</button>
|
case "language":
|
||||||
)}
|
return (
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Content type">
|
|
||||||
<Toggle
|
|
||||||
label="Normal"
|
|
||||||
checked={filters.includeNormal}
|
|
||||||
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label="Shorts"
|
|
||||||
checked={filters.includeShorts}
|
|
||||||
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label="Live / Upcoming"
|
|
||||||
checked={filters.includeLive}
|
|
||||||
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{languages.length > 0 && (
|
|
||||||
<Section title="Language">
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{languages.map((t) => (
|
{languages.map((t) => (
|
||||||
<TagChip
|
<TagChip
|
||||||
|
|
@ -187,76 +321,198 @@ export default function Sidebar({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
);
|
||||||
|
case "topic":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end mb-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
|
||||||
|
}
|
||||||
|
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
|
||||||
|
title="Match any vs all selected tags"
|
||||||
|
>
|
||||||
|
{filters.tagMode === "or" ? "Any" : "All"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{topics.map((t) => (
|
||||||
|
<TagChip
|
||||||
|
key={t.id}
|
||||||
|
tag={t}
|
||||||
|
active={filters.tags.includes(t.id)}
|
||||||
|
onClick={() => toggleTag(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedAvailable = layout.order.filter((id) => available[id]);
|
||||||
|
const renderedIds = editing
|
||||||
|
? orderedAvailable
|
||||||
|
: orderedAvailable.filter((id) => !layout.hidden[id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3 hidden md:block">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-semibold">
|
||||||
|
Filters
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="ml-1.5 text-xs font-medium text-accent">{activeCount} active</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{!editing && (
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
disabled={!active}
|
||||||
|
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<button
|
||||||
|
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
|
||||||
|
title="Reset to defaults"
|
||||||
|
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing((e) => !e)}
|
||||||
|
title={editing ? "Done" : "Customize sidebar"}
|
||||||
|
className={`p-1.5 rounded-lg transition hover:bg-card ${
|
||||||
|
editing ? "text-accent" : "text-muted hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="text-[11px] text-muted -mt-1">Drag to reorder · eye to show/hide</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{topics.length > 0 && (
|
{filters.channelId && !editing && (
|
||||||
<Section
|
<div className="rounded-xl border border-border bg-card/30">
|
||||||
title="Topic"
|
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">Channel</div>
|
||||||
right={
|
<div className="px-3 pb-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
|
setFilters({ ...filters, channelId: undefined, channelName: undefined })
|
||||||
}
|
}
|
||||||
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
|
className="w-full flex items-center justify-between gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
|
||||||
title="Match any vs all selected tags"
|
|
||||||
>
|
>
|
||||||
{filters.tagMode === "or" ? "Any" : "All"}
|
<span className="truncate">{filters.channelName ?? "This channel"}</span>
|
||||||
|
<X className="w-4 h-4 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{topics.map((t) => (
|
|
||||||
<TagChip
|
|
||||||
key={t.id}
|
|
||||||
tag={t}
|
|
||||||
active={filters.tags.includes(t.id)}
|
|
||||||
onClick={() => toggleTag(t.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active && (
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
<button
|
<SortableContext items={renderedIds} strategy={verticalListSortingStrategy}>
|
||||||
onClick={() =>
|
<div className="space-y-3">
|
||||||
setFilters({
|
{renderedIds.map((id) => (
|
||||||
tags: [],
|
<WidgetCard
|
||||||
tagMode: "or",
|
key={id}
|
||||||
q: filters.q,
|
id={id}
|
||||||
sort: "newest",
|
title={WIDGET_TITLES[id]}
|
||||||
includeNormal: true,
|
editing={editing}
|
||||||
includeShorts: false,
|
collapsed={!!layout.collapsed[id]}
|
||||||
includeLive: false,
|
hidden={!!layout.hidden[id]}
|
||||||
show: "unwatched",
|
onToggleCollapse={() => toggleCollapse(id)}
|
||||||
})
|
onToggleHidden={() => toggleHidden(id)}
|
||||||
}
|
>
|
||||||
className="w-full text-xs py-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
|
{widgetBody(id)}
|
||||||
>
|
</WidgetCard>
|
||||||
Clear filters
|
))}
|
||||||
</button>
|
</div>
|
||||||
)}
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({
|
function WidgetCard({
|
||||||
|
id,
|
||||||
title,
|
title,
|
||||||
right,
|
editing,
|
||||||
|
collapsed,
|
||||||
|
hidden,
|
||||||
|
onToggleCollapse,
|
||||||
|
onToggleHidden,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
id: WidgetId;
|
||||||
title: string;
|
title: string;
|
||||||
right?: React.ReactNode;
|
editing: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
hidden: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
onToggleHidden: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id,
|
||||||
|
disabled: !editing,
|
||||||
|
});
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
const showBody = !editing && !collapsed;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div
|
||||||
<div className="flex items-center justify-between mb-2">
|
ref={setNodeRef}
|
||||||
<div className="text-xs uppercase tracking-wide text-muted">{title}</div>
|
style={style}
|
||||||
{right}
|
className={`rounded-xl border border-border bg-card/30 ${
|
||||||
|
isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : ""
|
||||||
|
} ${hidden && editing ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-2">
|
||||||
|
{editing && (
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
title="Drag to reorder"
|
||||||
|
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 select-none text-xs uppercase tracking-wide text-muted">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{editing ? (
|
||||||
|
<button
|
||||||
|
onClick={onToggleHidden}
|
||||||
|
title={hidden ? "Show widget" : "Hide widget"}
|
||||||
|
className="text-muted hover:text-fg"
|
||||||
|
>
|
||||||
|
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
title={collapsed ? "Expand" : "Collapse"}
|
||||||
|
className="text-muted hover:text-fg"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{showBody && <div className="px-3 pb-3">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,74 @@
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { dismiss, getToasts, subscribe } from "../lib/toast";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Info,
|
||||||
|
X,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
dismiss,
|
||||||
|
getActiveToasts,
|
||||||
|
subscribe,
|
||||||
|
type NotifLevel,
|
||||||
|
} from "../lib/notifications";
|
||||||
|
|
||||||
|
export const LEVEL_STYLE: Record<
|
||||||
|
NotifLevel,
|
||||||
|
{ icon: typeof Info; color: string; bar: string }
|
||||||
|
> = {
|
||||||
|
info: { icon: Info, color: "text-accent", bar: "bg-accent" },
|
||||||
|
success: { icon: CheckCircle2, color: "text-emerald-400", bar: "bg-emerald-400" },
|
||||||
|
warning: { icon: AlertTriangle, color: "text-amber-400", bar: "bg-amber-400" },
|
||||||
|
error: { icon: AlertCircle, color: "text-red-400", bar: "bg-red-400" },
|
||||||
|
fatal: { icon: XCircle, color: "text-red-500", bar: "bg-red-500" },
|
||||||
|
};
|
||||||
|
|
||||||
export default function Toaster() {
|
export default function Toaster() {
|
||||||
const toasts = useSyncExternalStore(subscribe, getToasts, getToasts);
|
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => {
|
||||||
<div
|
const { icon: Icon, color, bar } = LEVEL_STYLE[t.level];
|
||||||
key={t.id}
|
return (
|
||||||
className="bg-surface border border-border rounded-xl shadow-2xl px-4 py-3 flex items-center gap-4 animate-[fadeIn_0.15s_ease]"
|
<div
|
||||||
>
|
key={t.id}
|
||||||
<span className="text-sm">{t.message}</span>
|
className="relative overflow-hidden bg-surface border border-border rounded-xl shadow-2xl px-3 py-3 flex items-start gap-3"
|
||||||
{t.action && (
|
>
|
||||||
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{t.title && <div className="text-sm font-semibold">{t.title}</div>}
|
||||||
|
<div className="text-sm break-words">{t.message}</div>
|
||||||
|
{t.action && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
t.action!.onClick();
|
||||||
|
dismiss(t.id);
|
||||||
|
}}
|
||||||
|
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
{t.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => dismiss(t.id)}
|
||||||
t.action!.onClick();
|
className="shrink-0 text-muted hover:text-fg"
|
||||||
dismiss(t.id);
|
title="Dismiss"
|
||||||
}}
|
|
||||||
className="text-accent text-sm font-semibold hover:underline"
|
|
||||||
>
|
>
|
||||||
{t.action.label}
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
{t.duration && (
|
||||||
</div>
|
<div
|
||||||
))}
|
className={`absolute left-0 bottom-0 h-0.5 w-full origin-left ${bar}`}
|
||||||
|
style={{ animation: `toastbar ${t.duration}ms linear forwards` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,16 @@ html[data-scheme="youtube"][data-theme="light"] {
|
||||||
--accent-fg: #ffffff;
|
--accent-fg: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
|
||||||
|
@keyframes toastbar {
|
||||||
|
from {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Thin, theme-aware scrollbars */
|
/* Thin, theme-aware scrollbars */
|
||||||
* {
|
* {
|
||||||
scrollbar-color: var(--border) transparent;
|
scrollbar-color: var(--border) transparent;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { notify } from "./notifications";
|
||||||
|
|
||||||
export interface Me {
|
export interface Me {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -67,12 +69,34 @@ class HttpError extends Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function req(url: string, opts: RequestInit = {}): Promise<any> {
|
async function req(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
const r = await fetch(url, {
|
const method = opts.method ?? "GET";
|
||||||
credentials: "include",
|
let r: Response;
|
||||||
headers: { "Content-Type": "application/json" },
|
try {
|
||||||
...opts,
|
r = await fetch(url, {
|
||||||
});
|
credentials: "include",
|
||||||
if (!r.ok) throw new HttpError(r.status);
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Network/connection failure — surface it in the notification center.
|
||||||
|
notify({
|
||||||
|
level: "error",
|
||||||
|
title: "Network error",
|
||||||
|
message: `Couldn't reach the server (${method} ${url}).`,
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
// Server faults are worth logging; 401/403/404 etc. are handled by callers.
|
||||||
|
if (r.status >= 500) {
|
||||||
|
notify({
|
||||||
|
level: "error",
|
||||||
|
title: `Server error ${r.status}`,
|
||||||
|
message: `${method} ${url}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new HttpError(r.status);
|
||||||
|
}
|
||||||
return r.status === 204 ? null : r.json();
|
return r.status === 204 ? null : r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
157
frontend/src/lib/notifications.ts
Normal file
157
frontend/src/lib/notifications.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
// Client-side notification store. Surfaces transient toasts AND keeps a history that
|
||||||
|
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||||
|
// errors). History is persisted to localStorage so it survives reloads; action
|
||||||
|
// callbacks are live-only and dropped on reload.
|
||||||
|
|
||||||
|
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||||
|
|
||||||
|
export interface NotifAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structured payload that lets the Notification Center offer in-app actions even
|
||||||
|
// after a reload (when live `action` callbacks are gone).
|
||||||
|
export type NotifMeta = {
|
||||||
|
kind: "video-hidden";
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: number;
|
||||||
|
level: NotifLevel;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
action?: NotifAction;
|
||||||
|
meta?: NotifMeta;
|
||||||
|
requiresInteraction: boolean;
|
||||||
|
duration?: number; // auto-dismiss ms (undefined when it stays until acted on)
|
||||||
|
ts: number;
|
||||||
|
read: boolean;
|
||||||
|
dismissed: boolean; // transient toast surface closed (still kept in history)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyInput {
|
||||||
|
message: string;
|
||||||
|
level?: NotifLevel;
|
||||||
|
title?: string;
|
||||||
|
action?: NotifAction;
|
||||||
|
meta?: NotifMeta;
|
||||||
|
requiresInteraction?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_KEY = "subfeed.notifications";
|
||||||
|
const MAX_HISTORY = 100;
|
||||||
|
const DEFAULT_TTL = 6000;
|
||||||
|
const ERROR_TTL = 15000;
|
||||||
|
|
||||||
|
let items: Notification[] = load();
|
||||||
|
let listeners: Array<() => void> = [];
|
||||||
|
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;
|
||||||
|
|
||||||
|
// Cached derived snapshots — useSyncExternalStore needs stable references between
|
||||||
|
// emits, so we recompute these only when `items` changes.
|
||||||
|
let cachedActive: Notification[] = [];
|
||||||
|
let cachedReversed: Notification[] = [];
|
||||||
|
let cachedUnread = 0;
|
||||||
|
|
||||||
|
function load(): Notification[] {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map((n) => ({ ...n, action: undefined }) as Notification);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
try {
|
||||||
|
const slim = items.map(({ action: _action, ...n }) => n);
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim));
|
||||||
|
} catch {
|
||||||
|
/* ignore quota / serialization errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recompute() {
|
||||||
|
cachedActive = items.filter((n) => !n.dismissed);
|
||||||
|
cachedReversed = [...items].reverse();
|
||||||
|
cachedUnread = items.reduce((c, n) => c + (n.read ? 0 : 1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
items = items.slice(-MAX_HISTORY);
|
||||||
|
recompute();
|
||||||
|
persist();
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
recompute();
|
||||||
|
|
||||||
|
export function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
listeners = listeners.filter((l) => l !== listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notify(input: NotifyInput): number {
|
||||||
|
const id = counter++;
|
||||||
|
const requiresInteraction = input.requiresInteraction ?? false;
|
||||||
|
const level = input.level ?? "info";
|
||||||
|
const duration = requiresInteraction
|
||||||
|
? undefined
|
||||||
|
: level === "error" || level === "fatal"
|
||||||
|
? ERROR_TTL
|
||||||
|
: DEFAULT_TTL;
|
||||||
|
items = [
|
||||||
|
...items,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
level,
|
||||||
|
title: input.title,
|
||||||
|
message: input.message,
|
||||||
|
action: input.action,
|
||||||
|
meta: input.meta,
|
||||||
|
requiresInteraction,
|
||||||
|
duration,
|
||||||
|
ts: Date.now(),
|
||||||
|
read: false,
|
||||||
|
dismissed: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
emit();
|
||||||
|
if (duration) setTimeout(() => dismiss(id), duration);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back-compat: a simple info toast with an optional inline action (e.g. Undo). */
|
||||||
|
export function toast(message: string, action?: NotifAction): number {
|
||||||
|
return notify({ message, action });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the transient toast surface; the entry stays in the center's history. */
|
||||||
|
export function dismiss(id: number): void {
|
||||||
|
items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n));
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAllRead(): void {
|
||||||
|
if (items.every((n) => n.read)) return;
|
||||||
|
items = items.map((n) => (n.read ? n : { ...n, read: true }));
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAll(): void {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
items = [];
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getActiveToasts = (): Notification[] => cachedActive;
|
||||||
|
export const getNotifications = (): Notification[] => cachedReversed;
|
||||||
|
export const getUnreadCount = (): number => cachedUnread;
|
||||||
64
frontend/src/lib/sidebarLayout.ts
Normal file
64
frontend/src/lib/sidebarLayout.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// Per-user customization of the filter sidebar: order of the widget cards, which are
|
||||||
|
// collapsed, and which are hidden. Persisted to localStorage and the server-side
|
||||||
|
// `preferences` blob so it follows the account.
|
||||||
|
|
||||||
|
export type WidgetId = "show" | "sort" | "date" | "content" | "language" | "topic";
|
||||||
|
|
||||||
|
export const ALL_WIDGETS: WidgetId[] = [
|
||||||
|
"show",
|
||||||
|
"sort",
|
||||||
|
"date",
|
||||||
|
"content",
|
||||||
|
"language",
|
||||||
|
"topic",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WIDGET_TITLES: Record<WidgetId, string> = {
|
||||||
|
show: "Show",
|
||||||
|
sort: "Sort",
|
||||||
|
date: "Upload date",
|
||||||
|
content: "Content type",
|
||||||
|
language: "Language",
|
||||||
|
topic: "Topic",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SidebarLayout {
|
||||||
|
order: WidgetId[];
|
||||||
|
collapsed: Partial<Record<WidgetId, boolean>>;
|
||||||
|
hidden: Partial<Record<WidgetId, boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LAYOUT: SidebarLayout = {
|
||||||
|
order: [...ALL_WIDGETS],
|
||||||
|
collapsed: {},
|
||||||
|
hidden: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY = "subfeed.sidebarLayout";
|
||||||
|
|
||||||
|
// Tolerate stale/partial data: keep only known widgets and append any that are missing
|
||||||
|
// (e.g. a widget added in a later version) so nothing silently disappears.
|
||||||
|
export function normalizeLayout(raw: unknown): SidebarLayout {
|
||||||
|
const r = (raw ?? {}) as Partial<SidebarLayout>;
|
||||||
|
const order: WidgetId[] = Array.isArray(r.order)
|
||||||
|
? (r.order.filter((x) => ALL_WIDGETS.includes(x as WidgetId)) as WidgetId[])
|
||||||
|
: [];
|
||||||
|
for (const w of ALL_WIDGETS) if (!order.includes(w)) order.push(w);
|
||||||
|
return {
|
||||||
|
order,
|
||||||
|
collapsed: { ...(r.collapsed ?? {}) },
|
||||||
|
hidden: { ...(r.hidden ?? {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadLayout(): SidebarLayout {
|
||||||
|
try {
|
||||||
|
return normalizeLayout(JSON.parse(localStorage.getItem(KEY) || "{}"));
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LAYOUT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLayoutLocal(l: SidebarLayout): void {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(l));
|
||||||
|
}
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
export interface ToastAction {
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
export interface ToastItem {
|
|
||||||
id: number;
|
|
||||||
message: string;
|
|
||||||
action?: ToastAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
let items: ToastItem[] = [];
|
|
||||||
let listeners: Array<() => void> = [];
|
|
||||||
let counter = 1;
|
|
||||||
|
|
||||||
function emit() {
|
|
||||||
listeners.forEach((l) => l());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toast(message: string, action?: ToastAction): number {
|
|
||||||
const id = counter++;
|
|
||||||
items = [...items, { id, message, action }];
|
|
||||||
emit();
|
|
||||||
setTimeout(() => dismiss(id), 9000);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dismiss(id: number) {
|
|
||||||
items = items.filter((i) => i.id !== id);
|
|
||||||
emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getToasts(): ToastItem[] {
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribe(listener: () => void): () => void {
|
|
||||||
listeners.push(listener);
|
|
||||||
return () => {
|
|
||||||
listeners = listeners.filter((l) => l !== listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
82
frontend/src/lib/urlState.ts
Normal file
82
frontend/src/lib/urlState.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// Serialize feed filters to/from a compact, human-readable URL query string, so a
|
||||||
|
// pasted URL reproduces exactly what's on screen (handy for "here's the URL, I see
|
||||||
|
// this bug"). Only non-default values are emitted to keep URLs clean.
|
||||||
|
import type { FeedFilters } from "./api";
|
||||||
|
|
||||||
|
const KEYS = [
|
||||||
|
"q",
|
||||||
|
"show",
|
||||||
|
"sort",
|
||||||
|
"normal",
|
||||||
|
"shorts",
|
||||||
|
"live",
|
||||||
|
"tags",
|
||||||
|
"tagmode",
|
||||||
|
"channel",
|
||||||
|
"maxage",
|
||||||
|
"from",
|
||||||
|
"to",
|
||||||
|
"mindur",
|
||||||
|
"maxdur",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function filtersToParams(f: FeedFilters): URLSearchParams {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (f.q) p.set("q", f.q);
|
||||||
|
if (f.show && f.show !== "unwatched") p.set("show", f.show);
|
||||||
|
if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
|
||||||
|
if (!f.includeNormal) p.set("normal", "0");
|
||||||
|
if (f.includeShorts) p.set("shorts", "1");
|
||||||
|
if (f.includeLive) p.set("live", "1");
|
||||||
|
if (f.tags.length) p.set("tags", f.tags.join(","));
|
||||||
|
if (f.tagMode === "and") p.set("tagmode", "and");
|
||||||
|
if (f.channelId) p.set("channel", f.channelId);
|
||||||
|
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
|
||||||
|
if (f.dateFrom) p.set("from", f.dateFrom);
|
||||||
|
if (f.dateTo) p.set("to", f.dateTo);
|
||||||
|
if (f.minDuration != null) p.set("mindur", String(f.minDuration));
|
||||||
|
if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge URL params onto a base filter set (typically the defaults). */
|
||||||
|
export function paramsToFilters(params: URLSearchParams, base: FeedFilters): FeedFilters {
|
||||||
|
const f: FeedFilters = { ...base };
|
||||||
|
const num = (v: string | null): number | undefined => {
|
||||||
|
if (v == null || v === "") return undefined;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.has("q")) f.q = params.get("q") ?? "";
|
||||||
|
if (params.has("show")) f.show = params.get("show") || "unwatched";
|
||||||
|
if (params.has("sort")) f.sort = params.get("sort") || "newest";
|
||||||
|
if (params.has("normal")) f.includeNormal = params.get("normal") !== "0";
|
||||||
|
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
|
||||||
|
if (params.has("live")) f.includeLive = params.get("live") === "1";
|
||||||
|
if (params.has("tags"))
|
||||||
|
f.tags = (params.get("tags") ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => Number(s))
|
||||||
|
.filter((n) => Number.isInteger(n));
|
||||||
|
if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or";
|
||||||
|
f.channelId = params.get("channel") || undefined;
|
||||||
|
f.maxAgeDays = num(params.get("maxage"));
|
||||||
|
f.dateFrom = params.get("from") || undefined;
|
||||||
|
f.dateTo = params.get("to") || undefined;
|
||||||
|
f.minDuration = num(params.get("mindur"));
|
||||||
|
f.maxDuration = num(params.get("maxdur"));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the query string carries any filter param we recognize. */
|
||||||
|
export function hasFilterParams(params: URLSearchParams): boolean {
|
||||||
|
return KEYS.some((k) => params.has(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reflect the current filters into the address bar without adding history entries. */
|
||||||
|
export function syncUrl(f: FeedFilters): void {
|
||||||
|
const qs = filtersToParams(f).toString();
|
||||||
|
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
||||||
|
window.history.replaceState(null, "", url);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|
@ -11,7 +12,9 @@ const queryClient = new QueryClient({
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue