Archilyzer · Source

archilyzer

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

commit c11d0f3cfb50bd8e9fe50afb4e0ae2e8e527bbcf
parent fd4803007104d6c37c9f13cf9c1d118507e96615
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 14 Jun 2026 00:21:31 -0400

Cross-site footer links between export sites

Each site's footer now lists links to the other sites in the pool. The list
is derived automatically from listSites() at build time (the whole pool is
present per build), so only a new per-site `siteUrl` is needed to make a site
linkable — a site without one is omitted, and a site never links to itself.

A site can override the flat list with ordered, named `relatedSites` groups
that pull closely-related siblings to the front; everything unfeatured falls
into a trailing "Other sites" group. Resolution lives in the pure
resolveRelatedSites() helper.

- common/lib/site.ts: siteUrl + relatedSites fields, RelatedSiteGroup type,
  parseSiteUrl/parseRelatedSites, writeSite persistence, resolveRelatedSites
- export Footer: subtle grouped link nav, omitted when no siblings
- editor SiteForm/actions/pages: Public URL field + related-sites group editor
- e2e: writeSite helper passthrough, export footer spec + sibling fixtures,
  editor round-trip tests; both changelogs updated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/lib/site.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/sites/[siteId]/page.tsx | 17+++++++++++++++--
Meditor/app/sites/actions.ts | 22++++++++++++++++++++++
Meditor/app/sites/components/SiteForm.tsx | 167++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/sites/new/page.tsx | 14+++++++++++---
Meditor/e2e/helpers.ts | 2++
Meditor/e2e/sites-crud.spec.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 3+++
Mexport/app/components/Footer.tsx | 46++++++++++++++++++++++++++++++++++++++++++++--
Aexport/e2e/fixtures/sites/nourlsite/site.json | 10++++++++++
Aexport/e2e/fixtures/sites/othersite/site.json | 11+++++++++++
Mexport/e2e/fixtures/sites/testsite/site.json | 1+
Aexport/e2e/fixtures/sites/thirdsite/site.json | 11+++++++++++
Aexport/e2e/related-sites.spec.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
15 files changed, 498 insertions(+), 8 deletions(-)

