"use client"; import { useState, type CSSProperties } from "react"; import { useActionState } from "react"; import Link from "next/link"; import { saveSiteAction, type SaveResult } from "../actions"; import type { Site } from "yt-dlp-transcript-common/lib/site"; import { ACCENTS, ACCENT_IDS, DEFAULT_ACCENT, isAccentId, } from "yt-dlp-transcript-common/lib/brand"; import type { ChannelGroup } from "yt-dlp-transcript-common/lib/channelGroups"; import { SocialLinksField, toSocialRow, type SocialRow, } from "../../components/SocialLinksField"; import { Field, SeededSelect } from "../../components/forms/Field"; import { ControlledCheck, ControlledSelect, } from "../../components/forms/Controlled"; import { seedChecked } from "../../lib/formState"; 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; }; type GroupRow = { id: string; name: string; description: string; selectedByDefault: boolean; inline: 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, name: g.name, description: g.description ?? "", selectedByDefault: g.selectedByDefault, inline: g.inline === true, }; } export function SiteForm({ initial, channels, allSites, isNew }: Props) { const [state, formAction] = useActionState( saveSiteAction, undefined, ); const [groups, setGroups] = useState(() => 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(() => (initial.relatedSites ?? []).map((g) => ({ label: g.label ?? "", siteIds: [...g.siteIds], })), ); const [defaultGroupId, setDefaultGroupId] = useState(initial.defaultGroupId); // Brand accent: one of the named accents (absent reads as the default, // Signal) or "custom" with its own hex. Controlled, so the choice survives // the form reset React does after an action. const initialCustom = initial.accent && !isAccentId(initial.accent) ? initial.accent : ""; const [accentChoice, setAccentChoice] = useState(() => initialCustom ? "custom" : (initial.accent ?? DEFAULT_ACCENT), ); const [customHex, setCustomHex] = useState(initialCustom); const customSwatch = /^#?[0-9a-f]{6}$/i.test(customHex.trim()) ? `#${customHex.trim().replace(/^#/, "")}` : undefined; // 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(() => (initial.socialLinks ?? []).map(toSocialRow), ); // Membership keyed by slug. Order = the order channels are listed; members // serialize in `channels` array order. const [members, setMembers] = useState>( () => new Map(initial.channels.map((c) => [c.slug, c.groupId ?? ""])), ); const groupsJson = JSON.stringify( groups.map((g, idx) => ({ id: g.id.trim(), name: g.name.trim(), description: g.description.trim() || undefined, selectedByDefault: g.selectedByDefault, order: idx, inline: g.inline || undefined, })), ); const channelsJson = JSON.stringify( channels .filter((c) => members.has(c.slug)) .map((c, idx) => { const groupId = members.get(c.slug)?.trim(); return groupId ? { slug: c.slug, groupId, order: idx } : { slug: c.slug, order: idx }; }), ); const toggleMember = (slug: string, on: boolean) => setMembers((prev) => { const next = new Map(prev); if (on) next.set(slug, prev.get(slug) ?? ""); else next.delete(slug); return next; }); const setMemberGroup = (slug: string, groupId: string) => setMembers((prev) => { const next = new Map(prev); next.set(slug, groupId); return next; }); const moveGroup = (idx: number, delta: -1 | 1) => setGroups((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 addGroup = () => setGroups((prev) => [ ...prev, // Manually added groups default off; only the seeded FALLBACK_GROUP // starts with inline on. { id: "", name: "", description: "", selectedByDefault: false, inline: false }, ]); const removeGroup = (idx: number) => setGroups((prev) => { const next = prev.filter((_, i) => i !== idx); if (next.length > 0 && !next.some((g) => g.id.trim() === defaultGroupId)) { setDefaultGroupId(next[0].id.trim()); } return next; }); const groupOptions = groups .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 (
{isNew ? ( ) : ( <>
Site ID:{" "} {initial.siteId}
)}
Brand accent

This site's accent (its icon and every page). A custom colour is darkened or lightened on each base (light, dark) until it reads at 4.5:1.

{ACCENT_IDS.map((id) => ( ))}

Off: the site is its reports only.

Reports {initial.reports && initial.reports.length > 0 ? (
    {initial.reports.map((id) => (
  1. {id}
  2. ))}
) : ( None )} The published reports, in order (report ids under this site's reports/ directory). Not edited here: saving this form keeps the list as it is. {!isNew && ( <> {" "}Publish, unpublish and reorder them on the{" "} Reports {" "} tab. )}

On by default. Turn off to leave this site out of the homepage (its card, chart and stats), the hub (its members, search, corpus.json and llms.txt) and every other site's footer, and to count its own channels in none of the published totals. The site still builds and deploys as before, at its own URL.

Off by default: the site serves its data for the hub to read but isn't independently installable and ships no service worker. Turn on to make this site installable with offline support.

On by default: each build produces per-channel transcript & live-chat zips and lists them on the site's Downloads page. Turn off to skip generation for this site.

On by default: a visitor's transcript viewer offers the transcript as a txt/srt/json file and as Markdown. Turn off to hide both on this site; the yt-dlp command for a marked clip is not a download and stays. The machine contract (corpus.json, manifests, shards) is published either way.

On by default: publishes the cross-channel duplicate-shorts page and its header link. Turn off to hide it for this site. Even when on, the link and page auto-hide when this site has no detected duplicates.

Channels

Pick the channels this site exposes and the group each belongs to. Channels not checked here are excluded from this site's build.

{channels.length === 0 && (
No channels configured.
)} {channels.map((c) => { const on = members.has(c.slug); return (
{on && groupOptions.length > 0 && ( setMemberGroup(c.slug, e.target.value)} aria-label={`Group for ${c.name || c.slug}`} className="rounded border border-border bg-card px-1.5 py-0.5 text-xs shrink-0" > {groupOptions.map((g) => ( ))} )}
); })}
Channel groups

Cosmetic groupings the export UI uses to organize this site's channel checkboxes.

{groups.map((g, idx) => { const idTrim = g.id.trim(); const isDefault = idTrim !== "" && idTrim === defaultGroupId; return (