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:
154
app/(marketing)/blog/page.tsx
Normal file
154
app/(marketing)/blog/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
214
app/(marketing)/blog/serie/[serieId]/[slug]/page.tsx
Normal file
214
app/(marketing)/blog/serie/[serieId]/[slug]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
160
app/(marketing)/blog/serie/[serieId]/page.tsx
Normal file
160
app/(marketing)/blog/serie/[serieId]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
21
content/nl/blog/_series.json
Normal file
21
content/nl/blog/_series.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
18
content/nl/blog/ai-speedrun/week-1-kickoff.mdx
Normal file
18
content/nl/blog/ai-speedrun/week-1-kickoff.mdx
Normal 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.
|
||||||
|
|
||||||
18
content/nl/blog/cortex-intent/introductie.mdx
Normal file
18
content/nl/blog/cortex-intent/introductie.mdx
Normal 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.
|
||||||
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"logo": "AI SPEEDRUN",
|
"logo": "AI SPEEDRUN",
|
||||||
"links": [
|
"links": [
|
||||||
|
{
|
||||||
|
"label": "Blog",
|
||||||
|
"href": "/blog"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Documentatie",
|
"label": "Documentatie",
|
||||||
"href": "/documentatie"
|
"href": "/documentatie"
|
||||||
|
|||||||
232
docs/blogs/blog-setup-guide.md
Normal file
232
docs/blogs/blog-setup-guide.md
Normal 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
|
||||||
|
---
|
||||||
|
```
|
||||||
306
docs/blogs/linkedin-series-cortex-v2.md
Normal file
306
docs/blogs/linkedin-series-cortex-v2.md
Normal 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
258
lib/mdx/blog.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user