ui: idea-md-editor with yaml-validate + wire into detail tabs (M12 T-511)
components/ideas/idea-md-editor.tsx: - Textarea-based editor with monospace styling for grill_md / plan_md - kind='plan': live yaml-frontmatter validation as derived state via useMemo (no setState-in-effect); inline errors with line numbers - kind='grill': free markdown, no validation - localStorage draft per (ideaId, kind) — lazy initial-value seeded on mount; toast notice if drift from server - Cmd/Ctrl+S keyboard shortcut to save - Server-action 422 details surface as separate submitErrors state components/ideas/idea-detail-layout.tsx: - Grill/Plan tabs flip into edit-mode via "Bewerk" button when: - grill: status in [GRILLED, PLAN_READY] (M12 grill-keuze 12) - plan: status === PLAN_READY - Empty-state offers "Schrijf zelf" when md is null + editable - Demo always read-only Tests: 546/546 still green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1362996a2b
commit
9d3a993f2a
2 changed files with 216 additions and 12 deletions
|
|
@ -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<IdeaStatusApi, Parameters<typeof getIdeaStatusBadge>[0]> = {
|
||||
draft: 'DRAFT',
|
||||
|
|
@ -215,7 +216,10 @@ export function IdeaDetailLayout({
|
|||
<MdSection
|
||||
kind="grill"
|
||||
markdown={grill_md}
|
||||
editable={false /* T-511 enables this in GRILLED|PLAN_READY */}
|
||||
// M12 grill-keuze 12: grill_md editable in GRILLED + PLAN_READY.
|
||||
editable={
|
||||
!isDemo && (idea.status === 'grilled' || idea.status === 'plan_ready')
|
||||
}
|
||||
ideaId={idea.id}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -223,7 +227,8 @@ export function IdeaDetailLayout({
|
|||
<MdSection
|
||||
kind="plan"
|
||||
markdown={plan_md}
|
||||
editable={false /* T-511 enables in PLAN_READY */}
|
||||
// M12 grill-keuze 12: plan_md editable alleen in PLAN_READY.
|
||||
editable={!isDemo && idea.status === 'plan_ready'}
|
||||
ideaId={idea.id}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -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 (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center italic">
|
||||
{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".'}
|
||||
</p>
|
||||
<IdeaMdEditor
|
||||
ideaId={ideaId}
|
||||
kind={kind}
|
||||
initialValue={markdown ?? ''}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!markdown) {
|
||||
return (
|
||||
<div className="space-y-3 py-6">
|
||||
<p className="text-sm text-muted-foreground text-center italic">
|
||||
{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".'}
|
||||
</p>
|
||||
{editable && (
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||
Schrijf zelf
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="rounded-md border border-input bg-surface-container p-4 text-sm whitespace-pre-wrap font-mono leading-relaxed overflow-x-auto">
|
||||
{markdown}
|
||||
</pre>
|
||||
<div className="space-y-3">
|
||||
{editable && (
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||
Bewerk
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<pre className="rounded-md border border-input bg-surface-container p-4 text-sm whitespace-pre-wrap font-mono leading-relaxed overflow-x-auto">
|
||||
{markdown}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
167
components/ideas/idea-md-editor.tsx
Normal file
167
components/ideas/idea-md-editor.tsx
Normal file
|
|
@ -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<PlanParseError[]>([])
|
||||
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<PlanParseError[]>(() => {
|
||||
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<HTMLTextAreaElement>) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
const dirty = value !== initialValue
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{errors.length > 0 && (
|
||||
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-3 space-y-1">
|
||||
<p className="text-xs font-medium text-status-blocked">
|
||||
{kind === 'plan' ? 'YAML-frontmatter fouten' : 'Validatiefouten'}
|
||||
</p>
|
||||
<ul className="text-xs text-status-blocked space-y-0.5">
|
||||
{errors.map((err, i) => (
|
||||
<li key={i}>
|
||||
{err.line ? `Regel ${err.line}: ` : ''}
|
||||
{err.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={24}
|
||||
className="font-mono text-sm leading-relaxed"
|
||||
placeholder={
|
||||
kind === 'grill'
|
||||
? '# Idee — ...\n## Scope\n...'
|
||||
: '---\npbi:\n title: ...\n priority: 2\nstories:\n - title: ...\n---\n\n# Overwegingen\n...'
|
||||
}
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dirty ? 'Niet-opgeslagen wijzigingen — Cmd/Ctrl+S om op te slaan' : 'Geen wijzigingen'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={submitting}>
|
||||
<X className="size-3.5 mr-1" />
|
||||
Annuleer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={save}
|
||||
disabled={!dirty || submitting || (errors.length > 0 && kind === 'plan')}
|
||||
>
|
||||
<Save className="size-3.5 mr-1" />
|
||||
Opslaan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue