Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 52d377e6956caf633b1b48f63ca943af6324cfe5
parent d009376e0169c908958531a7d0017f226d1ed258
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 21 Sep 2026 13:12:56 -0400

tags S2.9: the chip row, the card chips, and "Tags" -> "Keywords"

The filter panel's tag row is built ONLY from this site's /tags.json and is
absent entirely when that document is a 404 — which is every site until
someone curates a tag, and every site built before corpus spec 4. There is
no static list in the component, which is the operator's decision: a tag
defined corpus-wide but matching nothing HERE does not get a chip here.
selectableTags re-checks the count on the reader side so a stale or
hand-written document cannot put a chip that finds nothing in front of
someone.

Chips group by groupLabel ("Eva: Collab · In chat · Discussed"), each
carries this site's count, OR across the selection, and a selected chip
clears on click — the row is its own reset, so there is no second control
to keep in sync. A group wraps as a unit, so a phone gets one group per
line rather than a torn row.

On a card the record's tags render as quiet, NON-interactive spans: the card
header is already one big <Button>, and a clickable chip inside it would be
a control inside a control. A tag this site does not publish still shows —
by its id. The fact is on the record either way.

The yt-dlp keyword scope is now labelled "Keywords". The code token, the URL
and the MCP `scopes` enum stay "tags": renaming those would break every
saved query and share link to fix a word on a screen.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

Diffstat:
Mcommon/components/FiltersPanel.tsx | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/components/QueryLeafView.tsx | 12+++++++++---
Mcommon/components/SearchResults.tsx | 41+++++++++++++++++++++++++++++++++++++++++
3 files changed, 158 insertions(+), 4 deletions(-)

