'use client'; /** * DocumentEditor — a Google-Docs-like editor over typed document sub-sections. * * The reusable surface a tenant app uses to review + edit a multi-part content * record (e.g. a marketing draft = blog markdown + LinkedIn text + SEO fields + * sources). Generic + schema-driven (see `./sections`), presentational, and * CONTROLLED: the parent owns the section values (via `onChange`) and all * persistence (`onSave` is debounced autosave — bring your own writer). It never * fetches. Complements the read-only DraftReviewWorkspace. */ import * as React from 'react'; import { Columns2, Eye, Pencil, Plus, Trash2, Loader2, Check, AlertCircle } from 'lucide-react'; import { cn } from '../../lib/utils'; import { MarkdownRenderer } from '../blog/MarkdownRenderer'; import type { DocSection } from './types'; export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; export interface DocumentEditorProps { sections: DocSection[]; /** Controlled per-section edit — the parent updates its section state. */ onChange?(key: string, value: unknown): void; /** Debounced autosave (BYO persistence). Receives the current sections. */ onSave?(sections: DocSection[]): void | Promise; /** Autosave debounce, ms. Default 1200. */ autosaveMs?: number; /** Render every section read-only (previews only). */ readOnly?: boolean; /** Title / actions slot rendered in the header bar (e.g. a "Mark ready" button). */ header?: React.ReactNode; className?: string; } function countWords(s: string): number { const t = s.trim(); return t ? t.split(/\s+/).length : 0; } function asString(v: unknown): string { return typeof v === 'string' ? v : v == null ? '' : String(v); } function asRecord(v: unknown): Record { if (v && typeof v === 'object' && !Array.isArray(v)) { const out: Record = {}; for (const [k, val] of Object.entries(v as Record)) out[k] = asString(val); return out; } return {}; } function asList(v: unknown): string[] { return Array.isArray(v) ? v.map(asString) : []; } /** A key like `meta_description` / `metaDescription` → an SEO description meter. */ function isMetaDescriptionKey(key: string): boolean { return /meta[_-]?description/i.test(key) || /^description$/i.test(key); } export function DocumentEditor({ sections, onChange, onSave, autosaveMs = 1200, readOnly = false, header, className, }: DocumentEditorProps) { const [status, setStatus] = React.useState('idle'); // Serialize the section values so autosave fires only on a real content change. const serialized = React.useMemo( () => JSON.stringify(sections.map((s) => [s.key, s.value])), [sections], ); // Keep latest sections + onSave in refs so the debounce effect depends only on // the serialized content (not on a fresh inline `onSave` identity each render). const sectionsRef = React.useRef(sections); sectionsRef.current = sections; const onSaveRef = React.useRef(onSave); onSaveRef.current = onSave; const savedRef = React.useRef(serialized); React.useEffect(() => { if (readOnly) return; const fn = onSaveRef.current; if (!fn) return; if (serialized === savedRef.current) return; let cancelled = false; const timer = setTimeout(async () => { setStatus('saving'); try { await fn(sectionsRef.current); if (cancelled) return; savedRef.current = serialized; setStatus('saved'); } catch { if (!cancelled) setStatus('error'); } }, autosaveMs); return () => { cancelled = true; clearTimeout(timer); }; }, [serialized, readOnly, autosaveMs]); const showBar = header != null || (!readOnly && !!onSave); return (
{showBar && (
{header}
{!readOnly && onSave && }
)}
{sections.map((section) => ( ))}
); } function SaveStatusPill({ status }: { status: SaveStatus }) { const map: Record = { idle: { label: '', tone: 'text-muted-foreground', icon: null }, saving: { label: 'Saving…', tone: 'text-muted-foreground', icon: }, saved: { label: 'Saved', tone: 'text-emerald-600', icon: }, error: { label: 'Save failed', tone: 'text-rose-600', icon: }, }; const s = map[status]; if (!s.label) return null; return ( {s.icon} {s.label} ); } function SectionEditor({ section, readOnly, onChange, }: { section: DocSection; readOnly: boolean; onChange?(key: string, value: unknown): void; }) { const emit = React.useCallback((value: unknown) => onChange?.(section.key, value), [onChange, section.key]); return (
); } function SectionHeader({ section, right }: { section: DocSection; right?: React.ReactNode }) { return (

{section.label}

{right}
); } function SectionBody({ section, readOnly, emit, }: { section: DocSection; readOnly: boolean; emit: (value: unknown) => void; }) { switch (section.kind) { case 'markdown': return ; case 'text': return ; case 'structured': return ; case 'list': return ; default: return null; } } type ViewMode = 'edit' | 'preview' | 'split'; function MarkdownSection({ section, readOnly, emit, }: { section: DocSection; readOnly: boolean; emit: (value: unknown) => void; }) { const value = asString(section.value); const [mode, setMode] = React.useState('split'); const words = countWords(value); if (readOnly) { return ( <> {words} words} />
{value ? ( ) : (

Empty.

)}
); } const showEditor = mode === 'edit' || mode === 'split'; const showPreview = mode === 'preview' || mode === 'split'; return ( <> {words} words } />
{showEditor && (