Archilyzer · Source

archilyzer

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

commit 14b4b3620a4d6fe2b1aa81f3389dcba3c127a21a
parent 236f139c7a931b07c98c65e5b0782c09239e83fa
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  7 Jun 2026 22:28:27 -0400

global social links

Diffstat:
Mcommon/lib/settings.ts | 17+++++++++++++++++
Mcommon/lib/site.ts | 43+++++++++++++++++++++++++++++++++----------
Meditor/CHANGELOG.md | 1+
Aeditor/app/components/SocialLinksField.tsx | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/settings/actions.ts | 30++++++++++++++++++++++++++++++
Meditor/app/settings/components/SettingsForm.tsx | 22+++++++++++++++++++++-
Meditor/app/sites/actions.ts | 51++++++++++++++++++++++++++++++---------------------
Meditor/app/sites/components/SiteForm.tsx | 145+++++++++++++++++++++----------------------------------------------------------
Meditor/app/sites/new/page.tsx | 2+-
Meditor/e2e/export-search.spec.ts | 35+++++++++++++++++++++++++++++++++++
Meditor/e2e/helpers.ts | 4+++-
Meditor/e2e/settings.spec.ts | 25+++++++++++++++++++++++++
Meditor/e2e/sites-crud.spec.ts | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/components/Footer.tsx | 4+++-
14 files changed, 451 insertions(+), 142 deletions(-)

diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -27,6 +27,12 @@ export type SiteSettings = { // (default), audio is left for the next "Transcribe missing" pass so a // batch download finishes faster and whisper can parallelize. inlineTranscribeOnFallback: boolean; + // Default social links applied to every site that doesn't define its own. + // A site inherits these unless its site.json carries an explicit + // `socialLinks` array — see Site.socialLinks / resolveSocialLinks in + // common/lib/site.ts. The one presentation field that lives globally so a + // shared footer doesn't have to be repeated per site. + socialLinks: SocialLink[]; }; export type SocialLink = { @@ -75,6 +81,7 @@ function defaults(): SiteSettings { cookiesFromBrowser: "", sleepBetweenDownloadsSeconds: SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS, inlineTranscribeOnFallback: false, + socialLinks: [], }; } @@ -181,6 +188,7 @@ export function getSettings(): SiteSettings { if (typeof merged.inlineTranscribeOnFallback !== "boolean") { merged.inlineTranscribeOnFallback = false; } + merged.socialLinks = parseSocialLinks(merged.socialLinks); return merged; } @@ -222,6 +230,14 @@ export async function writeSettings(next: SiteSettings): Promise<void> { if (!next.transcribeBin.trim()) { throw new Error("Transcribe binary is required"); } + const socialLinks: SocialLink[] = []; + for (const link of parseSocialLinks(next.socialLinks)) { + const svg = normalizeSocialSvg(link.svg); + if (svg === null) { + throw new Error(`Social link "${link.label}" has an invalid SVG`); + } + socialLinks.push({ ...link, svg }); + } const file = getPaths().settingsFile; // Build the output from ONLY the known operational fields. Do NOT spread // `next`: getSettings() spreads the raw file, so a settings.json still @@ -244,6 +260,7 @@ export async function writeSettings(next: SiteSettings): Promise<void> { next.sleepBetweenDownloadsSeconds, ), inlineTranscribeOnFallback: next.inlineTranscribeOnFallback === true, + socialLinks, }; const tmp = `${file}.tmp-${process.pid}`; await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n"); diff --git a/common/lib/site.ts b/common/lib/site.ts @@ -9,8 +9,10 @@ import { } from "./channelGroups"; import { getPaths, type Paths } from "./paths"; import { + getSettings, normalizeSocialSvg, parseSocialLinks, + type SiteSettings, type SocialLink, } from "./settings"; @@ -38,7 +40,10 @@ export type Site = { siteDescription: string; headerTitle: string; homeTagline: string; - socialLinks: SocialLink[]; + // Per-site social links. `undefined` (no `socialLinks` key in site.json) + // means inherit the global default from SiteSettings.socialLinks; an array + // (even empty) overrides it. Resolve with resolveSocialLinks() at render. + socialLinks?: SocialLink[]; // Channel grouping layout for THIS site (cosmetic + default-selection // buckets the export UI renders). Mirrors what used to live in // SiteSettings.groups but is now per-site. @@ -96,7 +101,7 @@ function defaults(siteId: string): Site { siteDescription: "Browse and search video transcripts", headerTitle: "Transcript Browser", homeTagline: "", - socialLinks: [], + // socialLinks left undefined = inherit the global default. // A single default group selected by default keeps a site with no explicit // group config behaving like the pre-grouping UI (one bucket, all checked). groups: [{ ...FALLBACK_GROUP }], @@ -157,7 +162,10 @@ export function parseSite(siteId: string, raw: unknown): Site { siteDescription: str("siteDescription", base.siteDescription), headerTitle: str("headerTitle", base.headerTitle), homeTagline: str("homeTagline", base.homeTagline), - socialLinks: parseSocialLinks(r.socialLinks), + // Key present (array) = override; absent = inherit the global default. + socialLinks: Array.isArray(r.socialLinks) + ? parseSocialLinks(r.socialLinks) + : undefined, groups: resolvedGroups, defaultGroupId, channels, @@ -174,6 +182,16 @@ export function siteChannelSlugs(site: Site): Set<string> { return new Set(site.channels.map((c) => c.slug)); } +// The effective social links for a site: its own override when present, else +// the global default. Pass `settings` to avoid a redundant read; defaults to +// getSettings() for callers that don't already have it. +export function resolveSocialLinks( + site: Site, + settings: SiteSettings = getSettings(), +): SocialLink[] { + return site.socialLinks ?? settings.socialLinks; +} + export function getSite(siteId: string, paths: Paths = getPaths()): Site { if (!isValidSiteId(siteId)) { throw new Error(`Invalid site id: ${String(siteId)}`); @@ -234,13 +252,18 @@ export async function writeSite( `Default group "${String(site.defaultGroupId)}" is not in the configured groups`, ); } - const socialLinks: SocialLink[] = []; - for (const link of parseSocialLinks(site.socialLinks)) { - const svg = normalizeSocialSvg(link.svg); - if (svg === null) { - throw new Error(`Social link "${link.label}" has an invalid SVG`); + // undefined socialLinks = inherit the global default; only validate/persist a + // key when the site explicitly overrides (an array, even empty). + let socialLinks: SocialLink[] | undefined; + if (site.socialLinks !== undefined) { + socialLinks = []; + for (const link of parseSocialLinks(site.socialLinks)) { + const svg = normalizeSocialSvg(link.svg); + if (svg === null) { + throw new Error(`Social link "${link.label}" has an invalid SVG`); + } + socialLinks.push({ ...link, svg }); } - socialLinks.push({ ...link, svg }); } const channels = parseSiteChannels(site.channels).filter( (c) => !c.groupId || groups.some((g) => g.id === c.groupId), @@ -251,7 +274,7 @@ export async function writeSite( siteDescription: site.siteDescription, headerTitle: site.headerTitle, homeTagline: site.homeTagline, - socialLinks, + ...(socialLinks !== undefined ? { socialLinks } : {}), groups, defaultGroupId, channels, diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Global default social links, overridable per site.** Footer social links can now be defined once in **Settings → Social links** and apply to every site by default. Each site's form gained a **Use global default social links** checkbox (on by default): leave it checked to inherit the global list, or uncheck it to give that site its own links (an empty list shows none). Existing sites keep their current footer — sites that already had links stay as explicit overrides until you flip the toggle. Validation and SVG sanitization are unchanged and shared by both the global and per-site editors. - **Import a single off-playlist video by URL.** A channel's **Playlist** stage gained an **Import single video** panel: paste a video URL, click **Import video**, and that one video is downloaded into the channel using its normal handling (subtitles + metadata for YouTube channels, audio for transcribe channels) and appended to the archive — no need to add it to the stored playlist first. It reuses the same single-video downloader as the per-video "Download" button (including the auth-retry and no-subs→audio fallbacks), streams yt-dlp's output live, and runs on the channel's download queue. Transcription stays a separate step (use the per-video **Transcribe** button afterward), same as a normal download. - **Duplicate shorts detection on `/actionable`.** A new **Duplicate shorts** section finds the same short re-uploaded under a new id, re-titled, posted on another channel, or cross-posted to another platform. It runs a global, cross-platform pass: a cheap duration pre-cluster narrows candidates, then transcript content is compared (exact text hash → near-duplicate similarity → containment for "this short is a clip of a longer video"). Matches are **content-confirmed only** — a shared duration alone never clusters videos (that produced huge false clusters of unrelated same-length videos), and plain (non-karaoke) VTT captions that the index stores as zero cues are recovered by reading the raw transcript. Pick a scope — **Shorts only** (default ≤ 180s) or **All durations** (a heavier one-off that also surfaces clip-of-longer matches) — and click **Detect duplicates**; results list each cluster with its members (links), match strength, score, and cross-platform/cross-channel/clip-of-longer badges. It's **flag-only** — nothing is merged or deleted. Reads the cues + per-video stats written by **Build index** + **Build stats dataset**, so run those first; the report is written to `transcripts/duplicates.json` (also producible from the CLI via `pnpm --filter export run detect:duplicates [-- --all-durations]`). - **Cleanup sections now estimate how much disk space they'll reclaim.** Both the `/actionable` cleanup rows and a channel's **Cleanup** stage show an estimated size next to each cleanup operation — the **Actionable** page gained an **Est. reclaim** column for "cleanable transcribed audio" and "extra audio formats", and the channel Cleanup stage shows an "Estimated space to reclaim: ~N" line under each of its two actions. The estimate is computed when a channel's report is generated (it sums the `audio.*` files each cleanup would delete — all audio for transcribed-audio cleanup, every non-target format for extra-format cleanup — and respects "do not clean"), so refresh a channel's report to populate it. Older reports without the figure show `~0 B` until refreshed. diff --git a/editor/app/components/SocialLinksField.tsx b/editor/app/components/SocialLinksField.tsx @@ -0,0 +1,112 @@ +"use client"; + +import type { SocialLink } from "yt-dlp-transcript-common/lib/settings"; + +export type SocialRow = { label: string; url: string; svg: string }; + +export function toSocialRow(s: SocialLink): SocialRow { + return { label: s.label, url: s.url, svg: s.svg }; +} + +type Props = { + // Controlled rows + setter, owned by the parent form so it can also gate the + // whole editor (e.g. behind an "inherit global" toggle). + value: SocialRow[]; + onChange: (rows: SocialRow[]) => void; + // When set, a hidden <input name> carries the trimmed rows as JSON for the + // server action (same shape both the sites and settings actions parse). + name?: string; +}; + +// Shared editor for a list of social links (label / URL / inline SVG), with +// reorder + remove + add. Used by both the per-site form and the global +// settings form. +export function SocialLinksField({ value, onChange, name }: Props) { + const move = (idx: number, delta: -1 | 1) => { + const next = value.slice(); + const j = idx + delta; + if (j < 0 || j >= next.length) return; + [next[idx], next[j]] = [next[j], next[idx]]; + onChange(next); + }; + const update = (idx: number, patch: Partial<SocialRow>) => + onChange(value.map((row, i) => (i === idx ? { ...row, ...patch } : row))); + + const json = JSON.stringify( + value.map((s) => ({ + label: s.label.trim(), + url: s.url.trim(), + svg: s.svg.trim(), + })), + ); + + return ( + <> + {name && <input type="hidden" name={name} value={json} />} + {value.map((s, 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-end gap-1"> + <button + type="button" + onClick={() => move(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 link up" + > + ↑ + </button> + <button + type="button" + onClick={() => move(idx, 1)} + disabled={idx === value.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 link down" + > + ↓ + </button> + <button + type="button" + onClick={() => onChange(value.filter((_, i) => i !== 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> + <input + type="text" + value={s.label} + onChange={(e) => update(idx, { label: e.target.value })} + placeholder="Label (e.g. GitHub)" + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm" + /> + <input + type="text" + value={s.url} + onChange={(e) => update(idx, { url: e.target.value })} + placeholder="https://…" + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm" + /> + <textarea + value={s.svg} + onChange={(e) => update(idx, { svg: e.target.value })} + rows={4} + placeholder='<svg viewBox="0 0 24 24"><path d="…"/></svg>' + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-xs font-mono" + /> + </div> + ))} + <div> + <button + type="button" + onClick={() => onChange([...value, { label: "", url: "", svg: "" }])} + 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 link + </button> + </div> + </> + ); +} diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -2,12 +2,15 @@ import { revalidatePath } from "next/cache"; import { + normalizeSocialSvg, + parseSocialLinks, SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS, TRANSCRIPT_PAGE_HARD_CAP_BYTES, TRANSCRIPT_PAGE_MIN_BYTES, validateTranscribeArgs, writeSettings, type SiteSettings, + type SocialLink, } from "yt-dlp-transcript-common/lib/settings"; export type SaveResult = { ok: true } | { ok: false; error: string }; @@ -73,6 +76,32 @@ export async function saveSettingsAction( }; } + let socialInput: unknown; + try { + socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]")); + } catch { + return { ok: false, error: "Social links payload is malformed" }; + } + const socialParsed = parseSocialLinks(socialInput); + if (Array.isArray(socialInput) && socialParsed.length !== socialInput.length) { + return { + ok: false, + error: + "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.", + }; + } + const socialLinks: SocialLink[] = []; + for (const link of socialParsed) { + const svg = normalizeSocialSvg(link.svg); + if (svg === null) { + return { + ok: false, + error: `Social link "${link.label}" has an invalid SVG.`, + }; + } + socialLinks.push({ ...link, svg }); + } + const next: SiteSettings = { adminTitle, maxTranscriptPageBytes: parsed, @@ -82,6 +111,7 @@ export async function saveSettingsAction( cookiesFromBrowser, sleepBetweenDownloadsSeconds: sleepParsed, inlineTranscribeOnFallback, + socialLinks, }; await writeSettings(next); revalidatePath("/settings"); diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx @@ -1,11 +1,16 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useState } from "react"; import { saveSettingsAction, type SaveResult, } from "../actions"; import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; +import { + SocialLinksField, + toSocialRow, + type SocialRow, +} from "../../components/SocialLinksField"; type Props = { initial: SiteSettings; @@ -16,6 +21,9 @@ export function SettingsForm({ initial }: Props) { saveSettingsAction, undefined, ); + const [social, setSocial] = useState<SocialRow[]>(() => + initial.socialLinks.map(toSocialRow), + ); return ( <form action={formAction} className="flex flex-col gap-4 max-w-xl"> @@ -106,6 +114,18 @@ export function SettingsForm({ initial }: Props) { </span> </span> </label> + <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">Social links</legend> + <p className="text-xs text-zinc-500"> + Default social links shown in every site&apos;s footer. Each site can + override these from its own page (Sites → a site → Social links). + </p> + <SocialLinksField + value={social} + onChange={setSocial} + name="socialLinksJson" + /> + </fieldset> <div className="flex items-center gap-3"> <button type="submit" diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts @@ -65,30 +65,39 @@ export async function saveSiteAction( }; } - let socialInput: unknown; - try { - socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]")); - } catch { - return { ok: false, error: "Social links payload is malformed" }; - } - const socialParsed = parseSocialLinks(socialInput); - if (Array.isArray(socialInput) && socialParsed.length !== socialInput.length) { - return { - ok: false, - error: - "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.", - }; - } - const socialLinks: SocialLink[] = []; - for (const link of socialParsed) { - const svg = normalizeSocialSvg(link.svg); - if (svg === null) { + // When inheriting, leave socialLinks undefined so writeSite omits the key and + // the site falls back to the global default at render time. + const inheritSocialLinks = formData.get("inheritSocialLinks") === "on"; + let socialLinks: SocialLink[] | undefined; + if (!inheritSocialLinks) { + let socialInput: unknown; + try { + socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]")); + } catch { + return { ok: false, error: "Social links payload is malformed" }; + } + const socialParsed = parseSocialLinks(socialInput); + if ( + Array.isArray(socialInput) && + socialParsed.length !== socialInput.length + ) { return { ok: false, - error: `Social link "${link.label}" has an invalid SVG.`, + error: + "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.", }; } - socialLinks.push({ ...link, svg }); + socialLinks = []; + for (const link of socialParsed) { + const svg = normalizeSocialSvg(link.svg); + if (svg === null) { + return { + ok: false, + error: `Social link "${link.label}" has an invalid SVG.`, + }; + } + socialLinks.push({ ...link, svg }); + } } let channelsInput: unknown; @@ -105,7 +114,7 @@ export async function saveSiteAction( siteDescription, headerTitle, homeTagline, - socialLinks, + ...(socialLinks !== undefined ? { socialLinks } : {}), groups, defaultGroupId, channels, diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -5,7 +5,11 @@ import { useActionState } from "react"; import { saveSiteAction, type SaveResult } from "../actions"; import type { Site } from "yt-dlp-transcript-common/lib/site"; import type { ChannelGroup } from "yt-dlp-transcript-common/lib/channelGroups"; -import type { SocialLink } from "yt-dlp-transcript-common/lib/settings"; +import { + SocialLinksField, + toSocialRow, + type SocialRow, +} from "../../components/SocialLinksField"; export type ChannelOption = { slug: string; name: string }; @@ -21,7 +25,6 @@ type GroupRow = { description: string; selectedByDefault: boolean; }; -type SocialRow = { label: string; url: string; svg: string }; function toGroupRow(g: ChannelGroup): GroupRow { return { @@ -31,9 +34,6 @@ function toGroupRow(g: ChannelGroup): GroupRow { selectedByDefault: g.selectedByDefault, }; } -function toSocialRow(s: SocialLink): SocialRow { - return { label: s.label, url: s.url, svg: s.svg }; -} export function SiteForm({ initial, channels, isNew }: Props) { const [state, formAction] = useActionState<SaveResult | undefined, FormData>( @@ -44,8 +44,13 @@ export function SiteForm({ initial, channels, isNew }: Props) { initial.groups.map(toGroupRow), ); 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. + const [inheritSocial, setInheritSocial] = useState( + initial.socialLinks === undefined, + ); const [social, setSocial] = useState<SocialRow[]>(() => - initial.socialLinks.map(toSocialRow), + (initial.socialLinks ?? []).map(toSocialRow), ); // Membership keyed by slug. Order = the order channels are listed; members // serialize in `channels` array order. @@ -62,13 +67,6 @@ export function SiteForm({ initial, channels, isNew }: Props) { order: idx, })), ); - const socialLinksJson = JSON.stringify( - social.map((s) => ({ - label: s.label.trim(), - url: s.url.trim(), - svg: s.svg.trim(), - })), - ); const channelsJson = JSON.stringify( channels .filter((c) => members.has(c.slug)) @@ -115,15 +113,6 @@ export function SiteForm({ initial, channels, isNew }: Props) { } return next; }); - const moveSocial = (idx: number, delta: -1 | 1) => - setSocial((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 groupOptions = groups .map((g) => ({ id: g.id.trim(), label: g.name.trim() || g.id.trim() })) .filter((g) => g.id); @@ -132,7 +121,6 @@ export function SiteForm({ initial, channels, isNew }: Props) { <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="socialLinksJson" value={socialLinksJson} /> <input type="hidden" name="channelsJson" value={channelsJson} /> {isNew ? ( @@ -367,90 +355,33 @@ export function SiteForm({ initial, channels, isNew }: Props) { <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">Social links</legend> - {social.map((s, 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-end gap-1"> - <button - type="button" - onClick={() => moveSocial(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 link up" - > - ↑ - </button> - <button - type="button" - onClick={() => moveSocial(idx, 1)} - disabled={idx === social.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 link down" - > - ↓ - </button> - <button - type="button" - onClick={() => setSocial((prev) => prev.filter((_, i) => i !== 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> - <input - type="text" - value={s.label} - onChange={(e) => - setSocial((prev) => - prev.map((row, i) => - i === idx ? { ...row, label: e.target.value } : row, - ), - ) - } - placeholder="Label (e.g. GitHub)" - className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm" - /> - <input - type="text" - value={s.url} - onChange={(e) => - setSocial((prev) => - prev.map((row, i) => - i === idx ? { ...row, url: e.target.value } : row, - ), - ) - } - placeholder="https://…" - className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm" - /> - <textarea - value={s.svg} - onChange={(e) => - setSocial((prev) => - prev.map((row, i) => - i === idx ? { ...row, svg: e.target.value } : row, - ), - ) - } - rows={4} - placeholder='<svg viewBox="0 0 24 24"><path d="…"/></svg>' - className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-xs font-mono" - /> - </div> - ))} - <div> - <button - type="button" - onClick={() => - setSocial((prev) => [...prev, { label: "", url: "", svg: "" }]) - } - 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 link - </button> - </div> + <input + type="hidden" + name="inheritSocialLinks" + value={inheritSocial ? "on" : ""} + /> + <label className="flex items-center gap-2 text-sm"> + <input + type="checkbox" + checked={inheritSocial} + onChange={(e) => setInheritSocial(e.target.checked)} + className="accent-blue-600" + /> + Use global default social links + </label> + {inheritSocial ? ( + <p className="text-xs text-zinc-500"> + This site&apos;s footer uses the social links set in{" "} + <span className="font-medium">Settings</span>. Uncheck to give this + site its own list (an empty list shows no links). + </p> + ) : ( + <SocialLinksField + value={social} + onChange={setSocial} + name="socialLinksJson" + /> + )} </fieldset> <div className="flex items-center gap-3"> diff --git a/editor/app/sites/new/page.tsx b/editor/app/sites/new/page.tsx @@ -24,7 +24,7 @@ export default async function NewSitePage() { siteDescription: "", headerTitle: "", homeTagline: "", - socialLinks: [], + // socialLinks omitted = inherit the global default. groups: [{ ...FALLBACK_GROUP }], defaultGroupId: DEFAULT_GROUP_FALLBACK_ID, channels: [], diff --git a/editor/e2e/export-search.spec.ts b/editor/e2e/export-search.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from "@playwright/test"; +import { readJson, writeSettings } from "./helpers"; const EXPORT_BASE = `http://localhost:${process.env.EXPORT_PORT ?? 3010}`; @@ -589,6 +590,40 @@ test.describe("export footer", () => { expect(svgHtml).not.toMatch(/<svg[^>]*\swidth=/); expect(svgHtml).not.toMatch(/<svg[^>]*\sheight=/); }); + + test("a site's own social links win over the global default", async ({ + page, + }) => { + // The export server shares the editor's test-settings.json. Set a global + // default, then confirm the testsite (which defines its own links) + // overrides it instead of showing the global one. Restore afterwards so we + // don't leak settings into later tests. + const original = await readJson<Record<string, unknown>>( + "test-settings.json", + ); + try { + await writeSettings({ + ...original, + socialLinks: [ + { + label: "GlobalOnly", + url: "https://global.example", + svg: '<svg viewBox="0 0 24 24"><path d="M1 1h2v2H1z"/></svg>', + }, + ], + }); + await page.reload(); + await waitForHydration(page); + + const footer = page.locator("footer"); + await expect(footer.getByRole("link", { name: "GitHub" })).toBeVisible(); + await expect( + footer.getByRole("link", { name: "GlobalOnly" }), + ).toHaveCount(0); + } finally { + await writeSettings(original); + } + }); }); test.describe("export TranscriptSearch — deferred search submit", () => { diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts @@ -67,7 +67,9 @@ export async function writeSite( siteDescription: site.siteDescription ?? "", headerTitle: site.headerTitle ?? site.siteTitle ?? siteId, homeTagline: site.homeTagline ?? "", - socialLinks: site.socialLinks ?? [], + // Omit the key entirely when unspecified so the site inherits the global + // default; pass an explicit array (incl. []) to override. + ...("socialLinks" in site ? { socialLinks: site.socialLinks } : {}), groups: site.groups ?? [ { id: "default", name: "All channels", selectedByDefault: true }, ], diff --git a/editor/e2e/settings.spec.ts b/editor/e2e/settings.spec.ts @@ -31,6 +31,31 @@ test("saves settings via the form", async ({ page }) => { expect(saved.groups).toBeUndefined(); }); +test("saves global default social links", async ({ page }) => { + await page.goto("/settings"); + await page.getByRole("button", { name: /add link/i }).click(); + await page.getByPlaceholder(/label \(e\.g\. github\)/i).fill("GitHub"); + await page + .getByPlaceholder(/https:\/\//) + .fill("https://github.com/example"); + await page + .getByPlaceholder(/<svg viewbox/i) + .fill('<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>'); + + await page.getByRole("button", { name: /save settings/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + + const saved = await readJson<{ + socialLinks?: { label: string; url: string; svg: string }[]; + }>("test-settings.json"); + expect(saved.socialLinks).toHaveLength(1); + expect(saved.socialLinks?.[0].label).toBe("GitHub"); + // SVG was normalized on save (fill="currentColor" injected). + expect(saved.socialLinks?.[0].svg).toContain("currentColor"); +}); + test("rejects out-of-range maxTranscriptPageBytes", async ({ page }) => { await page.goto("/settings"); await page.getByLabel(/max transcript page bytes/i).fill("42"); diff --git a/editor/e2e/sites-crud.spec.ts b/editor/e2e/sites-crud.spec.ts @@ -8,6 +8,15 @@ type SiteFile = { channels: { slug: string; groupId?: string }[]; }; +type SocialSiteFile = { + socialLinks?: { label: string; url: string; svg: 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>'; +const INHERIT_TOGGLE = /use global default social links/i; + test("create a site with channel membership via the UI", async ({ page }) => { await resetData("one-youtube-channel-with-data"); @@ -70,6 +79,99 @@ test("delete removes the site config", async ({ page }) => { ); }); +test("new site inherits the global social links by default (no socialLinks key)", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/sites/new"); + await page.getByLabel(/site id/i).fill("inheritsite"); + await page.getByLabel(/site title/i).fill("Inherit Site"); + await page.getByLabel(/header title/i).fill("Inherit Site"); + await expect(page.getByRole("checkbox", { name: INHERIT_TOGGLE })).toBeChecked(); + + await page.getByRole("button", { name: /create site/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + + const site = await readJson<SocialSiteFile>( + "test-transcripts/sites/inheritsite/site.json", + ); + // Inheriting = the key is absent so resolveSocialLinks falls back to global. + expect("socialLinks" in site).toBe(false); +}); + +test("unchecking inherit and adding a link writes the site's own socialLinks", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/sites/new"); + await page.getByLabel(/site id/i).fill("ownlinks"); + await page.getByLabel(/site title/i).fill("Own Links"); + await page.getByLabel(/header title/i).fill("Own Links"); + + await page.getByRole("checkbox", { name: INHERIT_TOGGLE }).uncheck(); + await page.getByRole("button", { name: /add link/i }).click(); + await page.getByPlaceholder(/label \(e\.g\. github\)/i).fill("Mastodon"); + await page.getByPlaceholder(/https:\/\//).fill("https://example.social/@me"); + await page.getByPlaceholder(/<svg viewbox/i).fill(VALID_SVG); + + await page.getByRole("button", { name: /create site/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + + const site = await readJson<SocialSiteFile>( + "test-transcripts/sites/ownlinks/site.json", + ); + expect(site.socialLinks).toHaveLength(1); + expect(site.socialLinks?.[0].label).toBe("Mastodon"); + expect(site.socialLinks?.[0].url).toBe("https://example.social/@me"); + expect(site.socialLinks?.[0].svg).toContain("M0 0h24v24H0z"); +}); + +test("unchecking inherit with no links writes an empty socialLinks override", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/sites/new"); + await page.getByLabel(/site id/i).fill("nolinks"); + await page.getByLabel(/site title/i).fill("No Links"); + await page.getByLabel(/header title/i).fill("No Links"); + + await page.getByRole("checkbox", { name: INHERIT_TOGGLE }).uncheck(); + await page.getByRole("button", { name: /create site/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + + const site = await readJson<SocialSiteFile>( + "test-transcripts/sites/nolinks/site.json", + ); + // Explicit empty override = show no links even if a global default exists. + expect(site.socialLinks).toEqual([]); +}); + +test("a site with its own links opens with the inherit toggle off", async ({ + page, +}) => { + await resetData("empty"); + await writeSite("hasown", { + siteTitle: "Has Own", + socialLinks: [ + { label: "GitHub", url: "https://github.com/example", svg: VALID_SVG }, + ], + }); + + await page.goto("/sites/hasown"); + await expect( + page.getByRole("checkbox", { name: INHERIT_TOGGLE }), + ).not.toBeChecked(); + await expect(page.getByPlaceholder(/label \(e\.g\. github\)/i)).toHaveValue( + "GitHub", + ); +}); + test("first-run migrate button appears only when no sites exist", async ({ page, }) => { diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx @@ -1,7 +1,9 @@ +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { resolveSocialLinks } from "yt-dlp-transcript-common/lib/site"; import { currentSite } from "../lib/site"; export default function Footer() { - const { socialLinks } = currentSite(); + const socialLinks = resolveSocialLinks(currentSite(), getSettings()); 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">