feat(m5a): channel manager, tabbed settings panel, per-user sync status

Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
This commit is contained in:
npeter83 2026-06-11 20:45:48 +02:00
parent b8a8605797
commit a8822d3935
11 changed files with 1063 additions and 45 deletions

View file

@ -8,17 +8,20 @@ import {
saveLocalTheme,
type ThemePrefs,
} from "./lib/theme";
import { hasFilterParams, paramsToFilters, syncUrl } from "./lib/urlState";
import { hasFilterParams, paramsToFilters, readPage, syncUrl, type Page } from "./lib/urlState";
import {
loadLayout,
normalizeLayout,
saveLayoutLocal,
type SidebarLayout,
} from "./lib/sidebarLayout";
import { configureNotifications } from "./lib/notifications";
import Login from "./components/Login";
import Header from "./components/Header";
import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed";
import Channels from "./components/Channels";
import SettingsPanel from "./components/SettingsPanel";
import Toaster from "./components/Toaster";
const DEFAULT_FILTERS: FeedFilters = {
@ -54,11 +57,18 @@ export default function App() {
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
const [view, setView] = useState<"grid" | "list">("grid");
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(readPage);
const [settingsOpen, setSettingsOpen] = useState(false);
function setFilters(next: FeedFilters) {
setFiltersState(next);
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
syncUrl(next);
syncUrl(next, page);
}
function setPage(next: Page) {
setPageState(next);
syncUrl(filters, next);
}
function setSidebarLayout(next: SidebarLayout) {
@ -69,9 +79,8 @@ export default function App() {
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
// Reflect the initial filters + page into the address bar so the URL is always shareable.
useEffect(() => syncUrl(filters, page), []); // eslint-disable-line react-hooks/exhaustive-deps
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
@ -90,6 +99,7 @@ export default function App() {
setSidebarLayoutState(l);
saveLayoutLocal(l);
}
if (prefs.notifications) configureNotifications(prefs.notifications);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]);
@ -124,18 +134,42 @@ export default function App() {
setFilters={setFilters}
view={view}
setView={changeView}
page={page}
setPage={setPage}
onOpenSettings={() => setSettingsOpen(true)}
/>
<div className="flex flex-1 min-h-0">
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
/>
{page === "feed" && (
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
/>
)}
<main className="flex-1 min-w-0 overflow-y-auto">
<Feed filters={filters} setFilters={setFilters} view={view} />
{page === "feed" ? (
<Feed filters={filters} setFilters={setFilters} view={view} />
) : (
<Channels
onViewChannel={(id, name) => {
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
setPage("feed");
}}
/>
)}
</main>
</div>
{settingsOpen && (
<SettingsPanel
me={meQuery.data!}
theme={theme}
setTheme={setTheme}
view={view}
setView={changeView}
onClose={() => setSettingsOpen(false)}
/>
)}
<Toaster />
</div>
);