"use server"; import { PUBLISH_POLICIES, type PublishPolicy } from "yt-dlp-transcript-common/lib/settingsSchema"; import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { parseAccent, parseAccentSetting, } from "yt-dlp-transcript-common/lib/accent"; import { DEFAULT_ACCENT, wordmarkLeadFor, } from "yt-dlp-transcript-common/lib/brand"; import { getSite, writeSite, deleteSite, isValidSiteId, parseRelatedSites, parseSiteChannels, parseSiteUrl, type Site, } from "yt-dlp-transcript-common/lib/site"; import { parseSocialLinks, type SocialLink, } from "yt-dlp-transcript-common/lib/settings"; import { parseChannelGroups, resolveDefaultGroupId, } from "yt-dlp-transcript-common/lib/channelGroups"; import { migrateToSites } from "yt-dlp-transcript-common/controller/migrateToSites"; import { socialLinksForSave } from "yt-dlp-transcript-common/lib/socialLinks"; import { formValues, type FormState } from "../lib/formState"; import { reportSiteKeysFromForm } from "./lib/reportSiteKeys"; // A failure carries what was submitted, so SiteForm keeps what was typed // (lib/formState.ts). export type SaveResult = FormState<{ siteId: string }>; export async function saveSiteAction( _prev: SaveResult | undefined, formData: FormData, ): Promise { const values = formValues(formData); const siteId = String(formData.get("siteId") ?? "").trim(); if (!isValidSiteId(siteId)) { return { ok: false, error: "Site ID must be lowercase letters, numbers, and hyphens.", values, }; } const siteTitle = String(formData.get("siteTitle") ?? "").trim(); const headerTitle = String(formData.get("headerTitle") ?? "").trim(); if (!siteTitle) { return { ok: false, error: "Site title is required", values }; } if (!headerTitle) { return { ok: false, error: "Header title is required", values }; } const siteDescription = String(formData.get("siteDescription") ?? "").trim(); const homeTagline = String(formData.get("homeTagline") ?? "").trim(); // The wordmark's heavy lead must be a proper prefix of the header title; // lib/siteSchema.ts would silently drop anything else, so say so here. const wordmarkLead = String(formData.get("wordmarkLead") ?? "").trim(); if (wordmarkLead && !wordmarkLeadFor(headerTitle, wordmarkLead)) { return { ok: false, error: `Wordmark lead must be how the header title starts, same case, and shorter than it (e.g. "Jer" for "Jeralyzer") — or left blank.`, values, }; } // Brand accent: a named id from the radio group, or "custom" + its hex // field. The default (Signal) is stored as absent — the two read the same. // A bare hex in `accent` (a form from before the radio group) is taken as // a custom colour. const accentChoice = String(formData.get("accent") ?? "").trim(); const accentCustomRaw = String(formData.get("accentCustom") ?? "").trim(); let accent: string | undefined; if (accentChoice === "custom") { accent = parseAccent(accentCustomRaw); if (!accent) { return { ok: false, error: "Custom accent must be a hex color like #cc3366.", values, }; } } else if (accentChoice) { accent = parseAccentSetting(accentChoice); if (!accent) { return { ok: false, error: `Unknown brand accent "${accentChoice}".`, values, }; } if (accent === DEFAULT_ACCENT) accent = undefined; } 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.", values, }; } const siteUrl = parseSiteUrl(siteUrlRaw); // Listed on the homepage and hub by default: the same opt-out idiom as // archives below (an unchecked box sends no key → persisted as false). const listed = formData.get("listed") === "on"; // Who the site is built for (release 17 slice XP): only "private" is kept. const audienceRaw = String(formData.get("audience") ?? "public"); if (audienceRaw !== "public" && audienceRaw !== "private") { return { ok: false, error: `Unknown audience "${audienceRaw}".`, values }; } const isPrivate = audienceRaw === "private"; // The publish lane's policy for this site (release 18). writeSite refuses a // deploying policy with no Pages project (sitePublishProblem) and stores a // private site's as "build". const publishAutoRaw = String(formData.get("publishAuto") ?? "off"); if (!(PUBLISH_POLICIES as readonly string[]).includes(publishAutoRaw)) { return { ok: false, error: `Unknown publish policy "${publishAutoRaw}".`, values }; } const publishAuto = publishAutoRaw as PublishPolicy; // Search (opt-out; off = report-only) and the report list, which is not on // this form: the stored one is carried over. let storedReports: Pick | undefined; try { storedReports = getSite(siteId, getPaths()); } catch { storedReports = undefined; } const reportKeys = reportSiteKeysFromForm(formData, storedReports); // Hub parent (per-site override of the family default) + PWA opt-in. const hubUrlRaw = String(formData.get("hubUrl") ?? "").trim(); if (hubUrlRaw && !parseSiteUrl(hubUrlRaw)) { return { ok: false, error: "Hub URL must be an absolute http(s):// URL, or left blank.", values, }; } const hubUrl = parseSiteUrl(hubUrlRaw); const pwa = formData.get("pwa") === "on"; // Archives default on: an unchecked (default-on) box yields no "archives" key // → false → persisted as the explicit opt-out. const archives = formData.get("archives") === "on"; // Duplicates default on, same opt-out idiom as archives. const duplicates = formData.get("duplicates") === "on"; // Per-video transcript downloads default on, same opt-out idiom. const transcriptDownloads = formData.get("transcriptDownloads") === "on"; const archiveMaxMBRaw = String(formData.get("archiveMaxMB") ?? "").trim(); let archiveMaxBytes: number | undefined; if (archiveMaxMBRaw) { const mb = Number(archiveMaxMBRaw); if (!Number.isFinite(mb) || mb < 0) { return { ok: false, error: "Archive size cap must be a non-negative number of MB, or blank.", values, }; } archiveMaxBytes = Math.floor(mb * 1024 * 1024); } let relatedSitesInput: unknown; try { relatedSitesInput = JSON.parse( String(formData.get("relatedSitesJson") ?? "[]"), ); } catch { return { ok: false, error: "Related sites payload is malformed", values }; } const relatedSites = parseRelatedSites(relatedSitesInput); let groupsInput: unknown; try { groupsInput = JSON.parse(String(formData.get("groupsJson") ?? "[]")); } catch { return { ok: false, error: "Channel groups payload is malformed", values }; } const groups = parseChannelGroups(groupsInput); if (groups.length === 0) { return { ok: false, error: "At least one channel group is required (check ID/name fields)", values, }; } const defaultGroupIdRaw = String(formData.get("defaultGroupId") ?? "").trim(); const defaultGroupId = resolveDefaultGroupId(defaultGroupIdRaw, groups); if (defaultGroupIdRaw && defaultGroupIdRaw !== defaultGroupId) { return { ok: false, error: `Default group "${defaultGroupIdRaw}" is not among the configured group IDs`, values, }; } // 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", values }; } 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.", values, }; } // A link whose SVG is unchanged from the site's file is kept as it is; a // new or edited one is checked (lib/socialLinks.ts socialLinksForSave). let stored: SocialLink[] = []; try { stored = getSite(siteId, getPaths()).socialLinks ?? []; } catch { stored = []; } const checked = socialLinksForSave(socialParsed, stored); if ("refused" in checked) { return { ok: false, error: `Social link "${checked.refused.label}" has an invalid SVG: ${checked.refused.problem}.`, values, }; } socialLinks = checked.links; } let channelsInput: unknown; try { channelsInput = JSON.parse(String(formData.get("channelsJson") ?? "[]")); } catch { return { ok: false, error: "Channel membership payload is malformed", values, }; } const channels = parseSiteChannels(channelsInput); const site: Site = { siteId, siteTitle, siteDescription, headerTitle, ...(wordmarkLead ? { wordmarkLead } : {}), homeTagline, ...(socialLinks !== undefined ? { socialLinks } : {}), groups, defaultGroupId, channels, ...(accent ? { accent } : {}), ...(cloudflareProject ? { cloudflareProject } : {}), ...(siteUrl ? { siteUrl } : {}), // The Site is rebuilt from the form: a key missing here is dropped on save. ...(listed ? {} : { listed: false }), ...(isPrivate ? { audience: "private" as const } : {}), ...(publishAuto !== "off" ? { publish: { auto: publishAuto } } : {}), ...reportKeys, ...(hubUrl ? { hubUrl } : {}), ...(pwa ? { pwa: true } : {}), ...(archives ? {} : { archives: false }), ...(duplicates ? {} : { duplicates: false }), ...(transcriptDownloads ? {} : { transcriptDownloads: false }), ...(archiveMaxBytes !== undefined ? { archiveMaxBytes } : {}), ...(relatedSites.length > 0 ? { relatedSites } : {}), }; try { await writeSite(site, getPaths()); } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err), values, }; } revalidatePath("/sites"); // "layout": the h1 above every tab reads siteTitle, and a literal path would // revalidate only the settings page (revalidatePath.md). revalidatePath("/sites/[siteId]", "layout"); return { ok: true, siteId }; } export type DeleteResult = { ok: true } | { ok: false; error: string }; export async function deleteSiteAction( siteId: string, ): Promise { try { await deleteSite(siteId, getPaths()); } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } revalidatePath("/sites"); return { ok: true }; } export async function migrateToSitesAction(): Promise { try { await migrateToSites({ paths: getPaths() }); } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } revalidatePath("/sites"); revalidatePath("/settings"); return { ok: true }; }