feat(navigation): add Blog link to Dutch navigation

- Introduced a new link for "Blog" in the navigation JSON file, enhancing user access to blog content.
This commit is contained in:
colinislit
2026-01-01 09:39:30 +01:00
parent c1dc92035b
commit e7294e712f
10 changed files with 1385 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
/**
* Blog Overview Page
*
* Landing page with series cards and recent posts
*/
import Link from 'next/link'
import { getAllPosts, getSeriesWithCounts, type BlogSeries } from '@/lib/mdx/blog'
export const metadata = {
title: 'Blog - AI Speedrun',
description: 'Artikelen over AI-gestuurde software ontwikkeling, healthcare IT en het bouwen van een EPD.',
}
export default async function BlogPage() {
const series = await getSeriesWithCounts()
const posts = await getAllPosts()
return (
<div className="min-h-screen bg-slate-50 pb-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
{/* Header */}
<div className="mb-8 md:mb-12 pt-20 md:pt-24">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 mb-3 md:mb-4">
Blog
</h1>
<p className="text-lg md:text-xl text-slate-600 max-w-3xl leading-relaxed">
Artikelen over AI-gestuurde ontwikkeling en de reis van het bouwen van een EPD.
</p>
</div>
{/* Series Section */}
{series.length > 0 && (
<section className="mb-12">
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
Series
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{series.map((serie) => (
<SeriesCard key={serie.id} series={serie} />
))}
</div>
</section>
)}
{/* Recent Posts Section */}
<section>
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
Recente Posts
</h2>
{posts.length === 0 ? (
<p className="text-slate-500 text-lg text-center py-12">
Nog geen blogposts gepubliceerd.
</p>
) : (
<div className="space-y-4">
{posts.map((post) => (
<Link
key={`${post.seriesId}-${post.slug}`}
href={`/blog/serie/${post.seriesId}/${post.slug}`}
className="block bg-white rounded-lg border border-slate-200 p-5 md:p-6 hover:border-teal-300 hover:shadow-md transition-all active:scale-[0.99]"
>
<div className="flex flex-wrap items-center gap-2 mb-2 text-sm text-slate-500">
<span className="text-xs font-medium uppercase tracking-wide">
{post.seriesId.replace('-', ' ')}
</span>
<span></span>
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span></span>
<span>{post.readingTime} min leestijd</span>
</div>
<h3 className="text-xl md:text-2xl font-bold text-slate-900 mb-2 leading-tight">
{post.frontmatter.title}
</h3>
<p className="text-slate-600 leading-relaxed line-clamp-2">
{post.frontmatter.description}
</p>
{post.frontmatter.tags && post.frontmatter.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{post.frontmatter.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-slate-100 text-slate-600 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
</Link>
))}
</div>
)}
</section>
</div>
</div>
)
}
function SeriesCard({ series }: { series: BlogSeries & { postCount: number } }) {
const colorStyles = {
teal: {
bg: 'bg-teal-50',
border: 'border-teal-200 hover:border-teal-400',
badge: 'bg-teal-100 text-teal-700',
dot: 'bg-teal-500',
},
amber: {
bg: 'bg-amber-50',
border: 'border-amber-200 hover:border-amber-400',
badge: 'bg-amber-100 text-amber-700',
dot: 'bg-amber-500',
},
slate: {
bg: 'bg-slate-50',
border: 'border-slate-200 hover:border-slate-400',
badge: 'bg-slate-100 text-slate-700',
dot: 'bg-slate-500',
},
}
const styles = colorStyles[series.color] || colorStyles.slate
const statusLabels = {
completed: 'Voltooid',
active: 'Actief',
planned: 'Gepland',
}
return (
<Link
href={`/blog/serie/${series.id}`}
className={`block rounded-lg border-2 p-5 transition-all hover:shadow-md active:scale-[0.98] ${styles.bg} ${styles.border}`}
>
<div className="flex items-center gap-2 mb-2">
<div className={`w-2 h-2 rounded-full ${styles.dot}`} />
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${styles.badge}`}>
{statusLabels[series.status]}
</span>
</div>
<h3 className="text-lg font-bold text-slate-900 mb-1">{series.title}</h3>
<p className="text-sm text-slate-600 mb-3 line-clamp-2">{series.description}</p>
<div className="text-xs text-slate-500">
{series.postCount} {series.postCount === 1 ? 'deel' : 'delen'}
</div>
</Link>
)
}

View File

@@ -0,0 +1,214 @@
/**
* Individual Blog Post Page
*
* Renders MDX content with series navigation
*/
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { getPost, getSeries, getPostsBySeries, getSeriesNavigation, getAllPosts } from '@/lib/mdx/blog'
import { mdxComponents } from '../../../../documentatie/components/mdx-components'
interface PostPageProps {
params: Promise<{ serieId: string; slug: string }>
}
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({
serieId: post.seriesId,
slug: post.slug,
}))
}
export async function generateMetadata({ params }: PostPageProps) {
const { serieId, slug } = await params
const post = await getPost(serieId, slug)
const series = await getSeries(serieId)
if (!post || !series) {
return { title: 'Post niet gevonden' }
}
return {
title: `${post.frontmatter.title} - ${series.title}`,
description: post.frontmatter.description,
openGraph: {
title: post.frontmatter.title,
description: post.frontmatter.description,
type: 'article',
publishedTime: post.frontmatter.date,
},
}
}
export default async function PostPage({ params }: PostPageProps) {
const { serieId, slug } = await params
const post = await getPost(serieId, slug)
const series = await getSeries(serieId)
if (!post || !series) {
notFound()
}
const navigation = await getSeriesNavigation(serieId, slug)
const { frontmatter, content, readingTime } = post
const colorStyles = {
teal: {
badge: 'bg-teal-100 text-teal-700 border-teal-200',
link: 'text-teal-600 hover:text-teal-700',
},
amber: {
badge: 'bg-amber-100 text-amber-700 border-amber-200',
link: 'text-amber-600 hover:text-amber-700',
},
slate: {
badge: 'bg-slate-100 text-slate-700 border-slate-200',
link: 'text-slate-600 hover:text-slate-700',
},
}
const styles = colorStyles[series.color] || colorStyles.slate
return (
<div className="min-h-screen bg-white pb-16">
<article className="max-w-3xl mx-auto px-4 md:px-8">
{/* Back link */}
<div className="pt-20 md:pt-24 mb-6">
<Link
href={`/blog/serie/${serieId}`}
className="inline-flex items-center gap-2 text-slate-600 hover:text-teal-600 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Terug naar {series.title}</span>
</Link>
</div>
{/* Header */}
<header className="mb-8 pb-8 border-b border-slate-200">
{/* Series badge */}
<div className="flex flex-wrap items-center gap-3 mb-4">
<Link
href={`/blog/serie/${serieId}`}
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border transition-colors hover:opacity-80 ${styles.badge}`}
>
{series.title}
</Link>
<span className="text-sm text-slate-500">
Deel {navigation.current} van {navigation.total}
</span>
</div>
{/* Title */}
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 mb-4 leading-tight">
{frontmatter.title}
</h1>
{/* Description */}
<p className="text-xl text-slate-600 leading-relaxed mb-4">
{frontmatter.description}
</p>
{/* Meta */}
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-500">
<time dateTime={frontmatter.date}>
{new Date(frontmatter.date).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span></span>
<span>{readingTime} min leestijd</span>
</div>
{/* Tags */}
{frontmatter.tags && frontmatter.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{frontmatter.tags.map((tag) => (
<span
key={tag}
className="px-2.5 py-1 bg-slate-100 text-slate-600 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
</header>
{/* MDX Content */}
<div className="prose prose-slate max-w-none">
<MDXRemote source={content} components={mdxComponents} />
</div>
{/* Series Navigation */}
<nav className="mt-12 pt-8 border-t border-slate-200">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
{series.title} Serie
</div>
<div className="grid grid-cols-2 gap-4">
{/* Previous */}
<div>
{navigation.previous ? (
<Link
href={`/blog/serie/${serieId}/${navigation.previous.slug}`}
className="block p-4 rounded-lg border border-slate-200 hover:border-teal-300 hover:shadow-sm transition-all group"
>
<div className="flex items-center gap-2 text-sm text-slate-500 mb-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Vorige</span>
</div>
<div className="font-medium text-slate-900 group-hover:text-teal-600 transition-colors line-clamp-1">
{navigation.previous.title}
</div>
</Link>
) : (
<div />
)}
</div>
{/* Next */}
<div>
{navigation.next ? (
<Link
href={`/blog/serie/${serieId}/${navigation.next.slug}`}
className="block p-4 rounded-lg border border-slate-200 hover:border-teal-300 hover:shadow-sm transition-all group text-right"
>
<div className="flex items-center justify-end gap-2 text-sm text-slate-500 mb-1">
<span>Volgende</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
<div className="font-medium text-slate-900 group-hover:text-teal-600 transition-colors line-clamp-1">
{navigation.next.title}
</div>
</Link>
) : (
<div />
)}
</div>
</div>
</nav>
{/* Back to overview */}
<footer className="mt-8 text-center">
<Link
href="/blog"
className="text-sm text-slate-600 hover:text-teal-600 transition-colors"
>
Terug naar blog overzicht
</Link>
</footer>
</article>
</div>
)
}

View File

@@ -0,0 +1,160 @@
/**
* Series Overview Page
*
* Shows all posts in a specific series
*/
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getSeries, getPostsBySeries, getAllSeries } from '@/lib/mdx/blog'
interface SeriesPageProps {
params: Promise<{ serieId: string }>
}
export async function generateStaticParams() {
const series = await getAllSeries()
return series.map((serie) => ({ serieId: serie.id }))
}
export async function generateMetadata({ params }: SeriesPageProps) {
const { serieId } = await params
const series = await getSeries(serieId)
if (!series) {
return { title: 'Serie niet gevonden' }
}
return {
title: `${series.title} - Blog`,
description: series.description,
}
}
export default async function SeriesPage({ params }: SeriesPageProps) {
const { serieId } = await params
const series = await getSeries(serieId)
if (!series) {
notFound()
}
const posts = await getPostsBySeries(serieId)
const colorStyles = {
teal: {
bg: 'bg-teal-50',
badge: 'bg-teal-100 text-teal-700 border-teal-200',
dot: 'bg-teal-500',
number: 'bg-teal-600 text-white',
},
amber: {
bg: 'bg-amber-50',
badge: 'bg-amber-100 text-amber-700 border-amber-200',
dot: 'bg-amber-500',
number: 'bg-amber-600 text-white',
},
slate: {
bg: 'bg-slate-50',
badge: 'bg-slate-100 text-slate-700 border-slate-200',
dot: 'bg-slate-500',
number: 'bg-slate-600 text-white',
},
}
const styles = colorStyles[series.color] || colorStyles.slate
const statusLabels = {
completed: 'Voltooid',
active: 'Actief',
planned: 'Gepland',
}
return (
<div className="min-h-screen bg-slate-50 pb-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 md:px-8">
{/* Back link */}
<div className="pt-20 md:pt-24 mb-6">
<Link
href="/blog"
className="inline-flex items-center gap-2 text-slate-600 hover:text-teal-600 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Terug naar blog</span>
</Link>
</div>
{/* Series Header */}
<header className={`rounded-xl p-6 md:p-8 mb-8 ${styles.bg}`}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${styles.dot}`} />
<span className={`text-xs font-medium px-2.5 py-1 rounded-full border ${styles.badge}`}>
{statusLabels[series.status]}
</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-3">
{series.title}
</h1>
<p className="text-lg text-slate-600 leading-relaxed">
{series.description}
</p>
<div className="mt-4 text-sm text-slate-500">
{posts.length} {posts.length === 1 ? 'deel' : 'delen'}
</div>
</header>
{/* Posts List */}
{posts.length === 0 ? (
<p className="text-slate-500 text-center py-12">
Nog geen posts in deze serie.
</p>
) : (
<div className="space-y-3">
{posts.map((post, index) => (
<Link
key={post.slug}
href={`/blog/serie/${serieId}/${post.slug}`}
className="flex items-start gap-4 bg-white rounded-lg border border-slate-200 p-4 md:p-5 hover:border-teal-300 hover:shadow-md transition-all active:scale-[0.99]"
>
<div
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${styles.number}`}
>
{index + 1}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-slate-900 mb-1 leading-tight">
{post.frontmatter.title}
</h2>
<p className="text-sm text-slate-600 mb-2 line-clamp-2">
{post.frontmatter.description}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
<span></span>
<span>{post.readingTime} min</span>
</div>
</div>
<svg
className="w-5 h-5 text-slate-400 flex-shrink-0 mt-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
{
"series": [
{
"id": "ai-speedrun",
"title": "AI Speedrun",
"description": "4 weken, €200, 1 EPD. De reis van idee naar werkend prototype.",
"color": "teal",
"order": 1,
"status": "completed"
},
{
"id": "cortex-intent",
"title": "Intent Driven Cortex",
"description": "De volgende stap: een AI-copilot die begrijpt wat je wilt.",
"color": "amber",
"order": 2,
"status": "active"
}
]
}

View File

@@ -0,0 +1,18 @@
---
title: "Week 1: De Kickoff"
description: "Hoe bouw je een EPD in 4 weken? Het begin van de AI Speedrun reis."
date: "2024-11-15"
published: true
seriesOrder: 1
tags:
- AI
- Development
- EPD
---
## De Start
Placeholder voor je eerste AI Speedrun blogpost.
Vervang deze content met je LinkedIn post.

View File

@@ -0,0 +1,18 @@
---
title: "Introductie: Intent Driven Cortex"
description: "De volgende evolutie van het EPD: een AI-copilot die begrijpt wat je wilt bereiken."
date: "2025-01-01"
published: true
seriesOrder: 1
tags:
- AI
- Cortex
- Intent
---
## Een Nieuwe Aanpak
Placeholder voor je eerste Cortex Intent blogpost.
Vervang deze content met je nieuwe serie over het intent-driven EPD.

View File

@@ -1,6 +1,10 @@
{ {
"logo": "AI SPEEDRUN", "logo": "AI SPEEDRUN",
"links": [ "links": [
{
"label": "Blog",
"href": "/blog"
},
{ {
"label": "Documentatie", "label": "Documentatie",
"href": "/documentatie" "href": "/documentatie"

View File

@@ -0,0 +1,232 @@
# Blog Setup Gids (ikbenlit.nl)
Deze gids beschrijft hoe de blog-sectie van `ikbenlit.nl` is opgezet, zodat je dit kunt kopiëren naar een ander Next.js project.
## 1. Architectuur Overzicht
De blog gebruikt een **Filesystem-based MDX** benadering:
- **Content**: Opgeslagen als `.mdx` bestanden in de folder `content/blog`.
- **Data Fetching**: Server-side functies lezen de bestanden en parsen de YAML frontmatter.
- **Rendering**: Gebruikt `next-mdx-remote` voor het renderen van MDX content met custom React componenten.
- **Styling**: Gebruikt Tailwind CSS met de `@tailwindcss/typography` plugin.
## 2. Dependencies
Installeer de volgende pakketten in je nieuwe project:
```bash
pnpm add next-mdx-remote gray-matter rehype-slug rehype-autolink-headings rehype-pretty-code remark-gfm
pnpm add -D @tailwindcss/typography @types/mdx
```
## 3. File Structure
Zorg voor de volgende directory structuur:
```text
.
├── app/
│ └── blog/
│ ├── page.tsx # Blog overzichtspagina
│ └── [slug]/
│ └── page.tsx # Individuele blogpost pagina
├── components/
│ └── blog/
│ ├── blog-list.tsx # Lijst weergave component
│ └── ... # Overige UI componenten
├── content/
│ └── blog/
│ └── mijn-post.mdx # Je blog artikelen
├── lib/
│ ├── blog.ts # Data fetching logica
│ ├── mdx-components.tsx # MDX naar React mapping
│ └── types/
│ └── blog.ts # TypeScript interfaces
└── tailwind.config.js # Tailwind configuratie (typography)
```
## 4. Kern Implementatie
### A. Types (`lib/types/blog.ts`)
Definieer de structuur van een blogpost.
```typescript
export interface Post {
slug: string
title: string
description: string
date: string
published: boolean
image?: string
tags?: string[]
content: string
url: string
readingTime: number
}
export interface PostFrontmatter {
title: string
description: string
date: string
published?: boolean
image?: string
tags?: string[]
}
```
### B. Data Fetching (`lib/blog.ts`)
Deze functies lezen de bestanden van je harde schijf.
```typescript
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import type { Post, PostFrontmatter } from './types/blog'
const POSTS_DIRECTORY = path.join(process.cwd(), 'content/blog')
export async function getAllPosts(): Promise<Post[]> {
const files = fs.readdirSync(POSTS_DIRECTORY).filter(f => f.endsWith('.mdx'))
const posts = files.map((filename) => {
const slug = filename.replace('.mdx', '')
const fullPath = path.join(POSTS_DIRECTORY, filename)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
const frontmatter = data as PostFrontmatter
return {
slug,
...frontmatter,
content,
url: `/blog/${slug}`,
readingTime: Math.ceil(content.split(/\s+/).length / 200),
} as Post
})
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}
export async function getPostBySlug(slug: string): Promise<Post | null> {
const fullPath = path.join(POSTS_DIRECTORY, `${slug}.mdx`)
if (!fs.existsSync(fullPath)) return null
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
const frontmatter = data as PostFrontmatter
return {
slug,
...frontmatter,
content,
url: `/blog/${slug}`,
readingTime: Math.ceil(content.split(/\s+/).length / 200),
}
}
```
### C. MDX Componenten (`lib/mdx-components.tsx`)
Map HTML elementen naar je eigen gestylede componenten.
```tsx
import type { MDXComponents } from 'mdx/types'
import Link from 'next/link'
import Image from 'next/image'
export const mdxComponents: MDXComponents = {
h1: (props) => <h1 className="text-4xl font-bold mt-8 mb-4" {...props} />,
h2: (props) => <h2 className="text-3xl font-bold mt-12 mb-4" {...props} />,
p: (props) => <p className="leading-relaxed my-4" {...props} />,
img: (props) => <Image width={800} height={400} className="rounded-lg my-8" {...props} alt={props.alt || ""} />,
a: ({ href, ...props }) => <Link href={href || '#'} className="text-blue underline" {...props} />,
}
```
## 5. Pagina Implementatie
### Blog Overzicht (`app/blog/page.tsx`)
```tsx
import { getAllPosts } from '@/lib/blog'
import Link from 'next/link'
export default async function BlogPage() {
const posts = await getAllPosts()
return (
<main className="container mx-auto py-12">
<h1 className="text-5xl font-bold mb-12">Blog</h1>
<div className="grid gap-8">
{posts.map(post => (
<Link key={post.slug} href={post.url} className="border p-6 rounded-lg hover:shadow-lg transition">
<h2 className="text-2xl font-bold">{post.title}</h2>
<p className="text-gray-600">{post.description}</p>
</Link>
))}
</div>
</main>
)
}
```
### Blog Post Pagina (`app/blog/[slug]/page.tsx`)
```tsx
import { getPostBySlug, getAllPosts } from '@/lib/blog'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { mdxComponents } from '@/lib/mdx-components'
import { notFound } from 'next/navigation'
import remarkGfm from 'remark-gfm'
import rehypeSlug from 'rehype-slug'
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
if (!post) notFound()
return (
<article className="container mx-auto py-12 max-w-4xl">
<header className="mb-12">
<h1 className="text-5xl font-bold">{post.title}</h1>
<p className="text-xl text-gray-600 mt-4">{post.description}</p>
</header>
<div className="prose prose-lg max-w-none">
<MDXRemote
source={post.content}
components={mdxComponents}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeSlug],
}
}}
/>
</div>
</article>
)
}
```
## 6. Tailwind Configuratie (`tailwind.config.js`)
Vergeet niet de typography plugin toe te voegen:
```javascript
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./lib/**/*.{js,ts,jsx,tsx}',
],
plugins: [
require('@tailwindcss/typography'),
],
}
```
## Tips voor Kopieren
1. **MDX Styling**: De `prose` class in de pagina zorgt for de basis styling. Pas de `mdxComponents` aan voor specifieke elementen.
2. **Afbeeldingen**: Zorg dat je een `public/images` folder hebt als je lokale afbeeldingen gebruikt in je MDX.
3. **Frontmatter**: Elk `.mdx` bestand moet beginnen met:
```markdown
---
title: Mijn Eerste Post
description: Een korte samenvatting
date: '2025-01-01'
published: true
---
```

View File

@@ -0,0 +1,306 @@
# 📱 LinkedIn Series: Cortex V2 - Het Intelligente EPD
**Project:** AI Speedrun → Cortex V2
**Totaal:** 11 posts + 4 optioneel
**Doelgroep:** Zorgprofessionals, ICT-managers, directie, psychiaters (70%) + Techies, leveranciers (30%)
**Toon:** Toegankelijk, concreet, herkenbaar - techniek waar nodig (duidelijk gelabeld)
---
## Fase 0: De Brug (1 post)
### Post 0: "We bewezen dat het kan. Nu de echte vraag."
**Doel:** Transitie van Speedrun naar nieuwe serie
**Doelgroep:** Iedereen die Speedrun volgde + nieuwe lezers
**Inhoud:**
- Korte recap: 4 weken, 118k regels code, werkend EPD
- Maar: tijdens het bouwen realiseerde ik me iets
- Ik bouwde nog steeds een *traditioneel* EPD
- De vraag is niet "kan AI software bouwen?" maar "wat moeten we eigenlijk bouwen?"
- Teaser: intent-driven EPD concept
- Aankondiging: komende weken deel ik de volgende stap
**Hook:** "118.000 regels code. En toch de verkeerde vraag beantwoord."
---
## Fase 1: De Visie (3 posts)
### Post 1: "Het probleem met EPD's is niet de techniek"
**Doel:** Herkenning creëren, probleem framen
**Doelgroep:** Verpleegkundigen, behandelaren, managers
**Inhoud:**
- Scenario 1: Verpleegkundige loopt ronde, wond verzorgd → 3 losse acties nodig
- Scenario 2: Psychiater begint dienst → 15 dossiers openen om te checken wat er vannacht gebeurde
- Scenario 3: Psycholoog dicteert notitie over suïcidaliteit → systeem slaat alleen op, denkt niet mee
- Kern: EPD's zijn gebouwd rond data-opslag, niet rond zorgprocessen
- Behandelaren passen zich aan het systeem aan, niet andersom
**Hook:** "Je wond is verzorgd. Nu nog 3 schermen, 2 menu's en een bevestigingsdialoog."
---
### Post 2: "Wat als je EPD gewoon snapt wat je bedoelt?"
**Doel:** Intent-driven concept introduceren
**Doelgroep:** Breed - iedereen die met EPD werkt
**Inhoud:**
- Voorbeeld: "Zeg Jan af en maak notitie dat hij griep heeft"
- Wat er nu gebeurt: systeem snapt het niet, of je moet 2 losse commando's geven
- Wat er zou moeten gebeuren: systeem herkent 2 acties, bevestigt, voert uit
- Dit is geen spraakherkenning - dit is *begrip*
- Technisch (kort): AI die intentie herkent, niet alleen woorden
- Vergelijking: het verschil tussen een rekenmachine en een collega
**Hook:** "Eén zin. Twee acties. Nul extra kliks."
---
### Post 3: "Een EPD dat meedenkt"
**Doel:** Proactiviteit concept introduceren
**Doelgroep:** Behandelaren, verpleegkundigen, regiebehandelaren
**Inhoud:**
- Voorbeeld 1: Wondzorg genoteerd → "Wondcontrole inplannen over 3 dagen?"
- Voorbeeld 2: Nieuwe medicatie gestart → "Evaluatie inplannen over 2 weken?"
- Voorbeeld 3: Signalen van crisis → "Signaleringsplan bekijken?"
- Cruciale nuance: suggesties, geen verplichtingen
- Het systeem leert wat jij routinematig doet
- Grens: behulpzaam vs. Clippy ("Ik zie dat je een brief schrijft...")
**Hook:** "Na 'wond verzorgd' zegt je EPD nu: niets. Wat als het meedacht?"
---
## Fase 2: De Bouw (5 posts)
### Post 4: "Snelheid waar het kan, intelligentie waar het moet"
**Doel:** Architectuur uitleggen (toegankelijk)
**Doelgroep:** ICT-managers, tech-geïnteresseerde behandelaren
**Label:** 🔧 *Iets technischer - maar relevant voor iedereen die snapt waarom systemen soms traag zijn*
**Inhoud:**
- Probleem: AI is slim maar niet instant
- Oplossing: twee sporen
- Simpele commando's ("agenda vandaag") → direct, lokaal, <20ms
- Complexe vragen ("zeg af en noteer") → AI denkt mee, ~400ms
- Het systeem beslist zelf welk spoor
- Vergelijking: reflexen vs. nadenken (je trekt je hand terug van vuur zonder na te denken)
- Resultaat: snelheid waar het kan, intelligentie waar het moet
**Hook:** "Soms moet je nadenken. Soms moet je gewoon snel zijn. Een goed systeem weet wanneer."
---
### Post 5: "Waarom 'ik snap het niet' geen optie is"
**Doel:** Betrouwbaarheid en vertrouwen
**Doelgroep:** Iedereen die ooit "commando niet herkend" zag
**Inhoud:**
- Frustratie: 2x "ik begrijp het niet" en je typt het wel met de hand
- Principe: het systeem doet *altijd* een poging
- Bij twijfel: vraag verduidelijking ("Bedoel je een notitie of een afspraak?")
- Bij meerdere opties: bied ze aan
- Nooit opgeven, altijd proberen te helpen
- Dit is hoe vertrouwen ontstaat: betrouwbaarheid
**Hook:** "'Commando niet herkend.' Drie woorden die ervoor zorgen dat niemand spraak meer gebruikt."
---
### Post 6: "Context is alles"
**Doel:** Context-awareness uitleggen
**Doelgroep:** Behandelaren, verpleegkundigen
**Inhoud:**
- Voorbeeld: "Maak notitie voor hem"
- Probleem: wie is "hem"?
- Oplossing: het systeem weet:
- Welke patiënt je nu bekijkt (Marie)
- Welk scherm je open hebt (dossier)
- Wat je net deed (rapportage invoeren)
- "Hem" → Marie (want die staat open)
- "Morgen" → datum van morgen (niet "het woord morgen")
- Resultaat: natuurlijk praten, geen commando-syntax
**Hook:** "'Maak notitie voor hem.' Jij weet wie je bedoelt. Eindelijk je EPD ook."
---
### Post 7: "Van idee naar werkend systeem (met AI)"
**Doel:** Bouwproces delen
**Doelgroep:** ICT-managers, directie, leveranciers
**Label:** 🔧 *Voor de techneuten en beslissers*
**Inhoud:**
- Hoe ik dit bouw:
1. Denken: wat is het doel? (PRD)
2. Ontwerpen: hoe ervaart de gebruiker dit? (FO)
3. Bouwen: AI als partner, niet als vervanger (TO)
- Tools: Claude, Cursor, Supabase
- AI rol: code genereren, reviewen, debuggen
- Mijn rol: richting bepalen, beslissingen nemen, kwaliteit bewaken
- Niet magie - wel een nieuwe manier van werken
**Hook:** "AI bouwt de code. Maar wie bepaalt wat er gebouwd wordt?"
---
### Post 8: "De meedenkende collega: waar ligt de grens?"
**Doel:** Ethische overwegingen bespreken
**Doelgroep:** Behandelaren, managers, beleidsmakers
**Inhoud:**
- Vragen die ik mezelf stel:
- Wanneer is een suggestie behulpzaam vs. opdringerig?
- Alert-moeheid: hoe voorkom je dat alles genegeerd wordt?
- Verantwoordelijkheid: wat als het systeem iets mist?
- Privacy: wat "weet" het systeem eigenlijk?
- Mijn aanpak:
- Start conservatief (alleen kritieke suggesties)
- Transparante redenering (waarom deze suggestie?)
- Gebruiker blijft in control
- Geen definitieve antwoorden - wel de juiste vragen
**Hook:** "Een systeem dat meedenkt. Klinkt goed. Maar wanneer wordt het bemoeizucht?"
---
## Fase 3: Demo & Reflectie (3 posts)
### Post 9: "Het werkt. Kijk maar."
**Doel:** Bewijs leveren
**Doelgroep:** Iedereen
**Inhoud:**
- Video/GIF van werkende demo
- Flow: gebruiker spreekt → systeem detecteert 2 acties → voert uit → bevestigt → suggereert vervolg
- Eerlijk over wat nog niet perfect is
- Uitnodiging: feedback welkom
**Hook:** "Genoeg gepraat. Hier is het bewijs."
**Bijlage:** Demo video (30-60 sec)
---
### Post 10: "Waarom duurt alles 18 maanden?"
**Doel:** Status quo uitdagen
**Doelgroep:** Managers, directie, leveranciers
**Inhoud:**
- Als dit solo te bouwen is, waarom duren features bij leveranciers jaren?
- Eerlijk: wat mist er nog?
- NEN7510 compliance
- Integraties met bestaande systemen
- Edge cases
- Support en onderhoud
- Maar: de kern werkt. Nu.
- Vraag: waarom experimenteren we niet meer?
- Geen leverancier-bashing - wel een uitnodiging tot dialoog
**Hook:** "'Staat op de roadmap.' De vier woorden die innovatie in de zorg vertragen."
---
### Post 11: "Wat ik leerde over de toekomst van EPD's"
**Doel:** Serie afsluiten, vooruitkijken
**Doelgroep:** Iedereen
**Inhoud:**
- Learnings:
- Intent-driven werkt
- Context is cruciaal
- Proactiviteit is krachtig maar delicaat
- Het denken is het probleem, niet de techniek
- Wat nodig is voor productie:
- Compliance
- Integraties
- Gebruikerstesten
- Grotere visie (teaser):
- Geen schermen meer
- Agents die het werk doen
- Focus op de patiënt, niet het systeem
- Call to action: samenwerken, testen, of zelf beginnen met experimenteren
**Hook:** "De toekomst van EPD's is niet een beter scherm. Het is geen scherm."
---
## Optionele Posts (4)
### Optioneel A: "De bug die me een avond kostte"
**Wanneer:** Als er een goede technische struggle-story is
**Doelgroep:** 🔧 Techies, builders
**Inhoud:**
- Specifieke bug of uitdaging
- Hoe AI hielp (of juist niet)
- Wat ik leerde
- Relatable voor iedereen die ooit code debugde
---
### Optioneel B: "Privacy en AI in de zorg"
**Wanneer:** Als het onderwerp opkomt in reacties
**Doelgroep:** Managers, directie, beleidsmakers
**Inhoud:**
- Wat "weet" een AI-systeem?
- Waar gaat data naartoe?
- Hoe bouw je privacy-by-design?
- Geen juridisch advies - wel transparantie
---
### Optioneel C: "Geen schermen, maar agents"
**Wanneer:** Als grotere visie resoneert
**Doelgroep:** Visionairs, early adopters
**Inhoud:**
- Ephemeral UI: interfaces die ontstaan wanneer nodig
- Agents die taken uitvoeren
- De behandelaar focust op de patiënt
- Sci-fi? Nee, technisch nu al mogelijk
---
### Optioneel D: "Wat kost dit eigenlijk?"
**Wanneer:** Als kosten-vraag vaak komt
**Doelgroep:** Managers, directie
**Inhoud:**
- Breakdown: €200 budget, waar ging het heen?
- AI-kosten per interactie
- Vergelijking met traditionele development
- ROI-denken: wat kost tijd van behandelaren?
---
## Planning & Ritme
| Week | Posts | Focus |
|------|-------|-------|
| 1 | Post 0, 1, 2 | De brug + probleem + oplossing |
| 2 | Post 3, 4, 5 | Proactiviteit + architectuur + betrouwbaarheid |
| 3 | Post 6, 7, 8 | Context + bouwproces + ethiek |
| 4 | Post 9, 10, 11 | Demo + uitdaging + conclusie |
**Frequentie:** 3x per week (ma-wo-vr of di-do-za)
**Optionele posts:** Inspelen op reacties en momentum
---
## Technische Referenties (voor eigen gebruik)
De volgende documenten bevatten de technische details:
- `architecture-cortex-v2.md` - Volledige architectuur
- `fo-cortex-intent-system-v2.md` - Functioneel ontwerp
- `to-cortex-v2.md` - Technisch ontwerp
- `prd-cortex-v2.md` - Product requirements
- `review-cortex-v2.md` - Review rapport
**Let op:** Technische details alleen gebruiken waar relevant voor de post. Vertaal altijd naar concrete voorbeelden die behandelaren herkennen.

258
lib/mdx/blog.ts Normal file
View File

@@ -0,0 +1,258 @@
/**
* Blog MDX Utilities
*
* Functions for loading and parsing blog MDX files with series support
* Based on lib/mdx/documentatie.ts
*/
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
const BLOG_DIR = path.join(process.cwd(), 'content/nl/blog')
// ============================================================================
// Types
// ============================================================================
export interface BlogSeries {
id: string
title: string
description: string
color: 'teal' | 'amber' | 'slate'
order: number
status: 'active' | 'completed' | 'planned'
}
export interface BlogFrontmatter {
title: string
description: string
date: string
published?: boolean
seriesOrder: number
tags?: string[]
}
export interface BlogPost {
slug: string
seriesId: string
frontmatter: BlogFrontmatter
content: string
readingTime: number
}
export interface SeriesNavigation {
previous: { slug: string; title: string } | null
next: { slug: string; title: string } | null
current: number
total: number
}
// ============================================================================
// Helper Functions
// ============================================================================
function calculateReadingTime(content: string): number {
const wordsPerMinute = 200
const wordCount = content.split(/\s+/).length
return Math.ceil(wordCount / wordsPerMinute)
}
// ============================================================================
// Series Functions
// ============================================================================
/**
* Get all series metadata
*/
export async function getAllSeries(): Promise<BlogSeries[]> {
try {
const seriesPath = path.join(BLOG_DIR, '_series.json')
if (!fs.existsSync(seriesPath)) {
console.warn('_series.json not found')
return []
}
const content = fs.readFileSync(seriesPath, 'utf-8')
const data = JSON.parse(content)
return (data.series || []).sort(
(a: BlogSeries, b: BlogSeries) => a.order - b.order
)
} catch (error) {
console.error('Error loading series:', error)
return []
}
}
/**
* Get a single series by ID
*/
export async function getSeries(seriesId: string): Promise<BlogSeries | null> {
const allSeries = await getAllSeries()
return allSeries.find((s) => s.id === seriesId) || null
}
// ============================================================================
// Post Functions
// ============================================================================
/**
* Get all blog posts from all series
*/
export async function getAllPosts(): Promise<BlogPost[]> {
if (!fs.existsSync(BLOG_DIR)) return []
const series = await getAllSeries()
const allPosts: BlogPost[] = []
for (const serie of series) {
const serieDir = path.join(BLOG_DIR, serie.id)
if (!fs.existsSync(serieDir)) continue
const files = fs.readdirSync(serieDir).filter((f) => f.endsWith('.mdx'))
for (const file of files) {
const slug = file.replace('.mdx', '')
const filePath = path.join(serieDir, file)
const fileContent = fs.readFileSync(filePath, 'utf-8')
const { data, content } = matter(fileContent)
const frontmatter = data as BlogFrontmatter
// Skip unpublished in production
if (
process.env.NODE_ENV !== 'development' &&
frontmatter.published === false
) {
continue
}
allPosts.push({
slug,
seriesId: serie.id,
frontmatter,
content,
readingTime: calculateReadingTime(content),
})
}
}
// Sort by date, newest first
return allPosts.sort(
(a, b) =>
new Date(b.frontmatter.date).getTime() -
new Date(a.frontmatter.date).getTime()
)
}
/**
* Get all posts for a specific series
*/
export async function getPostsBySeries(seriesId: string): Promise<BlogPost[]> {
const serieDir = path.join(BLOG_DIR, seriesId)
if (!fs.existsSync(serieDir)) return []
const files = fs.readdirSync(serieDir).filter((f) => f.endsWith('.mdx'))
const posts: BlogPost[] = []
for (const file of files) {
const slug = file.replace('.mdx', '')
const filePath = path.join(serieDir, file)
const fileContent = fs.readFileSync(filePath, 'utf-8')
const { data, content } = matter(fileContent)
const frontmatter = data as BlogFrontmatter
// Skip unpublished in production
if (
process.env.NODE_ENV !== 'development' &&
frontmatter.published === false
) {
continue
}
posts.push({
slug,
seriesId,
frontmatter,
content,
readingTime: calculateReadingTime(content),
})
}
// Sort by seriesOrder
return posts.sort(
(a, b) => a.frontmatter.seriesOrder - b.frontmatter.seriesOrder
)
}
/**
* Get a single post
*/
export async function getPost(
seriesId: string,
slug: string
): Promise<BlogPost | null> {
const filePath = path.join(BLOG_DIR, seriesId, `${slug}.mdx`)
if (!fs.existsSync(filePath)) return null
const fileContent = fs.readFileSync(filePath, 'utf-8')
const { data, content } = matter(fileContent)
const frontmatter = data as BlogFrontmatter
return {
slug,
seriesId,
frontmatter,
content,
readingTime: calculateReadingTime(content),
}
}
/**
* Get navigation for a post within its series
*/
export async function getSeriesNavigation(
seriesId: string,
slug: string
): Promise<SeriesNavigation> {
const posts = await getPostsBySeries(seriesId)
const currentIndex = posts.findIndex((p) => p.slug === slug)
return {
previous:
currentIndex > 0
? { slug: posts[currentIndex - 1].slug, title: posts[currentIndex - 1].frontmatter.title }
: null,
next:
currentIndex < posts.length - 1
? { slug: posts[currentIndex + 1].slug, title: posts[currentIndex + 1].frontmatter.title }
: null,
current: currentIndex + 1,
total: posts.length,
}
}
/**
* Get series with post counts
*/
export async function getSeriesWithCounts(): Promise<
(BlogSeries & { postCount: number })[]
> {
const series = await getAllSeries()
const result = []
for (const serie of series) {
const posts = await getPostsBySeries(serie.id)
result.push({
...serie,
postCount: posts.length,
})
}
return result
}