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:
@@ -12,6 +12,7 @@ import { buildChannelColumns } from "./channelColumns";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import DataTable from "./DataTable";
|
import DataTable from "./DataTable";
|
||||||
|
import Tabs, { type TabDef } from "./Tabs";
|
||||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||||
@@ -299,7 +300,7 @@ export default function Channels() {
|
|||||||
key={c.value || "all"}
|
key={c.value || "all"}
|
||||||
onClick={() => setFilters({ ...filters, sync: c.value })}
|
onClick={() => setFilters({ ...filters, sync: c.value })}
|
||||||
aria-pressed={syncFilter === c.value}
|
aria-pressed={syncFilter === c.value}
|
||||||
className="pill text-xs px-2.5 py-1 rounded-full border"
|
className="pill text-xs px-2.5 py-1 rounded-lg border"
|
||||||
>
|
>
|
||||||
{t(c.labelKey)}
|
{t(c.labelKey)}
|
||||||
</button>
|
</button>
|
||||||
@@ -431,20 +432,17 @@ export default function Channels() {
|
|||||||
}, [filters, search, sort, resetPage]);
|
}, [filters, search, sort, resetPage]);
|
||||||
const cardPager = <Pager {...pagerProps} />;
|
const cardPager = <Pager {...pagerProps} />;
|
||||||
|
|
||||||
|
const viewTabs: TabDef[] = (
|
||||||
|
["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]
|
||||||
|
).map((v) => ({ id: v, label: t(`channels.tabs.${v}`) }));
|
||||||
const tabs = (
|
const tabs = (
|
||||||
<div className="px-4 pt-4 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
<div className="px-4 pt-4 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<Tabs
|
||||||
{(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => (
|
tabs={viewTabs}
|
||||||
<button
|
active={view}
|
||||||
key={v}
|
onChange={(id) => setView(id as ChannelsView)}
|
||||||
onClick={() => setView(v)}
|
className="flex flex-wrap items-center gap-1"
|
||||||
aria-pressed={view === v}
|
/>
|
||||||
className="pill text-sm px-3 py-1.5 rounded-full border"
|
|
||||||
>
|
|
||||||
{t(`channels.tabs.${v}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{view === "subscribed" && statsBlock}
|
{view === "subscribed" && statsBlock}
|
||||||
{view === "subscribed" && layout === "cards" && sortControl}
|
{view === "subscribed" && layout === "cards" && sortControl}
|
||||||
@@ -476,7 +474,7 @@ export default function Channels() {
|
|||||||
key={tg.id}
|
key={tg.id}
|
||||||
onClick={() => toggleTagFilter(tg.id)}
|
onClick={() => toggleTagFilter(tg.id)}
|
||||||
aria-pressed={tagFilter.includes(String(tg.id))}
|
aria-pressed={tagFilter.includes(String(tg.id))}
|
||||||
className="pill text-xs px-2 py-1 rounded-full border"
|
className="pill text-xs px-2 py-1 rounded-lg border"
|
||||||
>
|
>
|
||||||
{tg.name}
|
{tg.name}
|
||||||
</button>
|
</button>
|
||||||
@@ -491,7 +489,7 @@ export default function Channels() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTagManagerOpen(true)}
|
onClick={() => setTagManagerOpen(true)}
|
||||||
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-lg border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3 h-3" />
|
<Pencil className="w-3 h-3" />
|
||||||
{t("channels.tags.manage")}
|
{t("channels.tags.manage")}
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS } from "../lib/storage";
|
||||||
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
||||||
import { type SortState } from "../lib/columnSort";
|
import { type SortState } from "../lib/columnSort";
|
||||||
import { useSortState } from "../lib/useSortState";
|
import { useSortState } from "../lib/useSortState";
|
||||||
import { useFilterState } from "../lib/useFilterState";
|
import { useFilterState } from "../lib/useFilterState";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
import { usePersistedTab } from "./Tabs";
|
||||||
import type { FilterMap } from "./tableHeader";
|
import type { FilterMap } from "./tableHeader";
|
||||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
import type { ChannelsView } from "./Channels";
|
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
|
// 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.
|
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||||
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||||
const [view, setViewState] = useState<ChannelsView>(() =>
|
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so
|
||||||
clampView(getAccountRaw(LS.channelsView)),
|
// 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
|
||||||
const [layout, setLayoutState] = useState<ChannelLayout>(() =>
|
// heal them for display. setView layers the history logic (below) on top of the raw setter.
|
||||||
parseChannelLayout(getAccountRaw(LS.channelLayout)),
|
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
|
// 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).
|
// it against the live columns for display, so the provider stores it un-healed (no options passed).
|
||||||
const [sort, setSort] = useSortState(LS.channelSort);
|
const [sort, setSort] = useSortState(LS.channelSort);
|
||||||
@@ -130,15 +132,6 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
const [focusName, setFocusName] = useState<string | null>(null);
|
const [focusName, setFocusName] = useState<string | null>(null);
|
||||||
const [focusToken, setFocusToken] = useState(0);
|
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
|
// 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).
|
// 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`.
|
// 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
|
// 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.
|
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
|
||||||
if (v === viewRef.current) return;
|
if (v === viewRef.current) return;
|
||||||
markManagerDirty();
|
setViewRaw(v); // persists + guards the account-pin re-hydrate (via usePersistedTab)
|
||||||
setViewState(v);
|
|
||||||
setAccountRaw(LS.channelsView, v);
|
|
||||||
// Only own an entry while the manager is already on screen. Arriving from elsewhere
|
// 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
|
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
|
||||||
// one navigation never costs two Back presses.
|
// one navigation never costs two Back presses.
|
||||||
@@ -166,15 +157,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[markManagerDirty],
|
[setViewRaw],
|
||||||
);
|
|
||||||
const setLayout = useCallback(
|
|
||||||
(l: ChannelLayout) => {
|
|
||||||
markManagerDirty();
|
|
||||||
setLayoutState(l);
|
|
||||||
setAccountRaw(LS.channelLayout, l);
|
|
||||||
},
|
|
||||||
[markManagerDirty],
|
|
||||||
);
|
);
|
||||||
const focusChannel = useCallback(
|
const focusChannel = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
@@ -212,13 +195,11 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
function onPop(e: PopStateEvent) {
|
function onPop(e: PopStateEvent) {
|
||||||
const tab = e.state?._chTab;
|
const tab = e.state?._chTab;
|
||||||
if (!tab) return; // an entry outside the manager — leave the tab alone
|
if (!tab) return; // an entry outside the manager — leave the tab alone
|
||||||
const next = clampView(tab as string);
|
setViewRaw(clampView(tab as string)); // persists the restored tab (setViewRaw is stable)
|
||||||
setViewState(next);
|
|
||||||
setAccountRaw(LS.channelsView, next);
|
|
||||||
}
|
}
|
||||||
window.addEventListener("popstate", onPop);
|
window.addEventListener("popstate", onPop);
|
||||||
return () => window.removeEventListener("popstate", onPop);
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
}, []);
|
}, [setViewRaw]);
|
||||||
|
|
||||||
const state = useMemo<ChannelsState>(
|
const state = useMemo<ChannelsState>(
|
||||||
() => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
|
() => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ export default function Tabs({
|
|||||||
tabs,
|
tabs,
|
||||||
active,
|
active,
|
||||||
onChange,
|
onChange,
|
||||||
|
className = "flex flex-wrap gap-1 mb-4",
|
||||||
}: {
|
}: {
|
||||||
tabs: TabDef[];
|
tabs: TabDef[];
|
||||||
active: string;
|
active: string;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
|
// Override the row wrapper — e.g. the channel manager embeds the rail in a justify-between header
|
||||||
|
// and drops the default bottom margin. Omit for the standalone-row default.
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1 mb-4">
|
<div className={className}>
|
||||||
{tabs.map((tb) => {
|
{tabs.map((tb) => {
|
||||||
const on = tb.id === active;
|
const on = tb.id === active;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -159,11 +159,16 @@ export function useAccountPersistedState(
|
|||||||
): [string, (value: string) => void] {
|
): [string, (value: string) => void] {
|
||||||
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
|
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
|
||||||
const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback));
|
const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback));
|
||||||
const set = (next: string) => {
|
// Stable setter (base + markDirty are both stable) so a provider can build a stable action on top
|
||||||
|
// of it — e.g. ChannelsProvider's setView, which must not change identity on every tab switch.
|
||||||
|
const set = useCallback(
|
||||||
|
(next: string) => {
|
||||||
markDirty();
|
markDirty();
|
||||||
setValue(next);
|
setValue(next);
|
||||||
setAccountRaw(base, next);
|
setAccountRaw(base, next);
|
||||||
};
|
},
|
||||||
|
[base, markDirty],
|
||||||
|
);
|
||||||
return [value, set];
|
return [value, set];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user