feat(PBI-96/T-1069): doc viewer/editor + delete-button

- app/(app)/products/[id]/docs/[folder]/[slug]/page.tsx: server-route
  die doc laadt (scope-checked via productAccessFilter), frontmatter
  parseert, en op basis van ?edit=1 viewer of editor toont. Fallback
  voor unparseable frontmatter toont errors + raw content in <pre>.
- product-doc-viewer.tsx: server-component met frontmatter-kop
  (title + status-badge + audience/applies_to/last_updated meta) en
  body via <Markdown> (XSS-safe).
- product-doc-editor.tsx: client-wrapper rond MarkdownDocEditor met
  parseProductDocMd validator + updateProductDocAction + cancelHref.
- delete-product-doc-button.tsx: AlertDialog confirm + delete-action
  + DemoTooltip + redirect-na-success. Disabled in demo.
- Edit-knop conditioneel verborgen bij disabled folder (T-1071 voegt
  banner toe); delete blijft altijd zichtbaar voor cleanup.
- Button met `render={<Link/>}` ipv asChild (CLAUDE.md hardstop
  base-ui pattern).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-16 14:30:13 +02:00
parent 3c8699f8e7
commit bb4a71eafa
4 changed files with 381 additions and 0 deletions

View file

@ -0,0 +1,155 @@
// Product Doc detail-page (PBI-96 / T-1069). Server-component.
//
// Loadt + scope-checked de doc, parseert frontmatter, en switcht tussen
// viewer en editor via `?edit=1`. Edit-knop verborgen bij disabled folder
// (zie plan §C.4 + T-1071). Delete-knop blijft altijd zichtbaar (voor
// cleanup van docs in disabled folder); wel DemoTooltip-wrapped.
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Pencil } from 'lucide-react'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { productDocFolderFromApi, productDocFolderToApi } from '@/lib/product-doc-folder'
import { parseProductDocMd } from '@/lib/product-doc-parser'
import { Button } from '@/components/ui/button'
import { FOLDER_LABELS } from '@/components/product-docs/product-docs-index'
import { ProductDocViewer } from '@/components/product-docs/product-doc-viewer'
import { ProductDocEditor } from '@/components/product-docs/product-doc-editor'
import { DeleteProductDocButton } from '@/components/product-docs/delete-product-doc-button'
interface Props {
params: Promise<{ id: string; folder: string; slug: string }>
searchParams: Promise<{ edit?: string }>
}
export default async function ProductDocDetailPage({
params,
searchParams,
}: Props) {
const { id, folder: folderApiParam, slug } = await params
const { edit } = await searchParams
const session = await getSession()
if (!session.userId) redirect('/login')
const folderDb = productDocFolderFromApi(folderApiParam)
if (!folderDb) notFound()
const folderApi = productDocFolderToApi(folderDb)
const docsUrl = `/products/${id}/docs/${folderApi}`
const docUrl = `${docsUrl}/${slug}`
// Scope: doc moet onder een toegankelijk product hangen
const doc = await prisma.productDoc.findFirst({
where: {
product_id: id,
folder: folderDb,
slug,
product: productAccessFilter(session.userId),
},
select: {
id: true,
content_md: true,
status: true,
updated_at: true,
product: { select: { enabled_doc_folders: true } },
},
})
if (!doc) notFound()
const isFolderEnabled = doc.product.enabled_doc_folders.includes(folderDb)
const isDemo = session.isDemo ?? false
const isEditMode = edit === '1'
const parsed = parseProductDocMd(doc.content_md)
const label = FOLDER_LABELS[folderDb]
return (
<div className="p-6 max-w-4xl mx-auto space-y-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link
href={`/products/${id}/docs`}
className="hover:text-foreground"
>
Documentatie
</Link>
<span>/</span>
<Link href={docsUrl} className="hover:text-foreground">
{label.title}
</Link>
<span>/</span>
<span className="text-foreground font-mono">{slug}</span>
</div>
<div className="flex items-center justify-between gap-3">
<Link
href={docsUrl}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3" />
Terug naar {label.title}
</Link>
{!isEditMode && (
<div className="flex items-center gap-2">
{isFolderEnabled && !isDemo && (
<Button
variant="outline"
size="sm"
render={<Link href={`${docUrl}?edit=1`} />}
>
<Pencil className="size-3.5 mr-1" />
Bewerken
</Button>
)}
<DeleteProductDocButton
docId={doc.id}
docTitle={parsed.ok ? parsed.frontmatter.title : slug}
redirectHref={docsUrl}
isDemo={isDemo}
/>
</div>
)}
</div>
{isEditMode ? (
<ProductDocEditor
docId={doc.id}
initialValue={doc.content_md}
cancelHref={docUrl}
/>
) : parsed.ok ? (
<ProductDocViewer
title={parsed.frontmatter.title}
status={parsed.frontmatter.status}
body={parsed.body}
audience={parsed.frontmatter.audience}
applies_to={parsed.frontmatter.applies_to}
lastUpdated={parsed.frontmatter.last_updated}
/>
) : (
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-4 text-sm">
<p className="font-medium text-status-blocked mb-2">
Frontmatter is niet parseerbaar
</p>
<ul className="text-xs space-y-1 text-status-blocked">
{parsed.errors.map((err, i) => (
<li key={i}>
{err.line ? `Regel ${err.line}: ` : ''}
{err.message}
</li>
))}
</ul>
<p className="text-xs text-muted-foreground mt-3">
Klik &lsquo;Bewerken&rsquo; om de fout te herstellen, of bekijk
de raw inhoud hieronder.
</p>
<pre className="mt-3 rounded bg-surface-container p-3 overflow-x-auto text-xs">
{doc.content_md}
</pre>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,104 @@
'use client'
// Confirm-dialog + delete-action voor een Product Doc. DemoTooltip-wrapped
// (laag 3 van de drie-laagse demo-policy). Na succesvolle delete: redirect
// naar de folder-page.
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { debugProps } from '@/lib/debug'
import { deleteProductDocAction } from '@/actions/product-docs'
interface Props {
docId: string
docTitle: string
/** URL waar we naar redirecten na succes. */
redirectHref: string
isDemo: boolean
}
export function DeleteProductDocButton({
docId,
docTitle,
redirectHref,
isDemo,
}: Props) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [submitting, startSubmit] = useTransition()
function confirmDelete() {
startSubmit(async () => {
const r = await deleteProductDocAction(docId)
if ('error' in r) {
toast.error(r.error)
return
}
toast.success('Doc verwijderd')
setOpen(false)
router.push(redirectHref)
router.refresh()
})
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<DemoTooltip show={isDemo}>
<AlertDialogTrigger
render={
<Button
variant="outline"
size="sm"
disabled={isDemo}
{...debugProps(
'delete-product-doc-button',
'DeleteProductDocButton',
'components/product-docs/delete-product-doc-button.tsx',
)}
>
<Trash2 className="size-3.5 mr-1" />
Verwijderen
</Button>
}
/>
</DemoTooltip>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Doc verwijderen?</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{docTitle}&rdquo; wordt permanent verwijderd. Een audit-log-rij
blijft bewaard, maar de inhoud is daarna niet meer leesbaar.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}>Annuleer</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={submitting}
variant="destructive"
data-debug-id="delete-product-doc-button__confirm"
>
Ja, verwijder
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -0,0 +1,44 @@
'use client'
// Editor-wrapper voor Product Docs. Dunne wrapper rond MarkdownDocEditor
// (shared) die de product-doc-specifieke action + validator injecteert.
// Onderdeel van de viewer-page (T-1069) — wordt getoond wanneer `?edit=1`
// in de searchParams staat.
import { useRouter } from 'next/navigation'
import { MarkdownDocEditor } from '@/components/shared/markdown-doc-editor'
import { parseProductDocMd } from '@/lib/product-doc-parser'
import { updateProductDocAction } from '@/actions/product-docs'
interface Props {
docId: string
initialValue: string
/** URL waar de gebruiker naar terug navigeert na annuleren of opslaan. */
cancelHref: string
}
function frontmatterValidator(value: string) {
const r = parseProductDocMd(value)
return r.ok ? [] : r.errors
}
export function ProductDocEditor({ docId, initialValue, cancelHref }: Props) {
const router = useRouter()
return (
<MarkdownDocEditor
storageKey={`product-doc-${docId}`}
initialValue={initialValue}
validate={frontmatterValidator}
onSave={(value) => updateProductDocAction(docId, value)}
onSaved={() => router.refresh()}
onCancel={() => router.push(cancelHref)}
placeholder={`---\ntitle: "..."\nstatus: draft\n---\n\n# Inhoud\n`}
validationErrorsHeader="YAML-frontmatter fouten"
debugId="product-doc-editor"
debugComponentName="ProductDocEditor"
debugFile="components/product-docs/product-doc-editor.tsx"
/>
)
}

View file

@ -0,0 +1,78 @@
// Doc-viewer voor Product Docs. Server-component. Rendert een
// frontmatter-kop (title + status-badge + meta) gevolgd door de markdown-
// body via components/markdown.tsx (XSS-safe; iframe/script disabled).
//
// Frontmatter wordt door de pagina geparseerd en als losse props
// doorgegeven — voorkomt dat de viewer parsing-fouten moet afhandelen.
import { debugProps } from '@/lib/debug'
import { Markdown } from '@/components/markdown'
import { ProductDocStatusBadge } from './product-doc-status-badge'
interface Props {
title: string
status: string
body: string
audience?: string | string[] | undefined
applies_to?: string | string[] | undefined
lastUpdated?: string | undefined
}
function renderList(value: string | string[] | undefined): string | null {
if (!value) return null
return Array.isArray(value) ? value.join(', ') : value
}
export function ProductDocViewer({
title,
status,
body,
audience,
applies_to,
lastUpdated,
}: Props) {
const audienceLabel = renderList(audience)
const appliesToLabel = renderList(applies_to)
return (
<article
className="space-y-4"
{...debugProps(
'product-doc-viewer',
'ProductDocViewer',
'components/product-docs/product-doc-viewer.tsx',
)}
>
<header className="space-y-2 pb-3 border-b border-border">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold">{title}</h1>
<ProductDocStatusBadge status={status} />
</div>
{(audienceLabel || appliesToLabel || lastUpdated) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{audienceLabel && (
<span>
<span className="uppercase tracking-wide">Audience:</span>{' '}
{audienceLabel}
</span>
)}
{appliesToLabel && (
<span>
<span className="uppercase tracking-wide">Applies to:</span>{' '}
{appliesToLabel}
</span>
)}
{lastUpdated && (
<span>
<span className="uppercase tracking-wide">Bijgewerkt:</span>{' '}
{lastUpdated}
</span>
)}
</div>
)}
</header>
<Markdown>{body}</Markdown>
</article>
)
}