// localStorage layer for the /widget/builder form so it reopens with your last // configuration. Mirrors editor/app/jobs/jobsFilterStorage.ts (versioned key, // SSR-guarded, reset on malformed/version-mismatch). This persists ONLY the // builder's form state — the shared/embedded /widget URL stays authoritative for // what actually renders (see buildWidgetQuery). import { WIDGET_DEFAULTS, type WidgetConfig } from "../lib/config"; import { MAX_COLUMNS, MAX_ROWS, reconcileLayout, type Cell, type Layout, type Row, } from "../lib/placement"; import { SECTION_BY_ID, type SectionId } from "../lib/sections"; const KEY = "ytdlp-tb:widget-config"; // v3 replaces columns with rows of cells. A v1 or v2 entry resets to defaults, // which is right for both: each predates the dimension the next one added, and // has nothing to carry forward but flags that are already the defaults' shape. const VERSION = 3; type StoredWidgetConfig = { v: typeof VERSION; config: WidgetConfig }; // The layout is the one stored field that isn't a boolean, a string or a // number, so it needs its own validation: a track count, rows of cells, and // section ids we still recognize. Anything shaped wrong is discarded rather // than trusted — this is localStorage, which anything on the origin can write. function parseSections(v: unknown): SectionId[] { if (!Array.isArray(v)) return []; const ids: SectionId[] = []; for (const id of v) { if (typeof id !== "string" || !(id in SECTION_BY_ID)) continue; ids.push(id as SectionId); } return ids; } function parseLayout(v: unknown): Layout | null { if (!v || typeof v !== "object" || Array.isArray(v)) return null; const raw = v as Record; const cols = typeof raw.cols === "number" && Number.isFinite(raw.cols) ? Math.max(1, Math.min(MAX_COLUMNS, Math.round(raw.cols))) : null; if (cols === null) return null; if (!Array.isArray(raw.rows) || raw.rows.length === 0) return null; const rows: Row[] = []; for (const rowRaw of raw.rows.slice(0, MAX_ROWS)) { if (!rowRaw || typeof rowRaw !== "object") return null; const r = rowRaw as Record; if (!Array.isArray(r.cells) || r.cells.length === 0) return null; const cells: Cell[] = []; for (const cellRaw of r.cells) { if (!cellRaw || typeof cellRaw !== "object") return null; const c = cellRaw as Record; cells.push({ sections: parseSections(c.sections), span: typeof c.span === "number" && Number.isFinite(c.span) ? Math.max(1, Math.min(cols, Math.round(c.span))) : 1, flow: c.flow === "row" ? "row" : "col", }); } rows.push({ size: r.size === "fill" ? "fill" : "auto", cells }); } return { cols, rows }; } // Overlay validated fields onto the defaults so the saved form survives the // config gaining new fields (the new field simply takes its default). function parseStored(raw: string): WidgetConfig | null { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return null; } if (!parsed || typeof parsed !== "object") return null; const outer = parsed as Record; if (outer.v !== VERSION) return null; const c = outer.config; if (!c || typeof c !== "object") return null; const r = c as Record; const next: WidgetConfig = { ...WIDGET_DEFAULTS }; for (const key of Object.keys(WIDGET_DEFAULTS) as (keyof WidgetConfig)[]) { const v = r[key]; if (key === "layout") { const layout = parseLayout(v); if (layout) next.layout = layout; continue; } if (key === "channel") { if (typeof v === "string") next.channel = v || undefined; continue; } if (key === "pollSeconds") { if (typeof v === "number" && Number.isFinite(v) && v >= 1) { next.pollSeconds = Math.min(3600, Math.round(v)); } continue; } if (typeof v === "boolean") { (next[key] as boolean) = v; } } // Re-normalize rather than trust: a stored layout could name a section whose // flag was stored off (or miss one stored on), and the flags are what decide. // reconcileLayout also re-imposes the grid invariants, so a hand-edited entry // with spans that don't add up comes back a valid grid rather than a broken // one. return { ...next, layout: reconcileLayout(next.layout, next) }; } export function loadWidgetConfig(): WidgetConfig { if (typeof window === "undefined") return { ...WIDGET_DEFAULTS }; let raw: string | null = null; try { raw = window.localStorage.getItem(KEY); } catch { return { ...WIDGET_DEFAULTS }; } if (raw === null) return { ...WIDGET_DEFAULTS }; const parsed = parseStored(raw); if (!parsed) { try { window.localStorage.removeItem(KEY); } catch { /* ignore */ } return { ...WIDGET_DEFAULTS }; } return parsed; } export function saveWidgetConfig(config: WidgetConfig): void { if (typeof window === "undefined") return; try { const payload: StoredWidgetConfig = { v: VERSION, config }; window.localStorage.setItem(KEY, JSON.stringify(payload)); } catch { /* quota/serialization errors are non-fatal */ } } export function clearWidgetConfig(): void { if (typeof window === "undefined") return; try { window.localStorage.removeItem(KEY); } catch { /* ignore */ } }