refactor(channels): adopt shared Tabs + usePersistedTab for the manager tab row (R13 S2b)

Retire the hand-rolled channel-manager view pills for the shared <Tabs> component (given an
optional className so it embeds in the justify-between header without the default mb-4), and
back view + layout on usePersistedTab instead of manual getAccountRaw/setAccountRaw useState.
That subsumes the account-pin re-hydrate (usePersistedTab now re-reads on the pin), so
ChannelsProvider's bespoke markManagerDirty/useAccountRehydrate is gone; setView keeps the
history-tab logic (_chTab pushState + popstate) layered on the stable raw setter, and
useAccountPersistedState's setter is now memoized so that stable action holds.

Per the user's call, unify every channel-manager pill on the rounded-lg idiom: the view tabs
via <Tabs>, the sync chips and tag chips + the "manage tags" affordance from rounded-full to
rounded-lg — no more odd-one-out rounded-full.

gate-green: tsc + vitest 120 + eslint-0 + knip + prettier. Self-E2E'd: tab switch, browser
Back across tabs (Blocked->Hidden->Subscriptions with localStorage in sync), and a fresh tab
restoring view=hidden on the FIRST load; zero console errors.
This commit is contained in:
2026-08-14 03:50:43 +02:00
parent e6688edbcc
commit 735d08c91d
4 changed files with 42 additions and 54 deletions
+14 -33
View File
@@ -8,12 +8,12 @@ import {
useState,
type ReactNode,
} from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { getAccountRaw, LS } from "../lib/storage";
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
import { type SortState } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { usePersistedTab } from "./Tabs";
import type { FilterMap } from "./tableHeader";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import type { ChannelsView } from "./Channels";
@@ -117,12 +117,14 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// 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);
const [view, setViewState] = useState<ChannelsView>(() =>
clampView(getAccountRaw(LS.channelsView)),
);
const [layout, setLayoutState] = useState<ChannelLayout>(() =>
parseChannelLayout(getAccountRaw(LS.channelLayout)),
);
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so
// they get init-from-storage AND the account-pin re-hydrate for free (this provider mounts above
// <App>, which pins the account only after /api/me). Stored un-clamped; clampView/parseChannelLayout
// heal them for display. setView layers the history logic (below) on top of the raw setter.
const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
const view = clampView(viewRaw);
const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "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);
@@ -130,15 +132,6 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
const [focusName, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0);
// view + layout (like sort/filters) initialise from an unknown account on first load, because this
// provider mounts above <App>, which pins the account only after /api/me resolves. Re-read them once
// the account is known. Uses the raw state setters (not setView/setLayout) so it's a silent
// correction, not a history-pushing navigation; markManagerDirty guards a user switch made pre-pin.
const markManagerDirty = useAccountRehydrate(() => {
setViewState(clampView(getAccountRaw(LS.channelsView)));
setLayoutState(parseChannelLayout(getAccountRaw(LS.channelLayout)));
});
// 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).
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
@@ -154,9 +147,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// 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;
markManagerDirty();
setViewState(v);
setAccountRaw(LS.channelsView, v);
setViewRaw(v); // persists + guards the account-pin re-hydrate (via usePersistedTab)
// 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.
@@ -166,15 +157,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
}
},
[markManagerDirty],
);
const setLayout = useCallback(
(l: ChannelLayout) => {
markManagerDirty();
setLayoutState(l);
setAccountRaw(LS.channelLayout, l);
},
[markManagerDirty],
[setViewRaw],
);
const focusChannel = useCallback(
(name: string) => {
@@ -212,13 +195,11 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
function onPop(e: PopStateEvent) {
const tab = e.state?._chTab;
if (!tab) return; // an entry outside the manager — leave the tab alone
const next = clampView(tab as string);
setViewState(next);
setAccountRaw(LS.channelsView, next);
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 }),