diff --git a/common/lib/site.ts b/common/lib/site.ts @@ -55,6 +55,23 @@ export type Site = { // Cloudflare Pages project name this site deploys to // (`wrangler pages deploy out --project-name <cloudflareProject>`). cloudflareProject?: string; + // Absolute public URL of this site's deployment, e.g. "https://jeralyzer.com". + // Drives the cross-site footer list (see resolveRelatedSites). A site with no + // siteUrl is omitted from every other site's list — there's no link target. + siteUrl?: string; + // Per-site override that pulls specific siblings to the front of the footer's + // cross-site list, in named groups. Siblings not named here fall into a + // trailing auto "Other sites" group. Absent/empty = one flat list of every + // sibling. siteIds are resolved against the live pool at render time, so an + // id for a site that doesn't exist (yet) is harmless — it's just skipped. + relatedSites?: RelatedSiteGroup[]; +}; + +export type RelatedSiteGroup = { + // Optional muted heading shown above the group; omit for an unlabeled group. + label?: string; + // Sibling site ids, in display order. + siteIds: string[]; }; // siteId shares the group-id grammar: lowercase slug, used as a directory name. @@ -132,6 +149,42 @@ export function parseSiteChannels(input: unknown): SiteChannelMembership[] { return out; } +// Normalize a raw siteUrl into a trimmed absolute http(s) URL with no trailing +// slash, or undefined when missing/not a usable absolute URL. Relative or +// scheme-less values are rejected — a cross-site link must be absolute. +export function parseSiteUrl(input: unknown): string | undefined { + if (typeof input !== "string") return undefined; + const trimmed = input.trim().replace(/\/+$/, ""); + if (!/^https?:\/\/\S+/i.test(trimmed)) return undefined; + return trimmed; +} + +// Parse the relatedSites override: an ordered list of { label?, siteIds[] } +// groups. Keeps only valid site ids, dedupes within a group, drops groups with +// no valid ids, and preserves order. Existence against the live pool is NOT +// checked here — resolveRelatedSites does that at render time. +export function parseRelatedSites(input: unknown): RelatedSiteGroup[] { + if (!Array.isArray(input)) return []; + const out: RelatedSiteGroup[] = []; + for (const raw of input) { + if (!raw || typeof raw !== "object") continue; + const r = raw as Record<string, unknown>; + const ids = Array.isArray(r.siteIds) ? r.siteIds : []; + const siteIds: string[] = []; + const seen = new Set<string>(); + for (const id of ids) { + if (!isValidSiteId(id) || seen.has(id)) continue; + seen.add(id); + siteIds.push(id); + } + if (siteIds.length === 0) continue; + const label = + typeof r.label === "string" && r.label.trim() ? r.label.trim() : undefined; + out.push(label ? { label, siteIds } : { siteIds }); + } + return out; +} + // Parse a raw site.json object into a fully-resolved Site, applying defaults // and validating groups/defaultGroupId/channel membership. Mirrors getSettings. export function parseSite(siteId: string, raw: unknown): Site { @@ -173,6 +226,8 @@ export function parseSite(siteId: string, raw: unknown): Site { typeof r.cloudflareProject === "string" && r.cloudflareProject.trim() ? r.cloudflareProject.trim() : undefined, + siteUrl: parseSiteUrl(r.siteUrl), + relatedSites: parseRelatedSites(r.relatedSites), }; } @@ -192,6 +247,44 @@ export function resolveSocialLinks( return site.socialLinks ?? settings.socialLinks; } +// A single resolved cross-site link and a named bucket of them. +export type CrossSiteLink = { siteId: string; title: string; url: string }; +export type CrossSiteGroup = { label?: string; sites: CrossSiteLink[] }; + +// Resolve the footer's cross-site list for `current` against the full pool. +// Siblings that lack a siteUrl (or are `current`) are not linkable and dropped. +// `current.relatedSites` groups render first, in order, each filtered to known +// linkable ids (unknown/used/self skipped, empty groups dropped). Every still- +// unused sibling lands in a trailing remainder group — unlabeled when there +// were no override groups (a plain flat list), else labeled "Other sites". +export function resolveRelatedSites( + current: Site, + all: Site[] = listSites(), +): CrossSiteGroup[] { + const byId = new Map<string, CrossSiteLink>(); + for (const s of all) { + if (s.siteId === current.siteId || !s.siteUrl) continue; + byId.set(s.siteId, { siteId: s.siteId, title: s.siteTitle, url: s.siteUrl }); + } + const used = new Set<string>(); + const groups: CrossSiteGroup[] = []; + for (const g of current.relatedSites ?? []) { + const sites: CrossSiteLink[] = []; + for (const id of g.siteIds) { + const link = byId.get(id); + if (!link || used.has(id)) continue; + used.add(id); + sites.push(link); + } + if (sites.length > 0) groups.push({ label: g.label, sites }); + } + const rest = [...byId.values()].filter((l) => !used.has(l.siteId)); + if (rest.length > 0) { + groups.push({ label: groups.length > 0 ? "Other sites" : undefined, sites: rest }); + } + return groups; +} + export function getSite(siteId: string, paths: Paths = getPaths()): Site { if (!isValidSiteId(siteId)) { throw new Error(`Invalid site id: ${String(siteId)}`); @@ -281,6 +374,10 @@ export async function writeSite( ...(site.cloudflareProject && site.cloudflareProject.trim() ? { cloudflareProject: site.cloudflareProject.trim() } : {}), + ...(parseSiteUrl(site.siteUrl) ? { siteUrl: parseSiteUrl(site.siteUrl) } : {}), + ...(parseRelatedSites(site.relatedSites).length > 0 + ? { relatedSites: parseRelatedSites(site.relatedSites) } + : {}), }; const dir = siteDir(paths, site.siteId); await fs.promises.mkdir(dir, { recursive: true }); diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Sites can link to each other.** A site's form gained a **Public URL** field (the absolute URL it's served at, e.g. `https://jeralyzer.com`) and a **Related sites** section. The export footer automatically links to every *other* site that has a Public URL, so filling these in is all that's needed for cross-site links; a site left without a URL is simply omitted from the lists. The **Related sites** editor lets a site pull closely-related siblings to the front under named groups (e.g. Jeralyzer featuring Rekietalyzer under "MTG drama") — add a group, give it an optional heading, and check which sibling sites belong; everything you don't feature falls into a trailing "Other sites" group on its own. Groups reorder with ↑/↓. The picker only lists sites that actually exist, and featured ids for sites that were since deleted are dropped on save (with a heads-up note). It's a subtle, secondary feature — see the matching note in the export changelog for how it renders. - **Git worktrees can run in parallel on non-colliding ports (dev tooling).** Two checkouts of the repo (via `git worktree`) can now run their dev servers and e2e suites at the same time without port clashes. A new helper, `scripts/worktree.mjs` (exposed as `pnpm wt`), assigns each worktree a port block offset by `index * 100` based on its position in `git worktree list` — the main worktree keeps the original defaults (editor 3001, test 3011, export 3010/3000/3020), worktree #1 gets 31xx, and so on. `pnpm dev:editor`, `pnpm dev:export`, `pnpm start:export`, and `pnpm e2e` route through `wt run`, which injects the assigned ports, so they "just work" per worktree; the editor/export `package.json` port flags and the Playwright configs now honor these env vars (previously `pnpm dev:test` hardcoded 3011, so a custom `PORT` only moved the URL Playwright waited on, not the server). E2E specs that hit the editor's test API now derive the base URL from `PLAYWRIGHT_BASE_URL` (centralized in `editor/e2e/baseUrl.ts`) instead of hardcoding `localhost:3011`. `pnpm wt add <branch>` creates a sibling worktree pre-seeded with `settings.json` and prints its ports; `--share-data` links it to the main worktree's downloaded `transcripts/` for read-mostly reuse (with an LMDB concurrent-write caveat). See `WORKTREES.md`. - **Channel reports refresh themselves automatically, on a global debounce.** Any action that changes what a channel report (the per-channel snapshot powering the Channels list, `/actionable`, and the channel page) would say now regenerates that report on its own when it finishes — no more manually clicking **Refresh report** after transcribing, transcoding, cleaning audio, checking availability, editing channel config, or the per-video file operations (delete file, set primary transcript, delete dir, mark untranscribable, archive/do-not-clean). Every report-changing action marks its channel "dirty" and re-arms one shared debounce timer; when activity settles, the scheduler regenerates each dirty channel's snapshot in parallel (reusing the existing `refresh-report` job, deduped against any refresh already running) and revalidates the affected pages. This happens **after each completed sub-operation within a batch**, not only when the whole batch finishes — so a long download or transcription run updates its report incrementally as each video lands, rather than staying stale until the end. The debounce coalesces bursts — videos that finish within the same window collapse into a single regen pass instead of one rewrite per operation. The window is configurable in **Settings → Report refresh debounce**: **Fast** (~1s after activity settles, no cap — the default), **Balanced** (~3s, 30s max), or **Lazy** (~10s, 60s max). The download/sync pipeline's previous behavior of regenerating its report inline is removed in favor of this one uniform mechanism (so its report now lags by the debounce window — ~1s by default — rather than being written synchronously). The manual **Refresh report** / **Update all reports** buttons are unchanged. - **Parakeet transcriptions show a per-video ETA.** While a video is being transcribed with the parakeet app, its per-task progress bar on `/jobs/active` now shows an estimate of how long that single video has left (e.g. `segment 3/12 · ETA 6:10`), alongside the existing segment count and percent. The wrapper (`scripts/parakeet-stitch.mjs`) measures each segment's real transcription wall-time and projects the remaining time as **average time per completed segment × remaining segments**, emitting it on its progress lines; the parser surfaces that ETA in the task detail. It appears from the second segment onward (the first segment has no average to project from yet). This is distinct from the batch-level "~4:30 left" estimate across all videos. diff --git a/editor/app/sites/[siteId]/page.tsx b/editor/app/sites/[siteId]/page.tsx @@ -7,8 +7,13 @@ import { getSite, isValidSiteId, listSiteIds, + listSites, } from "yt-dlp-transcript-common/lib/site"; -import { SiteForm, type ChannelOption } from "../components/SiteForm"; +import { + SiteForm, + type ChannelOption, + type SiteOption, +} from "../components/SiteForm"; export const dynamic = "force-dynamic"; @@ -36,6 +41,9 @@ export default async function EditSitePage({ slug: c.slug, name: c.config.name ?? c.slug, })); + const allSites: SiteOption[] = listSites(paths) + .filter((s) => s.siteId !== siteId) + .map((s) => ({ siteId: s.siteId, siteTitle: s.siteTitle })); return ( <div className="flex flex-col gap-4"> <div className="flex items-center gap-2 text-sm text-zinc-500"> @@ -54,7 +62,12 @@ export default async function EditSitePage({ Edit charts dashboard → </Link> </div> - <SiteForm initial={site} channels={channels} isNew={false} /> + <SiteForm + initial={site} + channels={channels} + allSites={allSites} + isNew={false} + /> </div> ); } diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts @@ -6,7 +6,9 @@ import { writeSite, deleteSite, isValidSiteId, + parseRelatedSites, parseSiteChannels, + parseSiteUrl, type Site, } from "yt-dlp-transcript-common/lib/site"; import { @@ -42,6 +44,24 @@ export async function saveSiteAction( const cloudflareProject = String( formData.get("cloudflareProject") ?? "", ).trim(); + const siteUrlRaw = String(formData.get("siteUrl") ?? "").trim(); + if (siteUrlRaw && !parseSiteUrl(siteUrlRaw)) { + return { + ok: false, + error: "Public URL must be an absolute http(s):// URL, or left blank.", + }; + } + const siteUrl = parseSiteUrl(siteUrlRaw); + + let relatedSitesInput: unknown; + try { + relatedSitesInput = JSON.parse( + String(formData.get("relatedSitesJson") ?? "[]"), + ); + } catch { + return { ok: false, error: "Related sites payload is malformed" }; + } + const relatedSites = parseRelatedSites(relatedSitesInput); let groupsInput: unknown; try { @@ -119,6 +139,8 @@ export async function saveSiteAction( defaultGroupId, channels, ...(cloudflareProject ? { cloudflareProject } : {}), + ...(siteUrl ? { siteUrl } : {}), + ...(relatedSites.length > 0 ? { relatedSites } : {}), }; try { await writeSite(site, getPaths()); diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -12,10 +12,14 @@ import { } from "../../components/SocialLinksField"; export type ChannelOption = { slug: string; name: string }; +export type SiteOption = { siteId: string; siteTitle: string }; type Props = { initial: Site; channels: ChannelOption[]; + // Other sites (excluding this one) available to feature in related-site + // groups. Empty for a single-site install. + allSites: SiteOption[]; isNew: boolean; }; @@ -26,6 +30,10 @@ type GroupRow = { selectedByDefault: boolean; }; +// A featured related-sites group: a label + the sibling siteIds picked into it, +// in pick order. +type RelatedGroupRow = { label: string; siteIds: string[] }; + function toGroupRow(g: ChannelGroup): GroupRow { return { id: g.id, @@ -35,7 +43,7 @@ function toGroupRow(g: ChannelGroup): GroupRow { }; } -export function SiteForm({ initial, channels, isNew }: Props) { +export function SiteForm({ initial, channels, allSites, isNew }: Props) { const [state, formAction] = useActionState<SaveResult | undefined, FormData>( saveSiteAction, undefined, @@ -43,6 +51,14 @@ export function SiteForm({ initial, channels, isNew }: Props) { const [groups, setGroups] = useState<GroupRow[]>(() => initial.groups.map(toGroupRow), ); + // Featured related-sites groups (siblings pulled to the front of the footer + // cross-site list). Sites not featured here auto-fall into a trailing group. + const [related, setRelated] = useState<RelatedGroupRow[]>(() => + (initial.relatedSites ?? []).map((g) => ({ + label: g.label ?? "", + siteIds: [...g.siteIds], + })), + ); const [defaultGroupId, setDefaultGroupId] = useState(initial.defaultGroupId); // undefined socialLinks = inherit the global default (the default for new // sites); an array (even empty) = this site supplies its own list. @@ -117,11 +133,54 @@ export function SiteForm({ initial, channels, isNew }: Props) { .map((g) => ({ id: g.id.trim(), label: g.name.trim() || g.id.trim() })) .filter((g) => g.id); + // Drop empty groups + unknown siteIds at serialize time so the payload always + // matches what writeSite would keep (parseRelatedSites does the same). + const knownSiteIds = new Set(allSites.map((s) => s.siteId)); + const relatedSitesJson = JSON.stringify( + related + .map((g) => ({ + label: g.label.trim() || undefined, + siteIds: g.siteIds.filter((id) => knownSiteIds.has(id)), + })) + .filter((g) => g.siteIds.length > 0), + ); + const siteTitleById = new Map(allSites.map((s) => [s.siteId, s.siteTitle])); + + const addRelatedGroup = () => + setRelated((prev) => [...prev, { label: "", siteIds: [] }]); + const removeRelatedGroup = (idx: number) => + setRelated((prev) => prev.filter((_, i) => i !== idx)); + const moveRelatedGroup = (idx: number, delta: -1 | 1) => + setRelated((prev) => { + const next = prev.slice(); + const j = idx + delta; + if (j < 0 || j >= next.length) return prev; + [next[idx], next[j]] = [next[j], next[idx]]; + return next; + }); + const setRelatedLabel = (idx: number, label: string) => + setRelated((prev) => + prev.map((row, i) => (i === idx ? { ...row, label } : row)), + ); + // Toggle a sibling into/out of a group. Appending on add preserves pick order. + const toggleRelatedSite = (idx: number, siteId: string, on: boolean) => + setRelated((prev) => + prev.map((row, i) => { + if (i !== idx) return row; + const has = row.siteIds.includes(siteId); + if (on && !has) return { ...row, siteIds: [...row.siteIds, siteId] }; + if (!on && has) + return { ...row, siteIds: row.siteIds.filter((s) => s !== siteId) }; + return row; + }), + ); + return ( <form action={formAction} className="flex flex-col gap-4 max-w-2xl"> <input type="hidden" name="groupsJson" value={groupsJson} /> <input type="hidden" name="defaultGroupId" value={defaultGroupId} /> <input type="hidden" name="channelsJson" value={channelsJson} /> + <input type="hidden" name="relatedSitesJson" value={relatedSitesJson} /> {isNew ? ( <Field @@ -168,6 +227,12 @@ export function SiteForm({ initial, channels, isNew }: Props) { defaultValue={initial.cloudflareProject ?? ""} hint="Project name this site deploys to (wrangler --project-name). Leave blank to set later." /> + <Field + label="Public URL" + name="siteUrl" + defaultValue={initial.siteUrl ?? ""} + hint="Absolute URL this site is served at (e.g. https://jeralyzer.com). Used so other sites can link to it in their footer. Leave blank to omit this site from cross-site lists." + /> <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3"> <legend className="px-1 text-sm font-medium">Channels</legend> @@ -384,6 +449,106 @@ export function SiteForm({ initial, channels, isNew }: Props) { )} </fieldset> + <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3"> + <legend className="px-1 text-sm font-medium">Related sites</legend> + <p className="text-xs text-zinc-500"> + The footer links to every other site that has a Public URL. Add groups + here to pull closely-related sites to the front under a heading; every + site you don&apos;t feature falls into a trailing &ldquo;Other + sites&rdquo; group automatically. + </p> + {allSites.length === 0 ? ( + <p className="text-sm text-zinc-500"> + No other sites to link to yet. + </p> + ) : ( + <> + {related.map((g, idx) => ( + <div + key={idx} + className="flex flex-col gap-2 border border-zinc-200 dark:border-zinc-800 rounded p-3 bg-zinc-50/50 dark:bg-zinc-900/40" + > + <div className="flex items-center justify-between gap-2"> + <label className="flex flex-col gap-1 text-sm flex-1 min-w-0"> + <span className="font-medium">Group label</span> + <input + type="text" + value={g.label} + onChange={(e) => setRelatedLabel(idx, e.target.value)} + placeholder="Heading (optional)" + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm" + /> + </label> + <div className="flex items-center gap-1 self-end"> + <button + type="button" + onClick={() => moveRelatedGroup(idx, -1)} + disabled={idx === 0} + className="px-2 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40" + aria-label="Move related group up" + > + ↑ + </button> + <button + type="button" + onClick={() => moveRelatedGroup(idx, 1)} + disabled={idx === related.length - 1} + className="px-2 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40" + aria-label="Move related group down" + > + ↓ + </button> + <button + type="button" + onClick={() => removeRelatedGroup(idx)} + className="px-2 py-0.5 rounded border border-red-300 dark:border-red-800 text-xs text-red-700 dark:text-red-300" + > + Remove + </button> + </div> + </div> + <div className="flex flex-col gap-1"> + {allSites.map((s) => ( + <label + key={s.siteId} + className="flex items-center gap-2 text-sm" + > + <input + type="checkbox" + checked={g.siteIds.includes(s.siteId)} + onChange={(e) => + toggleRelatedSite(idx, s.siteId, e.target.checked) + } + /> + <span className="truncate"> + {s.siteTitle || s.siteId}{" "} + <code className="text-xs text-zinc-500">{s.siteId}</code> + </span> + </label> + ))} + </div> + </div> + ))} + <div> + <button + type="button" + onClick={addRelatedGroup} + className="px-3 py-1.5 rounded border border-zinc-300 dark:border-zinc-700 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800" + > + + Add related group + </button> + </div> + {related.some((g) => + g.siteIds.some((id) => !siteTitleById.has(id)), + ) && ( + <p className="text-xs text-amber-600 dark:text-amber-400"> + Some featured sites no longer exist and will be dropped on save. + </p> + )} + </> + )} + </fieldset> + <div className="flex items-center gap-3"> <button type="submit" diff --git a/editor/app/sites/new/page.tsx b/editor/app/sites/new/page.tsx @@ -6,8 +6,12 @@ import { DEFAULT_GROUP_FALLBACK_ID, FALLBACK_GROUP, } from "yt-dlp-transcript-common/lib/channelGroups"; -import type { Site } from "yt-dlp-transcript-common/lib/site"; -import { SiteForm, type ChannelOption } from "../components/SiteForm"; +import { listSites, type Site } from "yt-dlp-transcript-common/lib/site"; +import { + SiteForm, + type ChannelOption, + type SiteOption, +} from "../components/SiteForm"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "New site — Sites" }; @@ -18,6 +22,10 @@ export default async function NewSitePage() { slug: c.slug, name: c.config.name ?? c.slug, })); + const allSites: SiteOption[] = listSites(paths).map((s) => ({ + siteId: s.siteId, + siteTitle: s.siteTitle, + })); const blank: Site = { siteId: "", siteTitle: "", @@ -39,7 +47,7 @@ export default async function NewSitePage() { <span>new</span> </div> <h1 className="text-2xl font-semibold">New site</h1> - <SiteForm initial={blank} channels={channels} isNew /> + <SiteForm initial={blank} channels={channels} allSites={allSites} isNew /> </div> ); } diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts @@ -77,6 +77,8 @@ export async function writeSite( ...(site.cloudflareProject ? { cloudflareProject: site.cloudflareProject } : {}), + ...(site.siteUrl ? { siteUrl: site.siteUrl } : {}), + ...(site.relatedSites ? { relatedSites: site.relatedSites } : {}), }; await writeFile( join(dir, "site.json"), diff --git a/editor/e2e/sites-crud.spec.ts b/editor/e2e/sites-crud.spec.ts @@ -12,6 +12,11 @@ type SocialSiteFile = { socialLinks?: { label: string; url: string; svg: string }[]; }; +type RelatedSiteFile = { + siteUrl?: string; + relatedSites?: { label?: string; siteIds: string[] }[]; +}; + // Valid (un-normalized) SVG the server accepts: starts with <svg, has viewBox, // ends with </svg>. normalizeSocialSvg adds fill/aria-hidden on save. const VALID_SVG = '<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>'; @@ -182,6 +187,60 @@ test("a site with its own links opens with the inherit toggle off", async ({ ); }); +test("public URL and a featured related-sites group round-trip to disk", async ({ + page, +}) => { + await resetData("empty"); + // A sibling to feature in the related group. + await writeSite("alpha", { siteTitle: "Alpha Site" }); + + await page.goto("/sites/new"); + await page.getByLabel(/site id/i).fill("beta"); + await page.getByLabel(/site title/i).fill("Beta Site"); + await page.getByLabel(/header title/i).fill("Beta Site"); + await page.getByLabel(/public url/i).fill("https://beta.example"); + + await page.getByRole("button", { name: /add related group/i }).click(); + await page.getByPlaceholder(/heading \(optional\)/i).fill("Friends"); + await page.getByRole("checkbox", { name: /Alpha Site/ }).check(); + + await page.getByRole("button", { name: /create site/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + + const site = await readJson<RelatedSiteFile>( + "test-transcripts/sites/beta/site.json", + ); + expect(site.siteUrl).toBe("https://beta.example"); + expect(site.relatedSites).toEqual([ + { label: "Friends", siteIds: ["alpha"] }, + ]); +}); + +test("edit page loads an existing public URL and featured site", async ({ + page, +}) => { + await resetData("empty"); + await writeSite("alpha", { siteTitle: "Alpha Site" }); + await writeSite("beta", { + siteTitle: "Beta Site", + siteUrl: "https://beta.example", + relatedSites: [{ label: "Friends", siteIds: ["alpha"] }], + }); + + await page.goto("/sites/beta"); + await expect(page.getByLabel(/public url/i)).toHaveValue( + "https://beta.example", + ); + await expect(page.getByPlaceholder(/heading \(optional\)/i)).toHaveValue( + "Friends", + ); + await expect( + page.getByRole("checkbox", { name: /Alpha Site/ }), + ).toBeChecked(); +}); + test("first-run migrate button appears only when no sites exist", async ({ page, }) => { diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **Footer links to the network's other sites.** The footer now carries a subtle, low-key list of links to the *other* sites in the network (e.g. Jeralyzer's footer links to Rekietalyzer and Hasanalyzer). It's intentionally understated — a small, muted row beneath the downloads/social row — since it's a secondary wayfinding aid, not a primary feature. By default it's a flat list of every sibling site; a site can override that to surface closely-related subjects first under named groups (configured per site in the editor), with everything else collected under a trailing "Other sites" group. Only sites that have a public URL configured appear, and a site never links to itself. Single-site installs show nothing new. + ## [0.3.5] - 2026-06-04 - **Duplicates page.** A new **Duplicates** tab in the primary nav lists shorts whose transcripts match across channels and platforms — re-uploads, mirrors, and cross-posts of the same clip — grouped into clusters (strongest match first). Each cluster is laid out like a group of search results: a header bar shows how it matched (exact or near transcript, similarity score, clip-of-longer) and whether it spans multiple channels or platforms — or is a **same-channel** re-upload — and every member is a search-hit-style row with its title, channel name, platform and upload date, plus the original video's URL on its own line so it's obvious the members are genuinely different videos. Clicking a member's title plays it in the transcript modal. The page header also counts how many clusters contain same-channel duplicates. A **Filters** section (styled like the search page's) scopes the list by platform — **YouTube only by default**, with Rumble/Odysee/Twitch toggleable — and by match type, relationship (same-channel / cross-channel / cross-platform / clip-of-longer), channel, and a title/channel text search; platform and channel toggles drop members on de-selected platforms/channels and hide any cluster left with fewer than two members. The list is scoped to the channels this site exposes, so every entry is openable here. Sites with no detection run yet show an empty state. diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx @@ -1,9 +1,18 @@ import { getSettings } from "yt-dlp-transcript-common/lib/settings"; -import { resolveSocialLinks } from "yt-dlp-transcript-common/lib/site"; +import { + listSites, + resolveRelatedSites, + resolveSocialLinks, +} from "yt-dlp-transcript-common/lib/site"; import { currentSite } from "../lib/site"; export default function Footer() { - const socialLinks = resolveSocialLinks(currentSite(), getSettings()); + const site = currentSite(); + const socialLinks = resolveSocialLinks(site, getSettings()); + // The whole pool is present at build time, so siblings (other sites with a + // siteUrl) resolve here; single-site installs get an empty list and the + // related-sites block is omitted entirely. + const related = resolveRelatedSites(site, listSites()); return ( <footer className="mt-auto border-t border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40"> <div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between gap-4 text-sm text-zinc-500"> @@ -34,6 +43,39 @@ export default function Footer() { </ul> )} </div> + {related.length > 0 && ( + <nav + aria-label="Related sites" + className="max-w-6xl mx-auto px-4 pb-4 -mt-1 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-xs text-zinc-400" + > + {related.map((group, gi) => ( + <div + key={group.label ?? `group-${gi}`} + className="flex flex-wrap items-baseline gap-x-2 gap-y-1" + > + {group.label && ( + <span className="uppercase tracking-wide text-zinc-400/80"> + {group.label} + </span> + )} + <ul className="flex flex-wrap items-baseline gap-x-2 gap-y-1 list-none"> + {group.sites.map((s, si) => ( + <li key={s.siteId} className="flex items-baseline gap-x-2"> + {si > 0 && <span aria-hidden="true">·</span>} + <a + href={s.url} + rel="noopener noreferrer" + className="hover:text-zinc-900 dark:hover:text-zinc-100" + > + {s.title} + </a> + </li> + ))} + </ul> + </div> + ))} + </nav> + )} </footer> ); } diff --git a/export/e2e/fixtures/sites/nourlsite/site.json b/export/e2e/fixtures/sites/nourlsite/site.json @@ -0,0 +1,10 @@ +{ + "siteId": "nourlsite", + "siteTitle": "No URL Fixture Site", + "siteDescription": "A sibling with no public URL — must be excluded from cross-links", + "headerTitle": "No URL Fixture Site", + "homeTagline": "", + "groups": [{ "id": "default", "name": "All channels", "selectedByDefault": true }], + "defaultGroupId": "default", + "channels": [] +} diff --git a/export/e2e/fixtures/sites/othersite/site.json b/export/e2e/fixtures/sites/othersite/site.json @@ -0,0 +1,11 @@ +{ + "siteId": "othersite", + "siteTitle": "Other Fixture Site", + "siteDescription": "A sibling site for cross-link tests", + "headerTitle": "Other Fixture Site", + "homeTagline": "", + "siteUrl": "https://othersite.example", + "groups": [{ "id": "default", "name": "All channels", "selectedByDefault": true }], + "defaultGroupId": "default", + "channels": [] +} diff --git a/export/e2e/fixtures/sites/testsite/site.json b/export/e2e/fixtures/sites/testsite/site.json @@ -11,6 +11,7 @@ "svg": "<svg aria-hidden=\"true\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M1 1h2\"/></svg>" } ], + "relatedSites": [{ "label": "Friends", "siteIds": ["othersite"] }], "groups": [{ "id": "default", "name": "All channels", "selectedByDefault": true }], "defaultGroupId": "default", "channels": [{ "slug": "test-youtube", "groupId": "default" }] diff --git a/export/e2e/fixtures/sites/thirdsite/site.json b/export/e2e/fixtures/sites/thirdsite/site.json @@ -0,0 +1,11 @@ +{ + "siteId": "thirdsite", + "siteTitle": "Third Fixture Site", + "siteDescription": "A non-featured sibling for the remainder group", + "headerTitle": "Third Fixture Site", + "homeTagline": "", + "siteUrl": "https://thirdsite.example", + "groups": [{ "id": "default", "name": "All channels", "selectedByDefault": true }], + "defaultGroupId": "default", + "channels": [] +} diff --git a/export/e2e/related-sites.spec.ts b/export/e2e/related-sites.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; +import { installRoutes } from "./helpers"; + +// The footer lists the OTHER sites in the pool (those with a siteUrl). The +// playwright config points SITES_DIR at e2e/fixtures/sites with SITE_ID=testsite, +// whose site.json features `othersite` under a "Friends" group; `thirdsite` has +// a URL but isn't featured (→ trailing "Other sites"); `nourlsite` has no URL +// (→ excluded entirely). See export/app/components/Footer.tsx + resolveRelatedSites. + +test("footer links featured siblings first, then the rest", async ({ page }) => { + await installRoutes(page); + await page.goto("/"); + + const nav = page.getByRole("navigation", { name: "Related sites" }); + await expect(nav).toBeVisible(); + + // Featured group renders with its label and an absolute cross-site link. + await expect(nav.getByText("Friends")).toBeVisible(); + const featured = nav.getByRole("link", { name: "Other Fixture Site" }); + await expect(featured).toHaveAttribute("href", "https://othersite.example"); + + // Non-featured sibling falls into the trailing "Other sites" group. + await expect(nav.getByText("Other sites")).toBeVisible(); + await expect( + nav.getByRole("link", { name: "Third Fixture Site" }), + ).toHaveAttribute("href", "https://thirdsite.example"); +}); + +test("a sibling without a public URL is not linked", async ({ page }) => { + await installRoutes(page); + await page.goto("/"); + const nav = page.getByRole("navigation", { name: "Related sites" }); + await expect( + nav.getByRole("link", { name: "No URL Fixture Site" }), + ).toHaveCount(0); +}); + +test("the current site does not link to itself", async ({ page }) => { + await installRoutes(page); + await page.goto("/"); + const nav = page.getByRole("navigation", { name: "Related sites" }); + await expect( + nav.getByRole("link", { name: "Fixture Site Title" }), + ).toHaveCount(0); +});