diff --git a/components/ideas/idea-detail-layout.tsx b/components/ideas/idea-detail-layout.tsx index 7257314..d46b126 100644 --- a/components/ideas/idea-detail-layout.tsx +++ b/components/ideas/idea-detail-layout.tsx @@ -22,6 +22,7 @@ import { isIdeaEditable } from '@/lib/idea-status' import type { IdeaDto } from '@/lib/idea-dto' import { updateIdeaAction, archiveIdeaAction } from '@/actions/ideas' import { IdeaRowActions } from '@/components/ideas/idea-row-actions' +import { IdeaMdEditor } from '@/components/ideas/idea-md-editor' const API_TO_DB: Record[0]> = { draft: 'DRAFT', @@ -215,7 +216,10 @@ export function IdeaDetailLayout({ )} @@ -223,7 +227,8 @@ export function IdeaDetailLayout({ )} @@ -350,20 +355,52 @@ interface MdProps { ideaId: string } -function MdSection({ kind, markdown }: MdProps) { - if (!markdown) { +function MdSection({ kind, markdown, editable, ideaId }: MdProps) { + const [editing, setEditing] = useState(false) + + if (editing) { return ( -

- {kind === 'grill' - ? 'Nog geen grill-resultaat. Klik "Grill" in de header om te starten.' - : 'Nog geen plan. Voltooi eerst de grill-fase en klik dan "Plan".'} -

+ setEditing(false)} + /> ) } + + if (!markdown) { + return ( +
+

+ {kind === 'grill' + ? 'Nog geen grill-resultaat. Klik "Grill" in de header om te starten.' + : 'Nog geen plan. Voltooi eerst de grill-fase en klik dan "Plan".'} +

+ {editable && ( +
+ +
+ )} +
+ ) + } + return ( -
-      {markdown}
-    
+
+ {editable && ( +
+ +
+ )} +
+        {markdown}
+      
+
) } diff --git a/components/ideas/idea-md-editor.tsx b/components/ideas/idea-md-editor.tsx new file mode 100644 index 0000000..1d7c46e --- /dev/null +++ b/components/ideas/idea-md-editor.tsx @@ -0,0 +1,167 @@ +'use client' + +// IdeaMdEditor — bewerk grill_md of plan_md. +// +// - kind='grill': geen yaml-validatie (vrije markdown). +// - kind='plan' : preflight via parsePlanMd (server-side action herhaalt +// validation, dit is alleen UX om eerder te falen). +// +// Save → updateGrillMdAction / updatePlanMdAction. Cmd/Ctrl+S triggert save. +// LocalStorage-backed draft per idea+kind, restore bij heropening. + +import { useEffect, useMemo, useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { Save, X } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { parsePlanMd, type PlanParseError } from '@/lib/idea-plan-parser' +import { updateGrillMdAction, updatePlanMdAction } from '@/actions/ideas' + +type Kind = 'grill' | 'plan' + +interface Props { + ideaId: string + kind: Kind + initialValue: string + onCancel: () => void +} + +// Lazily compute the seed: read draft from localStorage on first render, fall +// back to initialValue. Avoids setState-in-useEffect for hydration. +function readSeed(draftKey: string, initialValue: string): { + value: string + restored: boolean +} { + if (typeof window === 'undefined') return { value: initialValue, restored: false } + const draft = window.localStorage.getItem(draftKey) + if (draft && draft !== initialValue) return { value: draft, restored: true } + return { value: initialValue, restored: false } +} + +export function IdeaMdEditor({ ideaId, kind, initialValue, onCancel }: Props) { + const router = useRouter() + const draftKey = `idea-md-draft-${ideaId}-${kind}` + const [seed] = useState(() => readSeed(draftKey, initialValue)) + const [value, setValue] = useState(seed.value) + const [submitErrors, setSubmitErrors] = useState([]) + const [submitting, startSubmit] = useTransition() + + // Eenmalige toast voor restore — de seed is al toegepast bij mount. + useEffect(() => { + if (seed.restored) { + toast.info('Niet-opgeslagen wijziging hersteld uit lokale draft.') + } + }, [seed.restored]) + + // Auto-save naar localStorage on change. + useEffect(() => { + if (typeof window === 'undefined') return + if (value === initialValue) { + window.localStorage.removeItem(draftKey) + } else { + window.localStorage.setItem(draftKey, value) + } + }, [value, initialValue, draftKey]) + + // Live yaml-validatie als afgeleide state — geen useEffect nodig. + const validationErrors = useMemo(() => { + if (kind !== 'plan') return [] + if (value === '' || value === initialValue) return [] + const r = parsePlanMd(value) + return r.ok ? [] : r.errors + }, [value, initialValue, kind]) + + // Combine: validation errors voor live feedback, submitErrors voor server-side details. + const errors = submitErrors.length > 0 ? submitErrors : validationErrors + + function save() { + if (errors.length > 0 && kind === 'plan') { + toast.error('Frontmatter heeft fouten — fix die eerst.') + return + } + setSubmitErrors([]) + startSubmit(async () => { + const r = + kind === 'grill' + ? await updateGrillMdAction(ideaId, value) + : await updatePlanMdAction(ideaId, value) + if ('error' in r) { + toast.error(r.error) + if ('details' in r && Array.isArray(r.details)) { + setSubmitErrors(r.details as PlanParseError[]) + } + return + } + toast.success('Opgeslagen') + window.localStorage.removeItem(draftKey) + router.refresh() + onCancel() + }) + } + + // Cmd/Ctrl+S → save + function onKeyDown(e: React.KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { + e.preventDefault() + save() + } + } + + const dirty = value !== initialValue + + return ( +
+ {errors.length > 0 && ( +
+

+ {kind === 'plan' ? 'YAML-frontmatter fouten' : 'Validatiefouten'} +

+
    + {errors.map((err, i) => ( +
  • + {err.line ? `Regel ${err.line}: ` : ''} + {err.message} +
  • + ))} +
+
+ )} + +