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,259 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getAccountRaw, LS, useAccountPersistedState } from "../../lib/storage";
|
||||
import { parseChannelLayout } from "../../lib/channelLayout";
|
||||
import { hydrateSort } from "../../lib/columnSort";
|
||||
import { useLatest } from "../../lib/useLatest";
|
||||
import { useSortState } from "../../lib/useSortState";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { useFilterState } from "../../lib/useFilterState";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { rememberNavPosition } from "../../lib/history";
|
||||
import {
|
||||
channelsParamsToState,
|
||||
channelsParamUpdates,
|
||||
hasChannelsParams,
|
||||
type ChannelsUrlState,
|
||||
} from "../../lib/channelsUrlState";
|
||||
import type { FilterMap } from "../../lib/tableColumns";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
import {
|
||||
ChannelLayoutContext,
|
||||
ChannelsActionsContext,
|
||||
ChannelsStateContext,
|
||||
type ChannelsActions,
|
||||
type ChannelsState,
|
||||
} from "./channelsContext";
|
||||
|
||||
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
|
||||
// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
|
||||
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
|
||||
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
|
||||
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||
//
|
||||
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
|
||||
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
|
||||
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
|
||||
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
|
||||
//
|
||||
// The contexts, their value types and the hooks live in channelsContext.ts.
|
||||
|
||||
function clampView(v: string | null): ChannelsView {
|
||||
return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
|
||||
}
|
||||
|
||||
// One-time carry-forward of the retired single-value sync chip (LS.legacyChannelFilter, vocab
|
||||
// all/needs_full/fully_synced) into the new filter map's `sync` key (vocab ""/needsFull/fully), so a
|
||||
// user who had a chip pinned keeps it. Only consulted when channelsFilterMap is still empty.
|
||||
function migrateSyncChip(): FilterMap | null {
|
||||
const old = getAccountRaw(LS.legacyChannelFilter);
|
||||
if (old === "needs_full") return { sync: "needsFull" };
|
||||
if (old === "fully_synced") return { sync: "fully" };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The manager's stored column-filter map, healed to a plain string/string[] object (same discipline as
|
||||
// useFilterState's hydrate). Used only to build the seed's base — the URL keeps whatever the stored map
|
||||
// has for any dimension a partial shared link omits.
|
||||
function readStoredFilters(): FilterMap {
|
||||
const raw = getAccountRaw(LS.channelsFilterMap);
|
||||
if (raw) {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) return v as FilterMap;
|
||||
} catch {
|
||||
/* fall through to the migrate seed */
|
||||
}
|
||||
}
|
||||
return migrateSyncChip() ?? {};
|
||||
}
|
||||
|
||||
// A shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
|
||||
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so
|
||||
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
|
||||
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasChannelsParams(params)) return null;
|
||||
return channelsParamsToState(params, {
|
||||
view: clampView(getAccountRaw(LS.channelsView)),
|
||||
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
||||
filters: readStoredFilters(),
|
||||
});
|
||||
}
|
||||
|
||||
export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const { page } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
|
||||
const [search, setSearchState] = useState("");
|
||||
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
|
||||
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||
// The view is this browser's (useAccountPersistedState), the layout an account preference
|
||||
// (usePref). Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers
|
||||
// the history logic (below) on top of the raw setter.
|
||||
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
|
||||
const view = clampView(viewRaw);
|
||||
const [layoutRaw, setLayout] = usePref<string>("channelLayout", "table", {
|
||||
localKey: LS.channelLayout,
|
||||
parse: (raw) => (typeof raw === "string" ? raw : "table"),
|
||||
});
|
||||
const layout = parseChannelLayout(layoutRaw);
|
||||
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
|
||||
// it against the live columns for display, so the provider stores it un-healed (no options passed).
|
||||
const [sort, setSort] = useSortState(LS.channelSort);
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusNameRaw, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
|
||||
// Shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
const [urlSeed] = useState(readChannelsUrlSeed);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
|
||||
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
|
||||
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
|
||||
// `view` is frozen at mount for the same reason `urlSeed` is: the comparison is against the tab
|
||||
// this load started on, and the three setters are stable by construction (useFilterState,
|
||||
// useSortState and useAccountPersistedState all return a useCallback). So the dep list can name
|
||||
// every value the body reads without the seed re-arming on a later tab switch.
|
||||
const [mountView] = useState(view);
|
||||
useLayoutEffect(() => {
|
||||
if (!urlSeed || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
if (urlSeed.view !== mountView) setViewRaw(urlSeed.view);
|
||||
setFilters(urlSeed.filters);
|
||||
setSort(urlSeed.sort);
|
||||
}, [urlSeed, mountView, setViewRaw, setFilters, setSort]);
|
||||
|
||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||
// it (a dep would recreate the action on every switch and re-render every actions consumer).
|
||||
const viewRef = useLatest(view);
|
||||
|
||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
|
||||
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
|
||||
// of jumping straight out to the previous module.
|
||||
const setView = useCallback(
|
||||
(v: ChannelsView) => {
|
||||
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
|
||||
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
|
||||
if (v === viewRef.current) return;
|
||||
setViewRaw(v); // persists (via useAccountPersistedState)
|
||||
// Only own an entry while the manager is already on screen. Arriving from elsewhere
|
||||
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
|
||||
// one navigation never costs two Back presses.
|
||||
if (window.history.state?.sfPage === "channels") {
|
||||
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
|
||||
// Back re-open the channel overlay or a spent search on top of the tab.
|
||||
rememberNavPosition();
|
||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||
}
|
||||
},
|
||||
[setViewRaw, viewRef],
|
||||
);
|
||||
const focusChannel = useCallback(
|
||||
(name: string) => {
|
||||
setFocusName(name);
|
||||
setFocusToken((n) => n + 1); // re-seed even when the same channel is re-focused
|
||||
setView("subscribed"); // the name filter applies to the subscriptions table
|
||||
setPage("channels");
|
||||
},
|
||||
[setView, setPage],
|
||||
);
|
||||
const goToFullHistory = useCallback(() => {
|
||||
// One controlled map replaces the old status chip: sync=needsFull, and dropping every other
|
||||
// column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
|
||||
setFilters({ sync: "needsFull" });
|
||||
setView("subscribed"); // the sync filter applies to the subscriptions tab
|
||||
setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips
|
||||
setPage("channels");
|
||||
}, [setFilters, setView, setPage]);
|
||||
|
||||
// The manager focus only means anything ON the Channels page, so it is DERIVED from the page
|
||||
// rather than cleared by an effect when you leave. focusChannel sets the name and navigates in the
|
||||
// same handler, so the value is live by the time channels renders; everywhere else it reads null,
|
||||
// one render earlier than the effect used to make it.
|
||||
//
|
||||
// But an intent has to be CONSUMED, not merely masked, and the consumed marker has to live HERE.
|
||||
// Channels is a page chunk that unmounts on every navigation (and on opening a channel page, which
|
||||
// does not even change `page`), so its seeding effect runs again on each remount. A marker inside
|
||||
// it would reset with it and re-apply an intent from earlier in the session over whatever the user
|
||||
// has since typed into the search box. Keyed on the token rather than the name, because
|
||||
// re-focusing the same channel is a new intent.
|
||||
const [focusApplied, setFocusApplied] = useState(0);
|
||||
const consumeFocus = useCallback(() => setFocusApplied(focusToken), [focusToken]);
|
||||
const focusName = page === "channels" && focusApplied !== focusToken ? focusNameRaw : null;
|
||||
|
||||
// Label the entry the manager was REACHED on (setPage pushes a bare { sfPage }), so stepping back
|
||||
// onto it later restores the tab it belonged to rather than leaving whatever tab is current.
|
||||
useEffect(() => {
|
||||
if (page !== "channels") return;
|
||||
const st = window.history.state || {};
|
||||
if (st._chTab === undefined) window.history.replaceState({ ...st, _chTab: view }, "");
|
||||
}, [page, view]);
|
||||
|
||||
// Back/Forward across tab entries: restore the tab the entry carries.
|
||||
useEffect(() => {
|
||||
function onPop(e: PopStateEvent) {
|
||||
const tab = e.state?._chTab;
|
||||
if (!tab) return; // an entry outside the manager — leave the tab alone
|
||||
setViewRaw(clampView(tab as string)); // persists the restored tab (setViewRaw is stable)
|
||||
}
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, [setViewRaw]);
|
||||
|
||||
const state = useMemo<ChannelsState>(
|
||||
() => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
|
||||
[search, filters, view, sort, filtersResetToken, focusName, focusToken],
|
||||
);
|
||||
const actions = useMemo<ChannelsActions>(
|
||||
() => ({
|
||||
setSearch,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel,
|
||||
consumeFocus,
|
||||
goToFullHistory,
|
||||
}),
|
||||
[
|
||||
setSearch,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel,
|
||||
consumeFocus,
|
||||
goToFullHistory,
|
||||
],
|
||||
);
|
||||
|
||||
// Mirror the content state (tab + sort + column filters) into the address bar under the namespaced
|
||||
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
|
||||
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
|
||||
// deliberately transient rail-search, like the feed's and plex's.
|
||||
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
|
||||
|
||||
return (
|
||||
<ChannelsActionsContext.Provider value={actions}>
|
||||
<ChannelLayoutContext.Provider value={layout}>
|
||||
<ChannelsStateContext.Provider value={state}>{children}</ChannelsStateContext.Provider>
|
||||
</ChannelLayoutContext.Provider>
|
||||
</ChannelsActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user