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:
parent
3c8699f8e7
commit
bb4a71eafa
4 changed files with 381 additions and 0 deletions
155
app/(app)/products/[id]/docs/[folder]/[slug]/page.tsx
Normal file
155
app/(app)/products/[id]/docs/[folder]/[slug]/page.tsx
Normal 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 ‘Bewerken’ 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
components/product-docs/delete-product-doc-button.tsx
Normal file
104
components/product-docs/delete-product-doc-button.tsx
Normal 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>
|
||||||
|
“{docTitle}” 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
components/product-docs/product-doc-editor.tsx
Normal file
44
components/product-docs/product-doc-editor.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
components/product-docs/product-doc-viewer.tsx
Normal file
78
components/product-docs/product-doc-viewer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue