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",
|
||||
"links": [
|
||||
{
|
||||
"label": "Blog",
|
||||
"href": "/blog"
|
||||
},
|
||||
{
|
||||
"label": "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