feat: Worksheet pagina met workshop materiaal

- /worksheet route met downloads, start prompt en tips
- Custom directives: tip block, command block updates
- Superpowers zip download voor deelnemers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frank Meeuwsen 2026-04-02 15:25:37 +02:00
parent f6be63dfb1
commit 44f75914cc
8 changed files with 247 additions and 11 deletions

71
content/worksheet.md Normal file
View file

@ -0,0 +1,71 @@
---
title: Workshop Materiaal
subtitle: Alles wat je nodig hebt tijdens de Claude Code Workshop
---
## Download Superpowers
Superpowers is een plugin voor Claude Code die extra mogelijkheden toevoegt. Denk aan gestructureerd plannen, test-driven development en slimmer debuggen. Tijdens de workshop gebruiken we deze plugin.
:::tip
Download het zipbestand en installeer het in Claude Code. Ga naar Customize > Personal Plugin (+) > Create Plugin > Upload Plugin en zet daar de zipfile in (niet uitgepakt)
:::
[Download Superpowers (zip)](superpowers-main.zip)
## Start Prompt
Gebruik deze prompt als startpunt om te brainstormen met Claude Code over je project. Plak hem in je terminal en vervang `{HIER JE IDEE}` door jouw projectidee. Aan het einde kun je het plan nog aanpassen of extra's aan toevoegen.
:::command[Start prompt]
Ik wil {HIER JE IDEE}.
Stel me maximaal 5 vragen om mijn idee beter te begrijpen. Stel ze een voor een met AskUserQuestions, niet allemaal tegelijk.
Maak daarna een bouwplan (PRD) met:
- Wat het doet (in gewone taal)
- Hoe het eruitziet (beschrijf de interface)
- Zelfstandige bouwfasen (max 3-4), in volgorde van prioriteit. Fase 1 is het minimum dat waarde oplevert.
Elke fase levert iets werkends op.
Als ik het bouwplan goedkeur:
1. Sla het op als PRD.md in de projectmap
2. Maak een CLAUDE.md aan met een samenvatting van het project, de bouwfasen en hun status (allemaal "nog niet gestart")
3. Stop. Ga niet verder met bouwen tot ik het zeg.
Beperkingen:
- Alleen lokaal, geen hosting of externe diensten
- Gebruik HTML/CSS/JavaScript waar mogelijk (geen framework nodig voor eenvoudige projecten)
- Node.js als een server nodig is
- Python alleen als Node.js echt niet volstaat
:::
## Tips tijdens het bouwen
### Sessiemanagement
- **Start een nieuwe sessie per bouwfase** - niet alles in een lange sessie proberen
- Elke sessie: open de PRD, zeg "bouw fase X" en go.
- Korte sessies zijn efficienter dan lange (minder context = minder tokens = snellere antwoorden)
- **Kernboodschap:** je PRD is je anker. Bewaar die goed, dan kun je altijd opnieuw starten.
### Waarom niet een lange sessie?
- Pro-limiet bereik je sneller in een lange sessie (meer context = meer tokenverbruik)
- Vroege signalen dat je limiet nadert: tragere antwoorden, kortere code, stappen overslaan
- Als je de melding "usage limit reached" krijgt ben je te laat en zul je een paar uur moeten wachten
- Tenzij je een Extra Usage Wallet hebt ingesteld (Settings > Usage)
- **Preventie is beter:** plan je sessies per fase, dan is de kans kleiner dat je tegen limieten loopt
### Workflow
1. Brainstorm en PRD schrijven (sessie 1)
2. Nieuwe sessie in dezelfde werkmap: "bouw fase 1" (sessie 2)
3. Nieuwe sessie: "bouw fase 2" (sessie 3)
4. Herhaal tot prototype klaar is
### Agents (voor gevorderden / Max-gebruikers)
- Claude Code zet zelf al subagents in wanneer het nuttig is - daar hoef je niks voor te doen
- Expliciet aansturen kan: "Zet waar mogelijk agents in om parallelle taken tegelijk te doen"
- **Let op:** agents verbruiken meer tokens, niet minder - elke agent heeft zijn eigen context
- Voor Pro-gebruikers die al tegen limieten aanlopen versnelt dat het probleem
- **Advies:** alleen actief inzetten als je op Max zit of een API key gebruikt

BIN
public/superpowers-main.zip Normal file

Binary file not shown.

View file

