refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
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, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
|
||||
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
|
||||
import { pageTitleKey } from "../../lib/pageMeta";
|
||||
import { useDocumentObjectTitle } from "../../lib/documentTitle";
|
||||
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
|
||||
import {
|
||||
backStaysInDocument,
|
||||
closeOverlaysThenNavigate,
|
||||
nextNavId,
|
||||
notifyNav,
|
||||
rememberNavPosition,
|
||||
} from "../../lib/history";
|
||||
import { reachablePage } from "../../lib/modules";
|
||||
import {
|
||||
channelViewFrom,
|
||||
loadInitialPage,
|
||||
PAGE_KEY,
|
||||
START_ON_HOME_OPTIONS,
|
||||
START_ON_HOME_PREF,
|
||||
type ChannelView,
|
||||
} from "./navigationInit";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
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.
|
||||
|
||||
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */
|
||||
function pageRootParams(page: Page): Record<string, string | null> {
|
||||
return {
|
||||
page: page === "feed" ? null : page,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
|
||||
};
|
||||
}
|
||||
|
||||
export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
|
||||
// this browser held reaches the server at once.
|
||||
const [startOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS);
|
||||
const [chosenPage, setPageState] = useState<Page>(() => loadInitialPage(startOnHome));
|
||||
// The page on screen: the chosen one when this account may open it, else the feed — derived, so a
|
||||
// link, a Back and a role taken away mid-session all land the same way, and the rail, the content
|
||||
// and the address bar (stamped from it below) agree.
|
||||
const me = useMe();
|
||||
const page = reachablePage(chosenPage, me);
|
||||
// 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),
|
||||
);
|
||||
// A page this account can no longer open (a role taken away, a module switched off mid-session)
|
||||
// is given up, not kept behind the feed: kept, the address bar would go on naming it, a click on
|
||||
// Feed would be a no-op (setPage's same-page guard sees the feed already on screen), and a later
|
||||
// grant would switch the page by itself. A channel page open over it closes with it, as on any
|
||||
// page switch. Adjusted during render; the history entry and the remembered page follow in the
|
||||
// effect below.
|
||||
const [givenUp, setGivenUp] = useState(0);
|
||||
if (page !== chosenPage) {
|
||||
setPageState(page);
|
||||
setChannelView(null);
|
||||
setGivenUp((n) => n + 1);
|
||||
}
|
||||
|
||||
// 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(pageRootParams(next)),
|
||||
);
|
||||
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 = me != 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 given up above is the feed, and the current entry says so (replaced, not pushed: no
|
||||
// step was taken). Its own keys go with it, and the channel page closed with it, as on any page
|
||||
// switch — left in the entry, a reload would reopen what the screen no longer shows.
|
||||
useEffect(() => {
|
||||
if (!givenUp) return;
|
||||
setAccountRaw(PAGE_KEY, "feed");
|
||||
const { _chan: _closedChan, _chanName: _closedName, ...rest } = window.history.state ?? {};
|
||||
window.history.replaceState(
|
||||
{ ...rest, sfPage: "feed" },
|
||||
"",
|
||||
urlWithParams(pageRootParams("feed")),
|
||||
);
|
||||
}, [givenUp]);
|
||||
|
||||
// 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.
|
||||
// A retired page's link — or the remembered last page, when that is what opened — also opens its
|
||||
// part of the page that replaced it (an `at` tab).
|
||||
const linked = new URLSearchParams(window.location.search).get("page");
|
||||
const remembered = getAccountRaw(PAGE_KEY);
|
||||
const url = urlWithParams({
|
||||
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
|
||||
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({ ...retiredParams(sf), 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user