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