2026-06-18 23:59:40 +02:00
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
2026-06-15 00:30:34 +02:00
|
|
|
import { useTranslation } from "react-i18next";
|
2026-06-19 19:52:29 +02:00
|
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import { api, HttpError, setUnauthorizedHandler, type FeedFilters } from "./lib/api";
|
2026-06-15 00:30:34 +02:00
|
|
|
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
2026-06-11 02:19:47 +02:00
|
|
|
import {
|
|
|
|
|
applyTheme,
|
|
|
|
|
DEFAULT_THEME,
|
|
|
|
|
loadLocalTheme,
|
|
|
|
|
saveLocalTheme,
|
|
|
|
|
type ThemePrefs,
|
|
|
|
|
} from "./lib/theme";
|
2026-06-18 03:20:17 +02:00
|
|
|
import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
|
2026-06-11 19:21:48 +02:00
|
|
|
import {
|
|
|
|
|
loadLayout,
|
|
|
|
|
normalizeLayout,
|
|
|
|
|
saveLayoutLocal,
|
|
|
|
|
type SidebarLayout,
|
|
|
|
|
} from "./lib/sidebarLayout";
|
2026-06-19 16:10:08 +02:00
|
|
|
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
2026-06-18 23:59:40 +02:00
|
|
|
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
2026-07-01 03:17:36 +02:00
|
|
|
import { LS, readJSON, readMerged, usePersistedState } from "./lib/storage";
|
2026-06-18 23:59:40 +02:00
|
|
|
import { useConfirm } from "./components/ConfirmProvider";
|
|
|
|
|
import type { PrefsController } from "./components/SettingsPanel";
|
2026-06-19 15:15:58 +02:00
|
|
|
import Welcome from "./components/Welcome";
|
2026-06-21 01:11:16 +02:00
|
|
|
import SetupWizard from "./components/SetupWizard";
|
2026-06-11 02:19:47 +02:00
|
|
|
import Header from "./components/Header";
|
2026-06-16 00:42:23 +02:00
|
|
|
import NavSidebar from "./components/NavSidebar";
|
2026-06-11 02:19:47 +02:00
|
|
|
import Sidebar from "./components/Sidebar";
|
|
|
|
|
import Feed from "./components/Feed";
|
2026-06-30 03:08:52 +02:00
|
|
|
import ChannelPage from "./components/ChannelPage";
|
2026-06-30 05:02:42 +02:00
|
|
|
import BackToTop from "./components/BackToTop";
|
2026-06-19 03:38:57 +02:00
|
|
|
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
|
2026-06-15 14:45:18 +02:00
|
|
|
import Playlists from "./components/Playlists";
|
2026-06-12 02:47:55 +02:00
|
|
|
import Stats from "./components/Stats";
|
2026-06-16 14:38:51 +02:00
|
|
|
import Scheduler from "./components/Scheduler";
|
2026-06-19 12:23:00 +02:00
|
|
|
import ConfigPanel from "./components/ConfigPanel";
|
2026-06-26 01:05:14 +02:00
|
|
|
import AdminUsers, { focusAccessRequestsTab } from "./components/AdminUsers";
|
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.
2026-06-11 20:45:48 +02:00
|
|
|
import SettingsPanel from "./components/SettingsPanel";
|
2026-06-18 03:20:17 +02:00
|
|
|
import NotificationsPanel from "./components/NotificationsPanel";
|
2026-06-25 22:34:24 +02:00
|
|
|
import Messages from "./components/Messages";
|
|
|
|
|
import ChatDock from "./components/ChatDock";
|
2026-06-14 01:11:29 +02:00
|
|
|
import OnboardingWizard from "./components/OnboardingWizard";
|
|
|
|
|
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
import Toaster from "./components/Toaster";
|
2026-06-18 01:17:31 +02:00
|
|
|
import ErrorDialog from "./components/ErrorDialog";
|
2026-06-15 00:06:57 +02:00
|
|
|
import About from "./components/About";
|
|
|
|
|
import ReleaseNotes from "./components/ReleaseNotes";
|
|
|
|
|
import VersionBanner from "./components/VersionBanner";
|
2026-06-16 10:12:10 +02:00
|
|
|
import DemoBanner from "./components/DemoBanner";
|
2026-06-15 00:06:57 +02:00
|
|
|
import { CURRENT_VERSION } from "./lib/releaseNotes";
|
2026-06-11 02:19:47 +02:00
|
|
|
|
|
|
|
|
const DEFAULT_FILTERS: FeedFilters = {
|
|
|
|
|
tags: [],
|
|
|
|
|
tagMode: "or",
|
|
|
|
|
q: "",
|
|
|
|
|
sort: "newest",
|
2026-06-15 04:06:22 +02:00
|
|
|
scope: "my",
|
2026-06-11 03:47:51 +02:00
|
|
|
includeNormal: true,
|
2026-06-11 02:19:47 +02:00
|
|
|
includeShorts: false,
|
|
|
|
|
includeLive: false,
|
|
|
|
|
show: "unwatched",
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-26 03:30:19 +02:00
|
|
|
const FILTERS_KEY = LS.filters;
|
|
|
|
|
const PAGE_KEY = LS.page;
|
|
|
|
|
const PERF_KEY = LS.perfMode;
|
2026-06-18 23:59:40 +02:00
|
|
|
|
|
|
|
|
// The preferences edited on the Settings page. They apply locally for instant preview but
|
|
|
|
|
// persist to the server only on an explicit Save — so App holds both the live "draft" (the
|
|
|
|
|
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
|
|
|
|
|
type EditablePrefs = {
|
|
|
|
|
theme: ThemePrefs;
|
|
|
|
|
view: "grid" | "list";
|
|
|
|
|
performanceMode: boolean;
|
|
|
|
|
hints: boolean;
|
|
|
|
|
notifications: NotifSettings;
|
|
|
|
|
};
|
2026-06-15 14:59:24 +02:00
|
|
|
|
|
|
|
|
// Page is navigation state, not a filter, but it's also kept out of the address bar — so
|
|
|
|
|
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
|
|
|
|
|
function loadInitialPage(): Page {
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
if (params.has("page")) return readPage();
|
|
|
|
|
const stored = localStorage.getItem(PAGE_KEY);
|
2026-06-18 03:20:17 +02:00
|
|
|
return isPage(stored) ? stored : "feed";
|
2026-06-15 14:59:24 +02:00
|
|
|
}
|
2026-06-11 03:28:45 +02:00
|
|
|
|
2026-06-11 19:17:08 +02:00
|
|
|
function loadStoredFilters(): FeedFilters {
|
2026-06-26 03:30:19 +02:00
|
|
|
return readMerged(FILTERS_KEY, DEFAULT_FILTERS);
|
2026-06-11 03:28:45 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 19:17:08 +02:00
|
|
|
// 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);
|
2026-07-01 03:17:36 +02:00
|
|
|
// A user-chosen default saved view takes precedence over the last-session filters on a fresh
|
|
|
|
|
// load / F5 (SavedViewsWidget mirrors its filters here). No default set → last-session filters.
|
|
|
|
|
const def = readJSON<FeedFilters | null>(LS.defaultViewFilters, null);
|
|
|
|
|
if (def) return { ...DEFAULT_FILTERS, ...def };
|
2026-06-11 19:17:08 +02:00
|
|
|
return loadStoredFilters();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export default function App() {
|
2026-06-17 14:28:29 +02:00
|
|
|
const { t, i18n } = useTranslation();
|
2026-06-18 23:59:40 +02:00
|
|
|
const confirm = useConfirm();
|
|
|
|
|
// Refs the (stable) navigation handlers read so they always see the latest unsaved-prefs
|
|
|
|
|
// state without being recreated; populated by an effect each render (below).
|
|
|
|
|
const prefsDirtyRef = useRef(false);
|
|
|
|
|
const discardRef = useRef<() => void>(() => {});
|
|
|
|
|
const confirmRef = useRef(confirm);
|
|
|
|
|
const pageRef = useRef<Page>("feed");
|
2026-06-11 02:19:47 +02:00
|
|
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
2026-06-11 19:17:08 +02:00
|
|
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
2026-06-11 02:19:47 +02:00
|
|
|
const [view, setView] = useState<"grid" | "list">("grid");
|
2026-06-18 23:59:40 +02:00
|
|
|
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
|
|
|
|
|
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
|
|
|
|
const [hints, setHints] = useState(() => hintsEnabled());
|
|
|
|
|
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
|
|
|
|
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
|
|
|
|
|
theme: loadLocalTheme(),
|
|
|
|
|
view: "grid",
|
|
|
|
|
performanceMode: localStorage.getItem(PERF_KEY) === "1",
|
|
|
|
|
hints: hintsEnabled(),
|
|
|
|
|
notifications: getNotifSettings(),
|
|
|
|
|
}));
|
|
|
|
|
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
|
|
|
|
|
const saveMsgTimer = useRef<number | undefined>(undefined);
|
2026-06-11 19:21:48 +02:00
|
|
|
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
2026-06-15 14:59:24 +02:00
|
|
|
const [page, setPageState] = useState<Page>(loadInitialPage);
|
2026-06-29 02:01:44 +02:00
|
|
|
// Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL,
|
|
|
|
|
// so a reload returns to the normal feed (a search spends quota, so we never auto-replay it).
|
2026-06-29 23:03:25 +02:00
|
|
|
// The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the
|
|
|
|
|
// popstate handler below derives ytSearch from it, so Back steps player → search → feed in
|
|
|
|
|
// that order (a player opened over the results pops first, then the search, then the feed) —
|
|
|
|
|
// instead of the search vanishing because it had no history entry of its own.
|
2026-07-01 01:55:05 +02:00
|
|
|
const [ytSearch, setYtSearch] = useState<string | null>(() => {
|
|
|
|
|
// Restore a live search across a reload ONLY when it was served by the zero-quota scrape
|
|
|
|
|
// source (re-fetching is free) — an api-source search would re-spend ~100 units, so it's
|
|
|
|
|
// dropped to the normal feed as before. The scrape flag rides in history.state (survives F5).
|
|
|
|
|
const st = window.history.state;
|
|
|
|
|
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
|
|
|
|
|
});
|
2026-06-29 23:03:25 +02:00
|
|
|
const enterYtSearch = useCallback((q: string) => {
|
|
|
|
|
setYtSearch(q);
|
2026-07-01 01:55:05 +02:00
|
|
|
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps
|
|
|
|
|
// it once the results resolve, so a reload only restores a confirmed scrape-sourced search.
|
|
|
|
|
const { _ytScrape: _drop, ...st } = window.history.state || {};
|
2026-06-29 23:03:25 +02:00
|
|
|
if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
|
|
|
|
|
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
|
|
|
|
|
}, []);
|
|
|
|
|
const exitYtSearch = useCallback(() => {
|
|
|
|
|
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
|
|
|
|
|
if (window.history.state?._yt) window.history.back();
|
|
|
|
|
else setYtSearch(null);
|
|
|
|
|
}, []);
|
2026-06-30 03:08:52 +02:00
|
|
|
// The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a
|
|
|
|
|
// browser-history entry (history.state._chan = channel id, _chanName = a display name to show
|
|
|
|
|
// before the detail loads), so Back closes the channel page (after any player opened over it)
|
|
|
|
|
// and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL.
|
2026-06-30 04:24:52 +02:00
|
|
|
const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(() => {
|
|
|
|
|
// Restore the open channel page across a reload (it rides in history.state, which survives F5),
|
|
|
|
|
// unlike the YouTube search which is intentionally dropped (it would re-spend quota).
|
|
|
|
|
const c = window.history.state?._chan as string | undefined;
|
|
|
|
|
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
|
|
|
|
|
});
|
2026-06-30 03:08:52 +02:00
|
|
|
const openChannel = useCallback((id: string, name?: string) => {
|
|
|
|
|
setChannelView({ id, name });
|
|
|
|
|
const st = window.history.state || {};
|
|
|
|
|
if (st._chan)
|
|
|
|
|
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
|
|
|
|
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
|
|
|
|
}, []);
|
|
|
|
|
const closeChannel = useCallback(() => {
|
|
|
|
|
if (window.history.state?._chan) window.history.back();
|
|
|
|
|
else setChannelView(null);
|
|
|
|
|
}, []);
|
2026-06-14 01:11:29 +02:00
|
|
|
const [wizardOpen, setWizardOpen] = useState(false);
|
2026-06-26 03:30:19 +02:00
|
|
|
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
|
|
|
|
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
|
|
|
|
|
const channelFilter: ChannelStatusFilter = (
|
|
|
|
|
["needs_full", "fully_synced", "hidden", "all"] as const
|
|
|
|
|
).includes(channelFilterRaw as ChannelStatusFilter)
|
|
|
|
|
? (channelFilterRaw as ChannelStatusFilter)
|
|
|
|
|
: "all";
|
2026-06-15 00:06:57 +02:00
|
|
|
const [aboutOpen, setAboutOpen] = useState(false);
|
|
|
|
|
const [notesOpen, setNotesOpen] = useState(false);
|
|
|
|
|
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
2026-06-19 03:38:57 +02:00
|
|
|
// The Channel manager's active tab (subscriptions vs playlist discovery). Lifted here and
|
|
|
|
|
// persisted so navigation intents that target the subscriptions table — the header's
|
|
|
|
|
// "without full history" link, focus-channel — can force it back to "subscribed" instead
|
|
|
|
|
// of dumping the user on whatever tab they last left open.
|
2026-06-26 03:30:19 +02:00
|
|
|
const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed");
|
|
|
|
|
const channelsView: ChannelsView =
|
|
|
|
|
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
2026-06-19 03:55:55 +02:00
|
|
|
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
|
|
|
|
// there to see a specific set (the header's "without full history" link).
|
|
|
|
|
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
2026-06-18 01:17:31 +02:00
|
|
|
// "Focus this channel in the manager": jump to the Channels page and seed its name filter
|
|
|
|
|
// so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later.
|
|
|
|
|
const [focusChannelName, setFocusChannelName] = useState<string | null>(null);
|
|
|
|
|
const focusChannel = (name: string) => {
|
|
|
|
|
setFocusChannelName(name);
|
2026-06-19 03:38:57 +02:00
|
|
|
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
|
2026-06-18 01:17:31 +02:00
|
|
|
setPage("channels");
|
|
|
|
|
};
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (page !== "channels") setFocusChannelName(null);
|
2026-06-29 02:01:44 +02:00
|
|
|
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
|
|
|
|
|
if (page !== "feed") setYtSearch(null);
|
2026-06-18 01:17:31 +02:00
|
|
|
}, [page]);
|
2026-06-15 00:06:57 +02:00
|
|
|
|
|
|
|
|
function openReleaseNotes(highlight?: string) {
|
|
|
|
|
setNotesHighlight(highlight);
|
|
|
|
|
setNotesOpen(true);
|
|
|
|
|
}
|
2026-06-11 02:19:47 +02:00
|
|
|
|
2026-06-11 03:28:45 +02:00
|
|
|
function setFilters(next: FeedFilters) {
|
|
|
|
|
setFiltersState(next);
|
|
|
|
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
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.
2026-06-11 20:45:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setPage(next: Page) {
|
2026-06-30 04:47:54 +02:00
|
|
|
// While a channel page is open it overlays the content column, so a nav click must close it
|
|
|
|
|
// even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
|
|
|
|
if (next === page && !channelView) return;
|
2026-06-18 23:59:40 +02:00
|
|
|
const go = () => {
|
2026-06-30 04:47:54 +02:00
|
|
|
setChannelView(null); // leave any open channel page when navigating via the rail
|
2026-06-18 23:59:40 +02:00
|
|
|
setPageState(next);
|
|
|
|
|
localStorage.setItem(PAGE_KEY, next);
|
|
|
|
|
// Push an in-app history entry so the browser/mouse Back button steps through pages
|
|
|
|
|
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
|
|
|
|
|
// the page rides in history.state, not the query string (filters never go in the URL).
|
2026-06-30 04:47:54 +02:00
|
|
|
// A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root.
|
2026-06-26 01:37:43 +02:00
|
|
|
window.history.pushState({ sfPage: next }, "");
|
2026-06-18 23:59:40 +02:00
|
|
|
};
|
|
|
|
|
// Guard leaving the Settings page with unsaved preference changes.
|
|
|
|
|
if (page === "settings" && prefsDirtyRef.current) {
|
|
|
|
|
void confirmRef.current({
|
|
|
|
|
title: t("settings.unsaved.title"),
|
|
|
|
|
message: t("settings.unsaved.message"),
|
|
|
|
|
confirmLabel: t("settings.unsaved.discard"),
|
|
|
|
|
danger: true,
|
|
|
|
|
}).then((ok) => {
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
discardRef.current();
|
|
|
|
|
go();
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
go();
|
2026-06-11 03:28:45 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 19:21:48 +02:00
|
|
|
function setSidebarLayout(next: SidebarLayout) {
|
|
|
|
|
setSidebarLayoutState(next);
|
|
|
|
|
saveLayoutLocal(next);
|
|
|
|
|
api.savePrefs({ sidebarLayout: next }).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
useEffect(() => applyTheme(theme), [theme]);
|
|
|
|
|
|
2026-06-18 23:59:40 +02:00
|
|
|
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via
|
|
|
|
|
// the stores too); persistence to the server is deferred to an explicit Save.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
document.documentElement.dataset.perf = perf ? "1" : "";
|
|
|
|
|
localStorage.setItem(PERF_KEY, perf ? "1" : "0");
|
|
|
|
|
}, [perf]);
|
|
|
|
|
useEffect(() => setHintsEnabled(hints), [hints]);
|
|
|
|
|
useEffect(() => configureNotifications(notif), [notif]);
|
|
|
|
|
|
2026-06-15 12:29:43 +02:00
|
|
|
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
|
|
|
|
|
// link, its params have already hydrated the initial state — persist them and strip the
|
|
|
|
|
// query so the URL stays clean from here on.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
if (hasFilterParams(params) || params.has("page")) {
|
|
|
|
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
|
|
|
|
|
stripUrlParams();
|
|
|
|
|
}
|
|
|
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
2026-06-11 19:17:08 +02:00
|
|
|
|
2026-06-16 01:27:49 +02:00
|
|
|
// In-app Back/Forward: stamp the initial entry with the current page, then sync `page`
|
2026-06-26 01:13:26 +02:00
|
|
|
// from history.state on popstate. (stripUrlParams preserves history.state, so this stamp
|
|
|
|
|
// survives a later query-string strip.)
|
2026-06-16 01:27:49 +02:00
|
|
|
useEffect(() => {
|
2026-07-01 01:55:05 +02:00
|
|
|
// A reload returns to the normal feed by dropping a stale _yt — EXCEPT when the search was
|
|
|
|
|
// scrape-sourced (zero quota): then we keep _yt + _ytScrape so it restores and re-fetches for
|
|
|
|
|
// free (ytSearch above already initialised from it). An api-source search is still dropped (it
|
|
|
|
|
// would re-spend ~100 units). _chan/_chanName are always kept so a channel page survives F5.
|
|
|
|
|
const st = window.history.state || {};
|
|
|
|
|
if (st._yt && st._ytScrape) {
|
|
|
|
|
window.history.replaceState({ ...st, sfPage: page }, "");
|
|
|
|
|
} else {
|
|
|
|
|
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
|
|
|
|
window.history.replaceState({ ...rest, sfPage: page }, "");
|
|
|
|
|
}
|
2026-06-16 01:27:49 +02:00
|
|
|
function onPop(e: PopStateEvent) {
|
|
|
|
|
const p = (e.state?.sfPage as Page) ?? "feed";
|
2026-06-18 23:59:40 +02:00
|
|
|
// Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
|
|
|
|
|
// entry so nothing is silently stranded, then ask; on discard, go where Back headed.
|
|
|
|
|
if (pageRef.current === "settings" && p !== "settings" && prefsDirtyRef.current) {
|
|
|
|
|
window.history.pushState({ ...window.history.state, sfPage: "settings" }, "");
|
|
|
|
|
void confirmRef.current({
|
|
|
|
|
title: t("settings.unsaved.title"),
|
|
|
|
|
message: t("settings.unsaved.message"),
|
|
|
|
|
confirmLabel: t("settings.unsaved.discard"),
|
|
|
|
|
danger: true,
|
|
|
|
|
}).then((ok) => {
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
discardRef.current();
|
|
|
|
|
setPageState(p);
|
|
|
|
|
localStorage.setItem(PAGE_KEY, p);
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-16 01:27:49 +02:00
|
|
|
setPageState(p);
|
|
|
|
|
localStorage.setItem(PAGE_KEY, p);
|
2026-06-29 23:03:25 +02:00
|
|
|
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
|
|
|
|
|
// clears it to match the entry we landed on (and a player popped first leaves it intact).
|
|
|
|
|
setYtSearch((e.state?._yt as string) ?? null);
|
2026-06-30 03:08:52 +02:00
|
|
|
// The channel page rides in history.state._chan the same way.
|
|
|
|
|
const chan = e.state?._chan as string | undefined;
|
|
|
|
|
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
|
2026-06-16 01:27:49 +02:00
|
|
|
}
|
|
|
|
|
window.addEventListener("popstate", onPop);
|
|
|
|
|
return () => window.removeEventListener("popstate", onPop);
|
|
|
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
2026-06-19 19:52:29 +02:00
|
|
|
const queryClient = useQueryClient();
|
2026-06-21 01:11:16 +02:00
|
|
|
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
|
|
|
|
// We check this before anything else and render the wizard; `me` is held back until we know the
|
|
|
|
|
// instance is set up (otherwise it would 503 against the setup-mode lock).
|
|
|
|
|
const setupQuery = useQuery({
|
|
|
|
|
queryKey: ["setup-status"],
|
|
|
|
|
queryFn: api.setupStatus,
|
|
|
|
|
staleTime: Infinity,
|
|
|
|
|
});
|
|
|
|
|
const needsSetup = setupQuery.data?.configured === false;
|
2026-06-19 19:52:29 +02:00
|
|
|
// Poll the session so an account suspended/deleted by an admin is noticed even with no
|
|
|
|
|
// interaction (option 1): the refetch 401s → the 401 handler below drops to the login page.
|
|
|
|
|
// Only poll while signed in (data present) — polling on the public login page is pointless and
|
|
|
|
|
// its periodic refetch caused a visible flicker there.
|
|
|
|
|
const meQuery = useQuery({
|
|
|
|
|
queryKey: ["me"],
|
|
|
|
|
queryFn: api.me,
|
2026-06-21 01:11:16 +02:00
|
|
|
enabled: setupQuery.isSuccess && !needsSetup,
|
2026-06-19 19:52:29 +02:00
|
|
|
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-26 01:13:26 +02:00
|
|
|
// Once signed in, drop any leftover query string from the pre-login flow (e.g.
|
|
|
|
|
// ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
|
|
|
|
|
// address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (meQuery.data) stripUrlParams();
|
|
|
|
|
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
2026-06-19 19:52:29 +02:00
|
|
|
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
|
|
|
|
// `me` exists), reload to the login page at once (option 2 — also covers any click that hits
|
|
|
|
|
// the API). Guarded on cached `me` so the public login page's own /api/me 401 can't loop.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setUnauthorizedHandler(() => {
|
|
|
|
|
if (queryClient.getQueryData(["me"])) {
|
|
|
|
|
queryClient.clear();
|
|
|
|
|
window.location.reload();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return () => setUnauthorizedHandler(null);
|
|
|
|
|
}, [queryClient]);
|
|
|
|
|
|
|
|
|
|
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
|
|
|
|
|
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
|
|
|
|
|
// Settings → Account so the new state is visible, then strip the param for a clean URL.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const result = new URLSearchParams(window.location.search).get("link");
|
|
|
|
|
if (!result) return;
|
|
|
|
|
if (result === "ok") {
|
|
|
|
|
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
|
|
|
|
void meQuery.refetch();
|
2026-06-26 03:30:19 +02:00
|
|
|
localStorage.setItem(LS.settingsTab, "account");
|
2026-06-19 19:52:29 +02:00
|
|
|
setPage("settings");
|
|
|
|
|
} else {
|
|
|
|
|
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
|
|
|
|
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
|
|
|
|
}
|
|
|
|
|
stripUrlParams();
|
|
|
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
2026-06-11 02:19:47 +02:00
|
|
|
|
2026-06-14 01:11:29 +02:00
|
|
|
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after
|
|
|
|
|
// each consent redirect). Derived from granted scopes + storage flags so it's stable
|
|
|
|
|
// across the full-page OAuth round-trip; dismissible and reopenable from Settings.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (meQuery.data && shouldAutoOpenOnboarding(meQuery.data)) setWizardOpen(true);
|
|
|
|
|
}, [meQuery.data?.id, meQuery.data?.can_read, meQuery.data?.can_write]);
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
// On login, adopt server-stored preferences.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const prefs = meQuery.data?.preferences;
|
|
|
|
|
if (!prefs) return;
|
2026-06-18 23:59:40 +02:00
|
|
|
// Adopt the server prefs as both the saved baseline and the live draft. The runtime
|
|
|
|
|
// effects above apply the draft (theme/perf/hints/notifications) for display.
|
|
|
|
|
const adopted: EditablePrefs = {
|
|
|
|
|
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
|
|
|
|
view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid",
|
|
|
|
|
performanceMode:
|
|
|
|
|
typeof prefs.performanceMode === "boolean"
|
|
|
|
|
? prefs.performanceMode
|
|
|
|
|
: localStorage.getItem(PERF_KEY) === "1",
|
|
|
|
|
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
|
|
|
|
notifications: prefs.notifications
|
|
|
|
|
? { ...getNotifSettings(), ...prefs.notifications }
|
|
|
|
|
: getNotifSettings(),
|
|
|
|
|
};
|
|
|
|
|
setThemeState(adopted.theme);
|
|
|
|
|
saveLocalTheme(adopted.theme);
|
|
|
|
|
setView(adopted.view);
|
|
|
|
|
setPerf(adopted.performanceMode);
|
|
|
|
|
setHints(adopted.hints);
|
|
|
|
|
setNotif(adopted.notifications);
|
|
|
|
|
setSavedPrefs(adopted);
|
|
|
|
|
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
2026-06-11 19:21:48 +02:00
|
|
|
if (prefs.sidebarLayout) {
|
|
|
|
|
const l = normalizeLayout(prefs.sidebarLayout);
|
|
|
|
|
setSidebarLayoutState(l);
|
|
|
|
|
saveLayoutLocal(l);
|
|
|
|
|
}
|
2026-06-15 00:30:34 +02:00
|
|
|
if (isSupported(prefs.language)) setLanguage(prefs.language);
|
2026-06-16 09:17:34 +02:00
|
|
|
// The demo account is shared: there are no subscriptions, so default it to the whole
|
2026-06-16 10:12:10 +02:00
|
|
|
// library (the "my" feed would be empty). The communal-state warning is a permanent
|
|
|
|
|
// banner (see DemoBanner below), not a toast that re-pops on every reload.
|
2026-06-16 09:17:34 +02:00
|
|
|
if (meQuery.data?.is_demo) {
|
|
|
|
|
setFilters({ ...loadStoredFilters(), scope: "all" });
|
|
|
|
|
}
|
2026-06-11 02:19:47 +02:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [meQuery.data?.id]);
|
|
|
|
|
|
2026-06-26 01:19:01 +02:00
|
|
|
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
|
|
|
|
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
|
|
|
|
// fresh load (which is why the notice used to linger until F5 after an approval).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (meQuery.data?.role !== "admin") return;
|
|
|
|
|
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
|
|
|
|
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
|
|
|
|
removeByMetaKind("access-requests");
|
|
|
|
|
const pending = meQuery.data.pending_invites ?? 0;
|
|
|
|
|
if (pending > 0) {
|
|
|
|
|
notify({
|
|
|
|
|
level: "warning",
|
|
|
|
|
title: t("common.accessRequestsTitle"),
|
|
|
|
|
message: t("common.accessRequestsMessage", { count: pending }),
|
|
|
|
|
// Durable inbox link (survives reload, unlike a live action callback); the toast also
|
|
|
|
|
// gets a click via the action.
|
|
|
|
|
meta: { kind: "access-requests" },
|
|
|
|
|
action: {
|
|
|
|
|
label: t("common.accessRequestsReview"),
|
|
|
|
|
onClick: () => {
|
|
|
|
|
focusAccessRequestsTab();
|
|
|
|
|
setPage("users");
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
|
|
|
|
|
|
2026-06-18 23:59:40 +02:00
|
|
|
// Theme is part of the Settings draft: apply locally for preview, persist on Save.
|
2026-06-11 02:19:47 +02:00
|
|
|
function setTheme(next: ThemePrefs) {
|
|
|
|
|
setThemeState(next);
|
|
|
|
|
saveLocalTheme(next);
|
|
|
|
|
}
|
2026-06-18 23:59:40 +02:00
|
|
|
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
2026-06-15 00:30:34 +02:00
|
|
|
function changeLanguage(code: LangCode) {
|
|
|
|
|
setLanguage(code);
|
|
|
|
|
api.savePrefs({ language: code }).catch(() => {});
|
|
|
|
|
}
|
2026-06-11 02:19:47 +02:00
|
|
|
|
2026-06-18 23:59:40 +02:00
|
|
|
const prefsDirty =
|
|
|
|
|
JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !==
|
|
|
|
|
JSON.stringify(savedPrefs);
|
|
|
|
|
|
|
|
|
|
const savePrefs = useCallback(() => {
|
|
|
|
|
const payload: EditablePrefs = { theme, view, performanceMode: perf, hints, notifications: notif };
|
|
|
|
|
window.clearTimeout(saveMsgTimer.current);
|
|
|
|
|
setPrefsSaveState("saving");
|
|
|
|
|
api
|
|
|
|
|
.savePrefs(payload)
|
|
|
|
|
.then(() => {
|
|
|
|
|
setSavedPrefs(payload);
|
|
|
|
|
setPrefsSaveState("saved");
|
|
|
|
|
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
// api.req() already surfaces the failure (connection-lost status / error dialog);
|
|
|
|
|
// just flag the bar so the user sees the save didn't take, then clear it.
|
|
|
|
|
setPrefsSaveState("error");
|
|
|
|
|
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
|
|
|
|
|
});
|
|
|
|
|
}, [theme, view, perf, hints, notif]);
|
|
|
|
|
|
|
|
|
|
const discardPrefs = useCallback(() => {
|
|
|
|
|
setThemeState(savedPrefs.theme);
|
|
|
|
|
saveLocalTheme(savedPrefs.theme);
|
|
|
|
|
setView(savedPrefs.view);
|
|
|
|
|
setPerf(savedPrefs.performanceMode);
|
|
|
|
|
setHints(savedPrefs.hints);
|
|
|
|
|
setNotif(savedPrefs.notifications);
|
|
|
|
|
window.clearTimeout(saveMsgTimer.current);
|
|
|
|
|
setPrefsSaveState("idle");
|
|
|
|
|
}, [savedPrefs]);
|
|
|
|
|
|
|
|
|
|
const prefsCtl: PrefsController = {
|
|
|
|
|
theme,
|
|
|
|
|
setTheme,
|
|
|
|
|
view,
|
|
|
|
|
setView,
|
|
|
|
|
perf,
|
|
|
|
|
setPerf,
|
|
|
|
|
hints,
|
|
|
|
|
setHints,
|
|
|
|
|
notif,
|
|
|
|
|
setNotif,
|
|
|
|
|
dirty: prefsDirty,
|
|
|
|
|
save: savePrefs,
|
|
|
|
|
discard: discardPrefs,
|
|
|
|
|
saveState: prefsSaveState,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Keep the navigation guards (setPage / popstate) reading the latest values.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
prefsDirtyRef.current = prefsDirty;
|
|
|
|
|
discardRef.current = discardPrefs;
|
|
|
|
|
confirmRef.current = confirm;
|
|
|
|
|
pageRef.current = page;
|
|
|
|
|
});
|
2026-06-19 00:04:19 +02:00
|
|
|
// No beforeunload guard: a reload/close can only raise the browser's own native prompt
|
|
|
|
|
// (we can't show our in-app confirm there), and unsaved prefs are local-only — they
|
|
|
|
|
// revert to the saved server baseline on the next load — so there's nothing to lose.
|
2026-06-18 23:59:40 +02:00
|
|
|
|
2026-06-21 01:11:16 +02:00
|
|
|
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
|
|
|
|
if (setupQuery.isLoading)
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
|
|
|
|
);
|
|
|
|
|
if (needsSetup) return <SetupWizard />;
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
if (meQuery.isLoading)
|
2026-06-15 00:30:34 +02:00
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
|
|
|
|
);
|
2026-06-11 02:19:47 +02:00
|
|
|
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
|
2026-06-19 15:15:58 +02:00
|
|
|
return <Welcome />;
|
2026-06-11 02:19:47 +02:00
|
|
|
if (meQuery.error)
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen grid place-items-center text-muted">
|
2026-06-15 00:30:34 +02:00
|
|
|
{t("common.somethingWrong")}
|
2026-06-11 02:19:47 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-16 00:42:23 +02:00
|
|
|
<div className="h-screen flex bg-bg text-fg">
|
|
|
|
|
<NavSidebar
|
2026-06-11 02:19:47 +02:00
|
|
|
me={meQuery.data!}
|
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.
2026-06-11 20:45:48 +02:00
|
|
|
page={page}
|
|
|
|
|
setPage={setPage}
|
2026-06-15 00:06:57 +02:00
|
|
|
onOpenAbout={() => setAboutOpen(true)}
|
2026-06-17 14:28:29 +02:00
|
|
|
onChangeLanguage={changeLanguage}
|
|
|
|
|
language={i18n.language as LangCode}
|
2026-06-11 02:19:47 +02:00
|
|
|
/>
|
2026-06-17 14:28:29 +02:00
|
|
|
<div className="relative flex-1 min-w-0 flex flex-col">
|
2026-06-30 03:08:52 +02:00
|
|
|
{channelView ? (
|
|
|
|
|
<ChannelPage
|
|
|
|
|
key={channelView.id}
|
|
|
|
|
channelId={channelView.id}
|
|
|
|
|
initialName={channelView.name}
|
|
|
|
|
me={meQuery.data!}
|
|
|
|
|
view={view}
|
|
|
|
|
onBack={closeChannel}
|
|
|
|
|
onOpenChannel={openChannel}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-06-16 00:42:23 +02:00
|
|
|
<Header
|
|
|
|
|
me={meQuery.data!}
|
|
|
|
|
filters={filters}
|
|
|
|
|
setFilters={setFilters}
|
|
|
|
|
page={page}
|
2026-06-29 23:03:25 +02:00
|
|
|
onYtSearch={enterYtSearch}
|
2026-06-16 00:42:23 +02:00
|
|
|
onGoToFullHistory={() => {
|
|
|
|
|
setChannelFilter("needs_full");
|
2026-06-19 03:38:57 +02:00
|
|
|
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
|
2026-06-19 03:55:55 +02:00
|
|
|
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
|
2026-06-16 00:42:23 +02:00
|
|
|
setPage("channels");
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-06-16 10:12:10 +02:00
|
|
|
{meQuery.data!.is_demo && <DemoBanner />}
|
2026-06-16 00:42:23 +02:00
|
|
|
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
|
|
|
|
<div className="flex flex-1 min-h-0">
|
|
|
|
|
{page === "feed" && (
|
|
|
|
|
<Sidebar
|
|
|
|
|
filters={filters}
|
|
|
|
|
setFilters={setFilters}
|
|
|
|
|
layout={sidebarLayout}
|
|
|
|
|
setLayout={setSidebarLayout}
|
2026-06-18 01:17:31 +02:00
|
|
|
onFocusChannel={focusChannel}
|
2026-07-01 03:17:36 +02:00
|
|
|
isDemo={meQuery.data!.is_demo}
|
2026-06-16 00:42:23 +02:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
2026-06-12 02:47:55 +02:00
|
|
|
{page === "channels" ? (
|
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.
2026-06-11 20:45:48 +02:00
|
|
|
<Channels
|
2026-06-11 23:27:11 +02:00
|
|
|
canWrite={meQuery.data!.can_write}
|
2026-06-17 19:16:23 +02:00
|
|
|
isAdmin={meQuery.data!.role === "admin"}
|
2026-06-18 01:17:31 +02:00
|
|
|
focusChannelName={focusChannelName}
|
|
|
|
|
onFocusChannel={focusChannel}
|
2026-06-14 07:08:59 +02:00
|
|
|
statusFilter={channelFilter}
|
|
|
|
|
setStatusFilter={setChannelFilter}
|
2026-06-19 03:38:57 +02:00
|
|
|
view={channelsView}
|
|
|
|
|
setView={setChannelsView}
|
2026-06-19 03:55:55 +02:00
|
|
|
filtersResetToken={channelsFilterReset}
|
2026-06-15 02:02:05 +02:00
|
|
|
onOpenWizard={() => setWizardOpen(true)}
|
2026-06-30 03:08:52 +02:00
|
|
|
onViewChannel={(id, name) => openChannel(id, name)}
|
2026-06-18 01:17:31 +02:00
|
|
|
onFilterByTag={(tagId, name) => {
|
|
|
|
|
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
|
|
|
|
|
setPage("feed");
|
|
|
|
|
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
|
|
|
|
|
}}
|
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.
2026-06-11 20:45:48 +02:00
|
|
|
/>
|
2026-06-19 11:48:20 +02:00
|
|
|
) : page === "stats" ? (
|
|
|
|
|
<Stats me={meQuery.data!} />
|
2026-06-16 14:38:51 +02:00
|
|
|
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
|
|
|
|
|
<Scheduler />
|
2026-06-19 12:23:00 +02:00
|
|
|
) : page === "config" && meQuery.data!.role === "admin" ? (
|
|
|
|
|
<ConfigPanel />
|
2026-06-19 14:16:48 +02:00
|
|
|
) : page === "users" && meQuery.data!.role === "admin" ? (
|
|
|
|
|
<AdminUsers me={meQuery.data!} />
|
2026-06-15 14:45:18 +02:00
|
|
|
) : page === "playlists" ? (
|
2026-06-15 21:23:13 +02:00
|
|
|
<Playlists canWrite={meQuery.data!.can_write} />
|
2026-06-18 03:20:17 +02:00
|
|
|
) : page === "notifications" ? (
|
2026-06-25 22:34:24 +02:00
|
|
|
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
|
|
|
|
|
) : page === "messages" && !meQuery.data!.is_demo ? (
|
|
|
|
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
|
|
|
|
<Messages meId={meQuery.data!.id} />
|
|
|
|
|
</div>
|
2026-06-16 01:05:05 +02:00
|
|
|
) : page === "settings" ? (
|
|
|
|
|
<SettingsPanel
|
|
|
|
|
me={meQuery.data!}
|
2026-06-18 23:59:40 +02:00
|
|
|
prefs={prefsCtl}
|
2026-06-16 01:05:05 +02:00
|
|
|
onOpenWizard={() => setWizardOpen(true)}
|
|
|
|
|
/>
|
2026-06-12 02:47:55 +02:00
|
|
|
) : (
|
2026-06-14 06:36:12 +02:00
|
|
|
<Feed
|
|
|
|
|
filters={filters}
|
|
|
|
|
setFilters={setFilters}
|
|
|
|
|
view={view}
|
|
|
|
|
canRead={meQuery.data!.can_read}
|
2026-06-16 09:27:34 +02:00
|
|
|
isDemo={meQuery.data!.is_demo}
|
2026-06-14 06:36:12 +02:00
|
|
|
onOpenWizard={() => setWizardOpen(true)}
|
2026-06-29 02:01:44 +02:00
|
|
|
ytSearch={ytSearch}
|
2026-06-29 23:03:25 +02:00
|
|
|
onYtSearch={enterYtSearch}
|
|
|
|
|
onExitYtSearch={exitYtSearch}
|
2026-06-30 03:08:52 +02:00
|
|
|
onOpenChannel={openChannel}
|
2026-06-14 06:36:12 +02:00
|
|
|
/>
|
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.
2026-06-11 20:45:48 +02:00
|
|
|
)}
|
2026-06-16 00:42:23 +02:00
|
|
|
</main>
|
|
|
|
|
</div>
|
2026-06-30 03:08:52 +02:00
|
|
|
</>
|
|
|
|
|
)}
|
2026-06-17 14:28:29 +02:00
|
|
|
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
|
|
|
|
|
Anchored inside the content column so they clear the sidebar automatically
|
|
|
|
|
(collapsed or expanded) without tracking its width. */}
|
|
|
|
|
<Toaster />
|
2026-06-11 02:19:47 +02:00
|
|
|
</div>
|
2026-06-25 22:34:24 +02:00
|
|
|
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
2026-06-16 09:27:34 +02:00
|
|
|
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
2026-06-14 01:11:29 +02:00
|
|
|
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
|
|
|
|
)}
|
2026-06-15 00:06:57 +02:00
|
|
|
{aboutOpen && (
|
|
|
|
|
<About
|
|
|
|
|
onClose={() => setAboutOpen(false)}
|
|
|
|
|
onOpenReleaseNotes={() => {
|
|
|
|
|
setAboutOpen(false);
|
|
|
|
|
openReleaseNotes();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{notesOpen && (
|
|
|
|
|
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
|
|
|
|
)}
|
2026-06-18 01:17:31 +02:00
|
|
|
<ErrorDialog />
|
2026-06-30 05:02:42 +02:00
|
|
|
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
|
|
|
|
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
2026-06-11 02:19:47 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|