diff --git a/common/components/FiltersPanel.tsx b/common/components/FiltersPanel.tsx @@ -13,7 +13,7 @@ // to be the outer one. It also doubles as the inline collapse, driven by the // session's persisted `filtersCollapsed`. -import { Fragment, useCallback } from "react"; +import { Fragment, useCallback, useMemo } from "react"; import { ChevronRightIcon } from "lucide-react"; import { MISSING_STATES, @@ -32,6 +32,10 @@ import { DEFAULT_FLUSH_INTERVAL_MS, } from "./SearchSessionContext"; import { useSearchData } from "./SearchDataContext"; +import { + groupPublishedTags, + selectableTags, +} from "../lib/publishedTags"; export default function FiltersPanel({ // Inside the sheet the sheet itself IS the disclosure, so the wrapper stays @@ -401,6 +405,7 @@ export default function FiltersPanel({ </div> </div> )} + <TagChipRow /> <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <span className="text-xs uppercase tracking-wide text-muted-foreground"> Type @@ -735,6 +740,108 @@ function ProfilesRow({ ); } +// The curated-tag chip row (lib/curatedTags.ts) — "Eva: Collab · In chat · +// Discussed" beside the channel-group chips. +// +// Built ONLY from this site's published /tags.json, which is the operator's +// decision and the reason there is no static list anywhere in this file: a tag +// defined corpus-wide but matching nothing on THIS site does not appear here, +// and neither does a hidden one, because compose-site never wrote it into this +// site's document. The whole row is absent when the site publishes nothing — +// which is every site until someone curates a tag, and every site built before +// corpus spec 4. +// +// OR across the chips: selecting Collab and In chat is every stream with +// either, never only the ones with both. A selected chip clears on click, so +// the row is its own reset and needs no second control. +function TagChipRow() { + const { curatedTags } = useSearchData(); + const { draftTags, toggleDraftTag } = useSearchSession(); + + // selectableTags is the belt to compose-site's braces: a stale or + // hand-written document must not be able to offer a chip that finds nothing. + const groups = useMemo( + () => groupPublishedTags(selectableTags(curatedTags)), + [curatedTags], + ); + if (groups.length === 0) return null; + + return ( + <div + className="flex flex-col gap-2 w-full" + data-testid="tag-chip-row" + > + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + Tags + </span> + <span className="text-xs text-muted-foreground"> + {draftTags.size === 0 + ? "any" + : `${draftTags.size} selected — a video with ANY of them`} + </span> + </div> + {/* One wrapping flow, phone-first: a group is a label plus its chips and + wraps as a unit, so a narrow viewport gets one group per line rather + than a torn row. */} + <div className="flex flex-wrap items-center gap-x-4 gap-y-2"> + {groups.map((group) => ( + <div + key={group.id || "ungrouped"} + data-testid="tag-chip-group" + data-group-id={group.id} + className="flex flex-wrap items-center gap-1.5 min-w-0" + > + {group.label && ( + <span className="text-xs text-muted-foreground shrink-0"> + {group.label}: + </span> + )} + {group.tags.map((tag) => { + const selected = draftTags.has(tag.id); + return ( + <button + key={tag.id} + type="button" + aria-pressed={selected} + data-testid="tag-chip" + data-tag-id={tag.id} + title={ + selected + ? `Clear "${tag.label}"` + : `Keep only videos tagged "${tag.label}"` + } + onClick={() => toggleDraftTag(tag.id)} + className={cn( + "flex items-center gap-1.5 rounded border px-2.5 py-1.5 text-sm select-none min-w-0 transition-colors", + selected + ? "border-primary bg-primary/10 text-foreground" + : "border-border bg-card/60 hover:bg-accent hover:text-accent-foreground", + )} + > + {tag.color && ( + <span + aria-hidden="true" + className="inline-block size-2 shrink-0 rounded-full" + style={{ background: tag.color }} + /> + )} + <span className="truncate">{tag.label}</span> + {/* The count is this site's, computed at index time — the + same number the chip's filter will produce. */} + <span className="text-xs text-muted-foreground shrink-0"> + {tag.count} + </span> + </button> + ); + })} + </div> + ))} + </div> + </div> + ); +} + function ChannelAllToggle({ channelOptions, draftExcludedChannels, diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx @@ -37,13 +37,19 @@ type Props = { onWrapInGroup?: () => void; }; +// LABELS ONLY. The `tags` scope searches yt-dlp KEYWORDS from +// metadata.info.json (TranscriptSummary.tags) and is shown as "Keywords", +// because "Tags" now means the operator's curated per-video vocabulary +// (lib/curatedTags.ts) — the chip row in the filter panel. The code token, the +// URL and the MCP `scopes` enum stay `"tags"`: renaming those would break every +// saved query and share link to fix a word on a screen. const SCOPE_LABELS: Record<LayerScope, string> = { transcripts: "Transcripts", chat: "Live chat", posts: "Posts", metadata: "Title / channel", description: "Description", - tags: "Tags", + tags: "Keywords", }; export default function QueryLeafView({ @@ -155,8 +161,8 @@ export default function QueryLeafView({ : "Search video descriptions..." : leaf.scope === "tags" ? leaf.useRegex - ? "Tags regex..." - : "Search video tags..." + ? "Keywords regex..." + : "Search video keywords..." : leaf.scope === "chat" ? leaf.useRegex ? "Live chat regex..." diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx @@ -40,6 +40,8 @@ import { type LeafInfo, type ResultGroup, } from "./SearchSessionContext"; +import { useSearchData } from "./SearchDataContext"; +import type { PublishedTag } from "../lib/curatedTags"; // Rough first-paint guess for one full result card (header + a couple of // leaf sections + a handful of hits). After mount, ResizeObserver measures @@ -496,6 +498,17 @@ const ResultCard = memo(function ResultCard({ expanded: boolean; onToggleExpand: (slug: string) => void; }) { + // The site's published tag vocabulary, for labelling this record's + // `curatedTags`. A tag the site does not publish (hidden here, zero-count + // here, or defined after this page was built) is still shown — by its id. + // The fact is on the record either way, and a raw id is more honest than + // silently dropping a tag someone put there. + const { curatedTags: publishedTags } = useSearchData(); + const tagById = useMemo<ReadonlyMap<string, PublishedTag>>( + () => new Map(publishedTags.map((t) => [t.id, t])), + [publishedTags], + ); + // Other copies of this video elsewhere in the archive. // // FEDERATION GUARD: in hub mode a group's slug may be an origin-qualified id, @@ -624,6 +637,34 @@ const ResultCard = memo(function ResultCard({ {group.hits.length > 0 && ` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`} </span> + {/* Curated tags this record carries. Non-interactive spans: the card + header is already one big <Button>, so a clickable chip here would + be a control inside a control. */} + {group.curatedTags && group.curatedTags.length > 0 && ( + <span className="basis-full sm:basis-auto flex flex-wrap items-center gap-1 shrink-0"> + {group.curatedTags.map((id) => { + const def = tagById.get(id); + return ( + <span + key={id} + data-testid="card-tag" + data-tag-id={id} + title={def?.groupLabel ? `${def.groupLabel}: ${def.label}` : undefined} + className="flex items-center gap-1 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-[10px] text-muted-foreground" + > + {def?.color && ( + <span + aria-hidden="true" + className="inline-block size-1.5 shrink-0 rounded-full" + style={{ background: def.color }} + /> + )} + {def?.label ?? id} + </span> + ); + })} + </span> + )} </Button> </div> <button