@ -21,7 +21,7 @@ import { useKeyboardShortcuts } from './useKeyboardShortcuts';
* @param {string} props.content - Huidige Markdown content * @param {string} props.content - Huidige Markdown content
* @param {function} props.onContentChange - Callback bij wijzigingen * @param {function} props.onContentChange - Callback bij wijzigingen
*/ */
export default function MarkdownEditor({ content, onContentChange }) { export default function MarkdownEditor({ content, onContentChange, filename = 'installatie.md' }) {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@ -41,7 +41,7 @@ export default function MarkdownEditor({ content, onContentChange }) {
const response = await fetch('/__editor/save', { const response = await fetch('/__editor/save', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }), body: JSON.stringify({ content, filename }),
}); });
const result = await response.json(); const result = await response.json();
@ -88,7 +88,7 @@ export default function MarkdownEditor({ content, onContentChange }) {
<div className="flex items-center justify-between bg-warm-900 text-white px-4 py-2"> <div className="flex items-center justify-between bg-warm-900 text-white px-4 py-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="font-display font-bold">Markdown Editor</h2> <h2 className="font-display font-bold">Markdown Editor</h2>
<span className="text-warm-400 text-sm">installatie.md</span> <span className="text-warm-400 text-sm">{filename}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-warm-400 text-xs"> <span className="text-warm-400 text-xs">

View file

@ -101,6 +101,14 @@ function parseDirectives(text) {
continue; continue;
} }
// :::tip
if (line.trim() === ':::tip') {
const result = parseEnclosed(lines, i);
blocks.push({ type: 'tip', content: result.content });
i = result.nextIndex;
continue;
}
// :::steps // :::steps
if (line.trim() === ':::steps') { if (line.trim() === ':::steps') {
const result = parseEnclosed(lines, i); const result = parseEnclosed(lines, i);

View file

@ -7,7 +7,7 @@
* Afbeeldingpaden worden automatisch geprefixed met BASE_URL. * Afbeeldingpaden worden automatisch geprefixed met BASE_URL.
*/ */
import { useState } from 'react'; import { useState, useCallback } from 'react';
import { marked } from 'marked'; import { marked } from 'marked';
const BASE_URL = import.meta.env.BASE_URL; const BASE_URL = import.meta.env.BASE_URL;
@ -48,6 +48,8 @@ function RenderBlock({ block, context }) {
return <WarningBlock content={block.content} />; return <WarningBlock content={block.content} />;
case 'info': case 'info':
return <InfoBlock content={block.content} />; return <InfoBlock content={block.content} />;
case 'tip':
return <TipBlock content={block.content} />;
case 'steps': case 'steps':
return <StepsBlock block={block} context={context} />; return <StepsBlock block={block} context={context} />;
case 'checklist': case 'checklist':
@ -196,6 +198,21 @@ function InfoBlock({ content }) {
); );
} }
/**
* Tipblok (gele achtergrond met lampje).
*/
function TipBlock({ content }) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-6 flex gap-3">
<span className="text-xl leading-6 shrink-0">💡</span>
<div
className="prose-installatie prose-info"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
</div>
);
}
/** /**
* Genummerde stappen met coral cirkel-iconen. * Genummerde stappen met coral cirkel-iconen.
* Gebruikt pre-parsed steps uit de directive parser. * Gebruikt pre-parsed steps uit de directive parser.
@ -274,15 +291,46 @@ function ChecklistVerifyBlock({ content }) {
} }
/** /**
* Terminal commando blok (donkere achtergrond). * Terminal commando blok (donkere achtergrond) met kopieerknop.
*/ */
function CommandBlock({ command, content }) { function CommandBlock({ command, content }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [content]);
return ( return (
<div className="mb-3"> <div className="mb-6">
<div className="bg-warm-900 text-warm-100 rounded-lg px-4 py-2 font-mono text-sm"> <div className="bg-warm-900 rounded-lg overflow-hidden">
{command} <div className="flex items-center justify-between px-4 py-2 border-b border-warm-700">
<span className="text-warm-400 text-sm font-medium">{command}</span>
<button
onClick={handleCopy}
className="text-warm-400 hover:text-white text-sm transition-colors flex items-center gap-1"
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Gekopieerd!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Kopieer
</>
)}
</button>
</div>
<pre className="text-warm-100 px-4 py-3 font-mono text-sm whitespace-pre-wrap">{content}</pre>
</div> </div>
<p className="text-warm-500 text-sm mt-1">{content}</p>
</div> </div>
); );
} }

View file

