commit c05af751a0b7eac0416e091c9772ad074e2c8ea2
parent 9af7e7bf1ffa687c18dddab1d60e69e7f43ca145
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 1 Jul 2026 23:47:41 -0400
Phase 4: stable channelKey for multi-origin channel selection
Channel selection identity moves from the display name to a stable channelKey
supplied by the SearchData provider. In single-site mode key === the channel
name, so name-keyed filter state, share links, and saved profiles round-trip
byte-for-byte; in hub mode the key will be origin-qualified (makeId(origin,
channelSlug)) so same-named channels from different sites don't collide.
- SearchDataContext: SearchDataValue gains channels: ChannelOption[]
{key,name,groupId}, groups, defaultGroupId, and channelKeyOf(summary).
SingleSiteDataProvider derives the model from the manifest (key=name), with
the summaries-derived fallback preserved.
- TranscriptSearch: consumes the provider's channel model; channelOptions/
exclusion sets are now keyed by channelKey with a channelLabelByKey lookup for
display; passesFilter uses channelKeyOf(t); the group derivation that used to
read the manifest now reads the provider model. Filter-UI checkboxes show the
label, toggle by key.
The MultiSiteDataProvider merge lands with the hub page (Phase 6), where it's
mountable and testable.
Verified: 23/23 filter-profile / share-current-search / profile-row /
browse-all / search-date-filter / modal-share e2e green; all packages typecheck.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 138 insertions(+), 107 deletions(-)
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -2,21 +2,47 @@
// Data source for TranscriptSearch. In single-site mode this is a thin wrapper
// over the same summaries/subs hooks the component used to call directly, so
-// behavior is byte-for-byte unchanged. In hub mode (added in a later phase) a
-// MultiSiteDataProvider merges these feeds across many origins and hands the
-// component one unified, origin-qualified view — so TranscriptSearch itself
-// stays mode-agnostic and reads everything from this context.
+// behavior is byte-for-byte unchanged. In hub mode (added when the hub page
+// lands) a MultiSiteDataProvider merges these feeds across many origins and
+// hands the component one unified, origin-qualified view — so TranscriptSearch
+// itself stays mode-agnostic and reads everything from this context.
-import { createContext, useContext, type ReactNode } from "react";
+import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useSummaries, type SummariesState } from "./summariesCache";
import { useSubsManifest } from "./subsCache";
import type { SubsManifest } from "../lib/manifest";
+import {
+ DEFAULT_GROUP_FALLBACK_ID,
+ FALLBACK_GROUP,
+ resolveChannelGroupId,
+ type ChannelGroup,
+} from "../lib/channelGroups";
+
+// One selectable channel in the filter UI. `key` is the STABLE selection
+// identity that filter state, share links, and saved profiles store:
+// single-site: key === the display name (back-compat with name-keyed state)
+// hub: key === makeId(origin, channelSlug) (collision-free across sites)
+// `name` is always the human label shown in the UI.
+export type ChannelOption = {
+ key: string;
+ name: string;
+ groupId: string;
+};
+
+// Minimal shape of a summary needed to derive its channel key.
+type ChannelKeyable = { channel: string; channelSlug: string };
export type SearchDataValue = {
// Summaries + cross-channel manifest. Merged across origins in hub mode.
summariesState: SummariesState;
// Cross-channel subs manifest (merged across origins in hub mode), or null.
subsManifest: SubsManifest | null;
+ // The channel selection model, resolved mode-appropriately.
+ channels: ChannelOption[];
+ groups: ChannelGroup[];
+ defaultGroupId: string;
+ // Map a summary to its channel selection key (matches ChannelOption.key).
+ channelKeyOf: (t: ChannelKeyable) => string;
};
const SearchDataContext = createContext<SearchDataValue | null>(null);
@@ -30,11 +56,61 @@ export function useSearchData(): SearchDataValue {
}
// Single-site data source: the built-in export site (same-origin, origin="").
+// The channel key IS the display name here, so all name-keyed filter state,
+// share links, and saved profiles round-trip exactly as before.
export function SingleSiteDataProvider({ children }: { children: ReactNode }) {
const summariesState = useSummaries("");
const subsManifest = useSubsManifest("").data ?? null;
+ const manifest = summariesState.manifest;
+
+ const groups = useMemo<ChannelGroup[]>(() => {
+ const src = manifest?.groups;
+ return src && src.length > 0 ? src : [{ ...FALLBACK_GROUP }];
+ }, [manifest]);
+
+ const defaultGroupId = useMemo<string>(() => {
+ const dg = manifest?.defaultGroupId;
+ if (typeof dg === "string" && groups.some((g) => g.id === dg)) return dg;
+ return groups[0]?.id ?? DEFAULT_GROUP_FALLBACK_ID;
+ }, [manifest, groups]);
+
+ const channels = useMemo<ChannelOption[]>(() => {
+ if (manifest?.channels.length) {
+ return manifest.channels
+ .filter((c) => c.name)
+ .map((c) => ({
+ key: c.name,
+ name: c.name,
+ groupId: resolveChannelGroupId(c.groupId, groups, defaultGroupId),
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }
+ // Fallback: no manifest channel list — derive names from loaded summaries.
+ const names = new Set<string>();
+ for (const t of summariesState.summaries) if (t.channel) names.add(t.channel);
+ return Array.from(names)
+ .sort((a, b) => a.localeCompare(b))
+ .map((name) => ({
+ key: name,
+ name,
+ groupId: resolveChannelGroupId(undefined, groups, defaultGroupId),
+ }));
+ }, [manifest, groups, defaultGroupId, summariesState.summaries]);
+
+ const value = useMemo<SearchDataValue>(
+ () => ({
+ summariesState,
+ subsManifest,
+ channels,
+ groups,
+ defaultGroupId,
+ channelKeyOf: (t) => t.channel,
+ }),
+ [summariesState, subsManifest, channels, groups, defaultGroupId],
+ );
+
return (
- <SearchDataContext.Provider value={{ summariesState, subsManifest }}>
+ <SearchDataContext.Provider value={value}>
{children}
</SearchDataContext.Provider>
);
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -62,13 +62,7 @@ import { SearchChartPanel } from "./charts/SearchChartPanel";
import { LayerSwatch } from "./LayerSwatch";
import { ymdToInput, inputToYmd } from "../lib/ymd";
import type { DisplaySummary } from "../lib/transcripts";
-import {
- DEFAULT_GROUP_FALLBACK_ID,
- FALLBACK_GROUP,
- resolveChannelGroupId,
- sortGroups,
- type ChannelGroup,
-} from "../lib/channelGroups";
+import { sortGroups, type ChannelGroup } from "../lib/channelGroups";
type Summary = DisplaySummary;
@@ -258,9 +252,17 @@ export default function TranscriptSearch() {
useEffect(() => setMounted(true), []);
// Data feeds come from the SearchData provider (single-site or, in hub mode,
- // merged across origins) so this component is mode-agnostic.
- const { summariesState, subsManifest } = useSearchData();
- const { manifest, summaries, loadedPages, pageCount, summariesReady } =
+ // merged across origins) so this component is mode-agnostic. `channels` is the
+ // selection model keyed by a stable channelKey (= the channel name in single-
+ // site mode, so name-keyed filter state / share links / profiles round-trip).
+ const {
+ summariesState,
+ subsManifest,
+ channels: channelModel,
+ groups: manifestGroups,
+ channelKeyOf,
+ } = useSearchData();
+ const { summaries, loadedPages, pageCount, summariesReady } =
summariesState;
const transcripts: Summary[] | null = summariesReady ? summaries : null;
@@ -364,120 +366,72 @@ export default function TranscriptSearch() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [committedHash]);
- // ─── Channel-grouping setup (unchanged) ───────────────────────────────────
- const manifestGroups = useMemo<ChannelGroup[]>(() => {
- const src = manifest?.groups;
- if (src && src.length > 0) return src;
- return [{ ...FALLBACK_GROUP }];
- }, [manifest]);
- const manifestDefaultGroupId = useMemo<string>(() => {
- const dg = manifest?.defaultGroupId;
- if (typeof dg === "string" && manifestGroups.some((g) => g.id === dg)) {
- return dg;
- }
- return manifestGroups[0]?.id ?? DEFAULT_GROUP_FALLBACK_ID;
- }, [manifest, manifestGroups]);
-
- const channelGroupByName = useMemo<Map<string, string>>(() => {
- const map = new Map<string, string>();
- if (manifest?.channels.length) {
- for (const c of manifest.channels) {
- if (!c.name) continue;
- map.set(
- c.name,
- resolveChannelGroupId(
- c.groupId,
- manifestGroups,
- manifestDefaultGroupId,
- ),
- );
- }
- }
- return map;
- }, [manifest, manifestGroups, manifestDefaultGroupId]);
-
- const channelOptions = useMemo<string[]>(() => {
- if (manifest?.channels.length) {
- return manifest.channels
- .map((c) => c.name)
- .filter((n) => n)
- .sort((a, b) => a.localeCompare(b));
- }
- if (!transcripts) return [];
- const names = new Set<string>();
- for (const t of transcripts) if (t.channel) names.add(t.channel);
- return Array.from(names).sort((a, b) => a.localeCompare(b));
- }, [manifest, transcripts]);
+ // ─── Channel-grouping setup ────────────────────────────────────────────────
+ // Selection identity is the stable channelKey from the provider's model. In
+ // single-site mode key === the channel name, so all name-keyed filter state,
+ // share links, and saved profiles round-trip exactly as before; in hub mode
+ // the key is origin-qualified so same-named channels from different sites
+ // don't collide. `channelOptions` holds keys; labels come from
+ // `channelLabelByKey` for display only.
+ const channelOptions = useMemo<string[]>(
+ () => channelModel.map((c) => c.key),
+ [channelModel],
+ );
+ const channelLabelByKey = useMemo<Map<string, string>>(
+ () => new Map(channelModel.map((c) => [c.key, c.name])),
+ [channelModel],
+ );
const channelGroupings = useMemo<
{ group: ChannelGroup; channels: string[] }[]
>(() => {
- if (channelOptions.length === 0) return [];
+ if (channelModel.length === 0) return [];
const byId = new Map<string, string[]>();
- for (const name of channelOptions) {
- const gid =
- channelGroupByName.get(name) ??
- resolveChannelGroupId(
- undefined,
- manifestGroups,
- manifestDefaultGroupId,
- );
- const list = byId.get(gid) ?? [];
- list.push(name);
- byId.set(gid, list);
+ for (const c of channelModel) {
+ const list = byId.get(c.groupId) ?? [];
+ list.push(c.key);
+ byId.set(c.groupId, list);
}
+ const labelOf = (k: string) => channelLabelByKey.get(k) ?? k;
const sorted = sortGroups(manifestGroups);
const out: { group: ChannelGroup; channels: string[] }[] = [];
for (const g of sorted) {
- const channels = byId.get(g.id);
- if (!channels || channels.length === 0) continue;
+ const keys = byId.get(g.id);
+ if (!keys || keys.length === 0) continue;
out.push({
group: g,
- channels: channels.slice().sort((a, b) => a.localeCompare(b)),
+ channels: keys
+ .slice()
+ .sort((a, b) => labelOf(a).localeCompare(labelOf(b))),
});
}
return out;
- }, [
- channelOptions,
- channelGroupByName,
- manifestGroups,
- manifestDefaultGroupId,
- ]);
+ }, [channelModel, channelLabelByKey, manifestGroups]);
const defaultSelectedChannels = useMemo<Set<string>>(() => {
const out = new Set<string>();
const groupById = new Map(manifestGroups.map((g) => [g.id, g]));
- for (const name of channelOptions) {
- const gid =
- channelGroupByName.get(name) ??
- resolveChannelGroupId(
- undefined,
- manifestGroups,
- manifestDefaultGroupId,
- );
- const g = groupById.get(gid);
- if (g?.selectedByDefault) out.add(name);
+ for (const c of channelModel) {
+ if (groupById.get(c.groupId)?.selectedByDefault) out.add(c.key);
}
return out;
- }, [
- channelOptions,
- channelGroupByName,
- manifestGroups,
- manifestDefaultGroupId,
- ]);
+ }, [channelModel, manifestGroups]);
const selectedChannelNames = useMemo(
- () => channelOptions.filter((name) => !draftExcludedChannels.has(name)),
+ () => channelOptions.filter((key) => !draftExcludedChannels.has(key)),
[channelOptions, draftExcludedChannels],
);
const selectedChannelsLabel = useMemo(
- () => selectedChannelNames.join(", "),
- [selectedChannelNames],
+ () =>
+ selectedChannelNames
+ .map((k) => channelLabelByKey.get(k) ?? k)
+ .join(", "),
+ [selectedChannelNames, channelLabelByKey],
);
const passesFilter = useMemo(() => {
return (t: Summary) => {
- if (committedChannels.has(t.channel)) return false;
+ if (committedChannels.has(channelKeyOf(t))) return false;
if (committedDateFrom && t.uploadDate < committedDateFrom) return false;
if (committedDateTo && t.uploadDate > committedDateTo) return false;
if (t.isLivestream ? committedNol : committedNov) return false;
@@ -496,6 +450,7 @@ export default function TranscriptSearch() {
};
}, [
committedChannels,
+ channelKeyOf,
committedDateFrom,
committedDateTo,
committedNov,
@@ -1540,11 +1495,11 @@ export default function TranscriptSearch() {
</div>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-1 pb-2">
- {channels.map((name) => {
- const checked = !draftExcludedChannels.has(name);
+ {channels.map((key) => {
+ const checked = !draftExcludedChannels.has(key);
return (
<label
- key={name}
+ key={key}
className="flex items-center gap-1.5 select-none"
>
<Checkbox
@@ -1552,13 +1507,13 @@ export default function TranscriptSearch() {
onCheckedChange={(value) => {
setDraftExcludedChannels((prev) => {
const next = new Set(prev);
- if (value === true) next.delete(name);
- else next.add(name);
+ if (value === true) next.delete(key);
+ else next.add(key);
return next;
});
}}
/>
- {name}
+ {channelLabelByKey.get(key) ?? key}
</label>
);
})}