"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import type { DisplaySummary } from "../../lib/transcripts"; import type { VideoStat } from "../../lib/stats"; import { newChart, type ChartConfig, type ChartFilters, type Dashboard, type GalleryGroup, type GalleryTemplate, } from "../../lib/chartConfig"; import { stringifyDashboard, parseDashboard, parseChart, } from "../../lib/chartShare"; import { ChartCard } from "./ChartCard"; import { ChartConfigEditor } from "./ChartConfigEditor"; import { FilterBar, type ChannelOption } from "./FilterBar"; type Props = { initialDashboard: Dashboard; gallery: GalleryGroup[]; stats: readonly VideoStat[]; summaries: DisplaySummary[]; channels: ChannelOption[]; // Viewer: reflect the board in the URL for shareable links. syncUrl?: boolean; // Editor: persist authored templates (debounced). onPersist?: (dashboard: Dashboard) => void; }; const primaryBtn = "rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"; const ghostBtn = "rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted"; const STORAGE_KEY = "charts:dashboard"; export function ChartsDashboard({ initialDashboard, gallery, stats, summaries, channels, syncUrl, onPersist, }: Props) { const [dashboard, setDashboard] = useState(initialDashboard); const [editing, setEditing] = useState(null); const [showGallery, setShowGallery] = useState(false); const [shareCopied, setShareCopied] = useState(false); const hydrated = useRef(false); // Set once the user edits the board. Writes (localStorage + ?dash=) only // happen after this, so un-edited visitors keep tracking the latest // editor-authored default and fresh URLs stay clean. const dirty = useRef(false); const markDirty = useCallback(() => { dirty.current = true; }, []); // Restore on mount (viewer only). Precedence: shared link (?dash=) → // localStorage → editor default. Pure restore — does not mark dirty or write. useEffect(() => { if (!syncUrl || hydrated.current) return; hydrated.current = true; const search = new URLSearchParams(window.location.search); // Resolve the base board first (?dash= → localStorage → editor default), // so a "Chart this search" handoff can append onto it. let base = initialDashboard; const fromUrl = search.get("dash"); if (fromUrl) { const parsed = parseDashboard(fromUrl); if (parsed && parsed.charts.length) base = parsed; } else { try { const saved = window.localStorage.getItem(STORAGE_KEY); if (saved) { const parsed = parseDashboard(saved); if (parsed && parsed.charts.length) base = parsed; } } catch { // ignore storage errors (private mode, etc.) } } // "Chart this search" handoff: a single serialized chart to add to (or, in // replace mode, become) the board. Open it in the editor and persist it. const addChart = search.get("addChart"); const chart = addChart ? parseChart(addChart) : null; if (chart) { const fresh: ChartConfig = { ...chart, id: newChart().id }; const next = search.get("chartMode") === "replace" ? { version: 1 as const, title: "Charted search", filters: {}, charts: [fresh] } : { ...base, charts: [...base.charts, fresh] }; dirty.current = true; setDashboard(next); setEditing(fresh); search.delete("addChart"); search.delete("chartMode"); const qs = search.toString(); window.history.replaceState( window.history.state, "", qs ? `${window.location.pathname}?${qs}` : window.location.pathname, ); return; } if (base !== initialDashboard) setDashboard(base); }, [syncUrl, initialDashboard]); // Persist edits to localStorage + ?dash= (viewer only), once dirty. useEffect(() => { if (!syncUrl || !hydrated.current || !dirty.current) return; const encoded = stringifyDashboard(dashboard); try { window.localStorage.setItem(STORAGE_KEY, encoded); } catch { // ignore } const params = new URLSearchParams(window.location.search); params.set("dash", encoded); window.history.replaceState( window.history.state, "", `${window.location.pathname}?${params.toString()}`, ); }, [dashboard, syncUrl]); // Persist authored templates (editor only), debounced. const persistRef = useRef(onPersist); persistRef.current = onPersist; useEffect(() => { if (!persistRef.current) return; const t = setTimeout(() => persistRef.current?.(dashboard), 600); return () => clearTimeout(t); }, [dashboard]); const setFilters = useCallback( (filters: ChartFilters) => { markDirty(); setDashboard((d) => ({ ...d, filters })); }, [markDirty], ); const upsertChart = (cfg: ChartConfig) => { markDirty(); setDashboard((d) => { const idx = d.charts.findIndex((c) => c.id === cfg.id); const charts = idx >= 0 ? d.charts.map((c) => (c.id === cfg.id ? cfg : c)) : [...d.charts, cfg]; return { ...d, charts }; }); setEditing(null); }; const removeChart = (id: string) => { markDirty(); setDashboard((d) => ({ ...d, charts: d.charts.filter((c) => c.id !== id) })); }; const resetToDefault = () => { try { window.localStorage.removeItem(STORAGE_KEY); } catch { // ignore } if (syncUrl) { window.history.replaceState(window.history.state, "", window.location.pathname); } dirty.current = false; setEditing(null); setDashboard(initialDashboard); }; const addFromGallery = (t: GalleryTemplate) => { markDirty(); const cfg: ChartConfig = { ...t.config, id: newChart().id }; setDashboard((d) => ({ ...d, charts: [...d.charts, cfg] })); setShowGallery(false); // Search starters ship with an empty term — open the editor so the user // fills it in immediately. if (cfg.search) setEditing(cfg); }; const share = async () => { const params = new URLSearchParams(); params.set("dash", stringifyDashboard(dashboard)); const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; try { await navigator.clipboard.writeText(url); setShareCopied(true); setTimeout(() => setShareCopied(false), 1800); } catch { window.prompt("Copy this link:", url); } }; return (

{dashboard.title}

{showGallery && (
{gallery.map((group) => (

{group.title}

{group.templates.map((t) => ( ))}
))}
)} {editing && ( setEditing(null)} /> )}
{dashboard.charts.map((cfg) => ( setEditing(cfg)} onRemove={() => removeChart(cfg.id)} /> ))}
{dashboard.charts.length === 0 && (

No charts yet — use “Add chart” to start from a template or “New blank” to build one.

)}
); }