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:
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'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'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">