@ -10,6 +10,7 @@ import Signup from './pages/Signup.jsx'
import WaitlistSignup from './pages/WaitlistSignup.jsx' import WaitlistSignup from './pages/WaitlistSignup.jsx'
import WaitlistThankYou from './pages/WaitlistThankYou.jsx' import WaitlistThankYou from './pages/WaitlistThankYou.jsx'
import Installatie from './pages/Installatie.jsx' import Installatie from './pages/Installatie.jsx'
import Worksheet from './pages/Worksheet.jsx'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
@ -23,6 +24,7 @@ createRoot(document.getElementById('root')).render(
<Route path="/wachtlijst-inschrijven" element={<WaitlistSignup />} /> <Route path="/wachtlijst-inschrijven" element={<WaitlistSignup />} />
<Route path="/wachtlijst-bedankt" element={<WaitlistThankYou />} /> <Route path="/wachtlijst-bedankt" element={<WaitlistThankYou />} />
<Route path="/installatie" element={<Installatie />} /> <Route path="/installatie" element={<Installatie />} />
<Route path="/worksheet" element={<Worksheet />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,

105
src/pages/Worksheet.jsx Normal file
View file

@ -0,0 +1,105 @@
/**
* Worksheet.jsx - Materiaal voor workshop deelnemers
*
* Rendert content uit content/worksheet.md via de custom Markdown parser.
* Bevat downloads, prompts en ander workshopmateriaal.
*
* Editor modus: bereikbaar via /worksheet?editor=<secret>
* De secret wordt geconfigureerd in src/config/editor.js
*/
import { useState, lazy, Suspense } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { parseDocument } from '../lib/markdown/directives';
import { renderBlocks } from '../lib/markdown/render';
import { EDITOR_CONFIG } from '../config/editor';
import worksheetContent from '../../content/worksheet.md?raw';
// Editor lazy-loaded zodat het niet in de productie bundle zit
const MarkdownEditor = lazy(() => import('../components/editor/MarkdownEditor'));
function Worksheet() {
const [searchParams] = useSearchParams();
const [content, setContent] = useState(worksheetContent);
// Editor alleen beschikbaar in dev mode (niet op productie)
const showEditor =
import.meta.env.DEV &&
EDITOR_CONFIG.enabled &&
searchParams.get('editor') === EDITOR_CONFIG.secret;
// Parse de Markdown content
const { frontmatter, blocks } = parseDocument(content);
// Context voor de renderer
const context = {};
return (
<div className="min-h-screen bg-warm-50">
{/* Header */}
<header className="bg-white border-b border-warm-200 py-6">
<div className="container-page">
<Link
to="/"
className="text-coral-500 hover:text-coral-600 transition-colors inline-flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Terug naar workshop
</Link>
</div>
</header>
{/* Content */}
<main className="container-page py-12">
<div className="max-w-3xl mx-auto">
{/* Titel en subtitel uit frontmatter */}
{frontmatter.title && (
<h1 className="heading-hero mb-4">{frontmatter.title}</h1>
)}
{frontmatter.subtitle && (
<p className="text-xl text-warm-600 mb-10">{frontmatter.subtitle}</p>
)}
{/* Gerenderde Markdown blocks */}
{renderBlocks(blocks, context)}
</div>
</main>
{/* Contact */}
<div className="text-center py-6">
<p className="text-warm-500">
Kom je er niet uit? Mail naar{' '}
<a href="mailto:frank@frankmeeuwsen.com" className="text-coral-500 hover:text-coral-600">
frank@frankmeeuwsen.com
</a>
</p>
</div>
{/* Editor overlay (alleen zichtbaar met juiste URL parameter) */}
{showEditor && (
<Suspense fallback={
<div className="fixed inset-0 bg-warm-900/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-8 text-warm-600">Editor laden...</div>
</div>
}>
<MarkdownEditor
content={content}
onContentChange={setContent}
filename="worksheet.md"
/>
</Suspense>
)}
{/* Footer */}
<footer className="bg-warm-900 text-warm-400 py-6">
<div className="container-page text-center text-sm">
&copy; {new Date().getFullYear()} Frank Meeuwsen. Alle rechten voorbehouden.
</div>
</footer>
</div>
);
}
export default Worksheet;

View file

@ -28,8 +28,10 @@ export function editorApiPlugin() {
req.on('data', chunk => { body += chunk; }); req.on('data', chunk => { body += chunk; });
req.on('end', () => { req.on('end', () => {
try { try {
const { content } = JSON.parse(body); const { content, filename } = JSON.parse(body);
const filePath = path.join(rootDir, 'content', 'installatie.md'); // Bestandsnaam uit request, fallback naar installatie.md
const safeName = (filename || 'installatie.md').replace(/[^a-zA-Z0-9._-]/g, '');
const filePath = path.join(rootDir, 'content', safeName);
// Zorg dat de content directory bestaat // Zorg dat de content directory bestaat
const dir = path.dirname(filePath); const dir = path.dirname(filePath);