- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
248 lines
12 KiB
TypeScript
248 lines
12 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { useLatest } from "../lib/useLatest";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMe } from "../lib/useMe";
|
|
import { resolvePage, urlWithParams, type Page } from "../lib/urlState";
|
|
import { LOGS_KEYS } from "../lib/logsUrlState";
|
|
import { pageTitleKey } from "../lib/pageMeta";
|
|
import { useDocumentObjectTitle } from "../lib/documentTitle";
|
|
import { setAccountRaw } from "../lib/storage";
|
|
import {
|
|
backStaysInDocument,
|
|
closeOverlaysThenNavigate,
|
|
nextNavId,
|
|
notifyNav,
|
|
rememberNavPosition,
|
|
} from "../lib/history";
|
|
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
|
|
import {
|
|
NavigationActionsContext,
|
|
NavigationStateContext,
|
|
type NavigationActions,
|
|
type NavigationState,
|
|
} from "./navigationContext";
|
|
|
|
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
|
|
// consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree.
|
|
// Mirrors ConfirmProvider (context + default value + one-line hooks); composed in main.tsx inside
|
|
// ConfirmProvider, around <App/>.
|
|
//
|
|
// The contexts, their value types and the hooks live in navigationContext.ts.
|
|
|
|
export function NavigationProvider({ children }: { children: ReactNode }) {
|
|
const { t, i18n } = useTranslation();
|
|
const [page, setPageState] = useState<Page>(loadInitialPage);
|
|
// 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) — UNLESS it was zero-quota scrape-sourced, which rides
|
|
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
|
|
// Back steps player → search → feed instead of the search vanishing without a history entry.
|
|
const [ytSearch, setYtSearch] = useState<string | null>(() => {
|
|
const st = window.history.state;
|
|
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
|
|
});
|
|
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
|
|
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
|
|
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
|
|
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
|
|
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
|
|
channelViewFrom(window.history.state, window.location.search),
|
|
);
|
|
|
|
// Latest-value refs so the STABLE action callbacks can read the latest state without depending on
|
|
// it — a dep would recreate them on every navigation and re-render every actions consumer (e.g.
|
|
// VideoCard). Only READ in event handlers, after commit.
|
|
const pageRef = useLatest(page);
|
|
const channelViewRef = useLatest(channelView);
|
|
|
|
const setPage = useCallback(
|
|
(next: Page, then?: () => void) =>
|
|
// Never under an open overlay — see the interface doc. The refs are read when the push
|
|
// actually happens, which over an open player is a moment after this call.
|
|
closeOverlaysThenNavigate(() => {
|
|
// A channel page 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 === pageRef.current && !channelViewRef.current) {
|
|
then?.();
|
|
return;
|
|
}
|
|
setChannelView(null); // leave any open channel page when navigating via the rail
|
|
setPageState(next);
|
|
setAccountRaw(PAGE_KEY, next);
|
|
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
|
|
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
|
|
// root, and the address bar reflects the page (feed is implicit) while clearing the
|
|
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
|
|
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
|
|
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
|
|
// their page. The item it was opened from is still focused: the state setters above have not
|
|
// re-rendered anything yet.
|
|
rememberNavPosition();
|
|
window.history.pushState(
|
|
{ sfPage: next, sfScroll: nextNavId() },
|
|
"",
|
|
urlWithParams({
|
|
page: next === "feed" ? null : next,
|
|
chan: null,
|
|
v: null,
|
|
pl: null,
|
|
pscope: null,
|
|
psort: null,
|
|
pshow: null,
|
|
pf: null,
|
|
ctab: null,
|
|
csort: null,
|
|
cf: null,
|
|
...Object.fromEntries(LOGS_KEYS.map((k) => [k, null])),
|
|
}),
|
|
);
|
|
notifyNav();
|
|
then?.();
|
|
}),
|
|
[pageRef, channelViewRef],
|
|
);
|
|
|
|
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
|
|
// under an open overlay: the player's channel link and the palette's channel rows call it from
|
|
// inside one.
|
|
const openChannel = useCallback((id: string, name?: string) => {
|
|
closeOverlaysThenNavigate(() => {
|
|
const url = urlWithParams({ chan: id });
|
|
if (window.history.state?._chan) {
|
|
const st = window.history.state;
|
|
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
|
} else {
|
|
const st = rememberNavPosition();
|
|
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
|
}
|
|
setChannelView({ id, name });
|
|
});
|
|
}, []);
|
|
|
|
const closeChannel = useCallback(() => {
|
|
if (window.history.state?._chan && backStaysInDocument()) window.history.back();
|
|
else {
|
|
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
|
|
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
|
|
// entry's channel marks and the ?chan param.
|
|
setChannelView(null);
|
|
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
|
|
window.history.replaceState(st, "", urlWithParams({ chan: null }));
|
|
}
|
|
}, []);
|
|
|
|
const enterYtSearch = useCallback((q: string) => {
|
|
setYtSearch(q);
|
|
// 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 || {};
|
|
if (st._yt)
|
|
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
|
|
else {
|
|
// New sub-view entry.
|
|
const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
|
|
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
|
|
}
|
|
}, []);
|
|
|
|
const exitYtSearch = useCallback(() => {
|
|
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
|
|
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
|
|
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
|
|
else {
|
|
setYtSearch(null);
|
|
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
|
|
window.history.replaceState(st, "");
|
|
}
|
|
}, []);
|
|
|
|
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
|
|
// DURING the render that changes the page, against the page we were last on: as an effect it ran
|
|
// after the new page had already committed, so for one frame a module rendered with a live search
|
|
// still in the nav state. Keyed on the CHANGE rather than derived from `page`, because "ended" has
|
|
// to be permanent — a derived null would quietly come back when you returned to the feed.
|
|
const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
|
|
if (pageSearchWasOn !== page) {
|
|
setPageSearchWasOn(page);
|
|
if (page !== "feed") setYtSearch(null);
|
|
}
|
|
|
|
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
|
|
// "<object> · <module> · Siftlode". The object is the open view's own heading (a channel, a Plex
|
|
// title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same
|
|
// as one opened in the app. Signed out, the page on screen is the public landing whatever `page`
|
|
// says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19).
|
|
const signedIn = useMe() != null;
|
|
const objectTitle = useDocumentObjectTitle();
|
|
useEffect(() => {
|
|
const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : [];
|
|
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
|
|
}, [signedIn, page, objectTitle, i18n.language, t]);
|
|
|
|
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
|
|
// entry: every later page change writes its own entry through setPage.
|
|
const [mountPage] = useState(page);
|
|
|
|
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
|
|
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
|
|
// the popstate handler only needs to read history.state.
|
|
useEffect(() => {
|
|
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
|
|
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
|
|
// are always kept so a channel page survives F5.
|
|
const st = window.history.state || {};
|
|
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
|
|
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
|
|
// ?chan / ?v / ?pl / filter params the loaders already read.
|
|
const url = urlWithParams({ page: mountPage === "feed" ? null : mountPage });
|
|
if (st._yt && st._ytScrape) {
|
|
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
|
|
} else {
|
|
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
|
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
|
|
}
|
|
function onPop(e: PopStateEvent) {
|
|
// An entry can name a page this build no longer has (one pushed before a module was removed,
|
|
// reached by Back after a reload onto the new build); that lands where the page's content went
|
|
// (`resolvePage`), else on the feed, not on a crash. The entry is rewritten to say so: left
|
|
// alone, the address bar would keep naming the dead page and a click on the page it landed on
|
|
// would be a no-op (setPage's same-page guard). It spreads the LIVE entry, not `e.state`:
|
|
// lib/history's listener ran first and already took the spent `sfNav` off it.
|
|
const sf: unknown = e.state?.sfPage;
|
|
const p: Page = (typeof sf === "string" && resolvePage(sf)) || "feed";
|
|
if (typeof sf === "string" && sf !== p)
|
|
window.history.replaceState(
|
|
{ ...window.history.state, sfPage: p },
|
|
"",
|
|
urlWithParams({ page: p === "feed" ? null : p }),
|
|
);
|
|
setPageState(p);
|
|
setAccountRaw(PAGE_KEY, p);
|
|
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
|
|
// restores or clears them to match the entry we landed on.
|
|
setYtSearch((e.state?._yt as string) ?? null);
|
|
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
|
|
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
|
|
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
|
|
// shares this resolution with the seed initializer above.
|
|
setChannelView(channelViewFrom(e.state, window.location.search));
|
|
}
|
|
window.addEventListener("popstate", onPop);
|
|
return () => window.removeEventListener("popstate", onPop);
|
|
}, [mountPage]);
|
|
|
|
const state = useMemo<NavigationState>(
|
|
() => ({ page, channelView, ytSearch }),
|
|
[page, channelView, ytSearch],
|
|
);
|
|
const actions = useMemo<NavigationActions>(
|
|
() => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }),
|
|
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch],
|
|
);
|
|
|
|
return (
|
|
<NavigationActionsContext.Provider value={actions}>
|
|
<NavigationStateContext.Provider value={state}>{children}</NavigationStateContext.Provider>
|
|
</NavigationActionsContext.Provider>
|
|
);
|
|
}
|