feat: Markdown editor voor Installatie pagina + content updates

Installatie pagina omgezet van hardcoded JSX naar Markdown-driven rendering.
Browser-based editor toegevoegd (alleen in dev mode) met split-pane layout,
sneltoetsen, toolbar en drag & drop afbeeldingen.

Nieuwe afbeeldingen voor Git en Python installatie-instructies.
Workshop op uitverkocht gezet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frank Meeuwsen 2026-03-29 21:44:03 +02:00
parent 4ab4f7e4f2
commit fb52af979a
20 changed files with 1832 additions and 475 deletions

236
content/installatie.md Normal file
View file

@ -0,0 +1,236 @@
---
title: Installatie-instructies
subtitle: Bereid je voor op de workshop door Claude Code alvast te installeren. Volg onderstaande stappen en je bent binnen 10 minuten klaar.
---
## Wat je nodig hebt
:::checklist
- **Een Claude abonnement** -- Claude Code werkt alleen met een [betaald Claude account](https://claude.ai/pricing). Claude Pro kost €18/maand, Claude Max €90/maand. Start voor de workshop met Pro.
- **Een Mac of Windows laptop** -- De desktop app werkt op beide platforms. Neem deze laptop mee naar de workshop. **Let op:** Claude Code werkt *niet* op een Chromebook of Chrome OS!
- **Een idee wat je wilt maken** -- Denk al na over een klein, uitvoerbaar idee wat je in een paar uur kunt realiseren. Een complete site nabouwen zal niet lukken, een klein deel maken wel.
:::
## Desktop of terminal?
Claude Code kun je op twee manieren gebruiken: via de desktop app of via de terminal (command line). Beide zijn volwaardig. Voor deze workshop starten we met de desktop app - die is het makkelijkst om mee te beginnen.
Het is wel handig om te weten wat de terminal is. Als je daar al bekend mee bent, sla dit dan over.
:::accordion[Wat is de terminal?]
Een terminal is een venster waar je tekstcommando's typt in plaats van te klikken. Je hoeft dit niet te beheersen voor de workshop, maar het helpt om te weten dat het bestaat.
**Terminal openen:**
- **Mac:** Druk `Cmd + Spatie`, typ "Terminal", druk Enter
- **Windows:** Druk de Windows-toets, typ "PowerShell", klik erop
**4 basiscommando's:**
:::command[pwd]
Waar ben ik? Toont het pad naar je huidige map.
:::
:::command[ls]
Wat staat hier? Toont bestanden en mappen. Op Windows: `dir`
:::
:::command[cd mapnaam]
Ga naar een map. `cd ..` gaat een map terug.
:::
:::command[mkdir mapnaam]
Maak een nieuwe map aan.
:::
:::
## Claude Code desktop app installeren
Ga naar [claude.com/download](https://claude.com/download) en download de app voor jouw besturingssysteem.
![Claude Code download pagina](images/installatie/desktop-installatie.png)
:::tabs{os}
::tab[Mac]
### Installatie op Mac
:::steps
1. Download het .dmg bestand van claude.com/download
2. Open het bestand en sleep het Claude icoon naar je Applications map
3. Klaar! Open de app vanuit je Applications map of via Spotlight (Cmd + Spatie, typ "Claude")
:::
::tab[Windows]
### Installatie op Windows
:::steps
1. Download het .exe bestand van claude.com/download
2. Dubbelklik op het bestand om de installer te starten
3. Volg de installatiestappen
4. Claude Code wordt automatisch toegevoegd aan je Start menu
:::
:::
:::if{os=mac}
### Installatie hulpprogramma
We installeren nu extra software voor je Mac waarmee je straks versiebeheer kunt doen. Dit zijn de Apple Xcode Command Line Tools. Hiervoor gaan we kort naar Terminal.
:::steps
1. Open Terminal (druk Cmd + Spatie, typ "Terminal", druk Enter)
2. Kopieer dit commando en plak het in de terminal: `xcode-select --install`
3. Je ziet nu een pop-up verschijnen met de mededeling om Git te installeren. Klik op Install en heb geduld. Dit kan 15 - 20 minuten duren. Doe dit dus ruim vantevoren.
![git-install.png](images/installatie/git-install.png)
![git-time.png](images/installatie/git-time.png)
4. Je krijgt automatisch een melding als de installatie is geslaagd
:::
### Installatie NodeJS
NodeJS is een programmeer-omgeving die Claude Code veel gebruikt. Hiermee kan Claude Code zelfstandig websites en apps ontwikkelen. Je installeert dit eenmalig op je computer.
:::steps
1. Ga naar [de website van NodeJS](https://nodejs.org) en klik op *Get NodeJS*
2. Kies onder het zwarte scherm voor MacOS Installer (.pkg) en download het programma
3. Dubbelklik op het bestand en installeer Node.js
:::
### Installatie Python
Met Python is veel mogelijk. Van losse applicaties tot scripts, tot uitgebreide berekeningen en analyses. Claude Code wil dit vaak gebruiken in de ontwikkeling, daarom installeren we het nu.
:::steps
1. Ga naar [de website van Python](https://python.org) en klik op *Download Python*
2. De site biedt direct de macOS Installer aan en je download het programma
3. Dubbelklik op het bestand en installeer Python
4. Voor Python is nog één extra stap nodig. Je krijgt aan het einde een melding om certificaten te installeren.
![python-install-warning.png](images/installatie/python-install-warning.png)
5. Tegelijk opent je Finder en zie je het bestand *Install Certificates.command*. Dubbelklik hier op.
![python-install-certificates.png](images/installatie/python-install-certificates.png)
:::
De installatie voor Mac is hiermee voltooid.
:::
:::if{os=windows}
## Git installeren (Windows)
Op Windows heb je Git nodig om foutmeldingen in de Claude Code desktop app te voorkomen. Git is een programma dat Claude Code gebruikt om je bestanden bij te houden.
![Foutmelding in Claude Code wanneer Git niet geinstalleerd is](images/installatie/windows-git-installatie-melding.png)
:::steps
1. Ga naar [git-scm.com/download/win](https://git-scm.com/download/win)
2. Download de "Standalone Installer" (64-bit)
3. Voer de installer uit en klik steeds op "Next" (de standaardinstellingen zijn prima)
4. Klik op "Install" en wacht tot het klaar is
:::
:::warning
**Start je computer opnieuw op na de installatie van Git.** Door te herstarten worden alle instellingen automatisch goed gezet. Je hoeft niets handmatig aan te passen.
:::
## NodeJS installeren
NodeJS is een programmeer-omgeving die Claude Code veel gebruikt. Hiermee kan Claude Code zelfstandig websites en apps ontwikkelen. Je installeert dit eenmalig op je computer.
:::steps
1. Ga naar [de website van NodeJS](https://nodejs.org) en klik op *Get NodeJS*
2. Kies onder het zwarte scherm voor Windows Installer (.msi) en download het programma
3. Dubbelklik op het bestand en installeer Node.js
:::
## Installatie Python
Met Python is veel mogelijk. Van losse applicaties tot scripts, tot uitgebreide berekeningen en analyses. Claude Code wil dit vaak gebruiken in de ontwikkeling, daarom installeren we het nu.
:::steps
1. Ga naar [de website van Python](https://python.org)
2. In de navigatie zie je Downloads. Ga er overheen en je ziet direct staan "Download stand-alone installer". Klik er op en download naar je computer
3. Dubbelklik op het bestand en installeer Python met de optie "*Install now*"
![python-self-installer.png](images/installatie/python-self-installer.png)
Hiermee heb je alle hulpprogramma's geinstalleerd.
:::
:::
## Eerste keer opstarten
:::steps
1. **Open de Claude app** -- Je ziet een welkomstscherm met de mogelijkheid om in te loggen.
:::image{os=mac}
![Claude for Mac welkomstscherm](images/installatie/mac-installatie-inloggen.png)
:::
:::image{os=windows}
![Claude Code welkomstscherm met Sign in knop](images/installatie/windows-installatie-inloggen.png)
:::
2. **Log in met je Claude account** -- Kies om in te loggen via email of Google. Je browser opent automatisch. Geef toestemming voor de desktop app.
3. **Schakel over naar Claude Code** -- Na het inloggen land je in Claude Chat. Bovenin het scherm zie je een schakelaar. Zet die om naar **Claude Code**. Dit hoef je maar een keer te doen.
![Schakelaar bovenin de app om te wisselen tussen Claude Chat en Claude Code](images/installatie/desktop-installatie-schuif-claude-boven.png)
4. **Je ziet het Claude Code scherm** -- Een chat interface in het midden en een overzicht van gesprekken links. Je bent klaar!
:::
## Projectmap aanmaken
Claude Code werkt altijd binnen een projectmap. Maak vooraf een map aan waar je tijdens de workshop in gaat werken.
:::if{os=mac}
:::steps
1. Maak een map aan met de naam `Projecten` in je thuisfolder. Installeer het *niet* in je Documenten-map omdat hiermee extra lees- en schrijfrechten worden gevraagd door Mac.
2. Op de Mac open je Finder en ga je via het menu naar Ga > Thuismap (Sneltoets Shift-CMD-H) en maak hier de submap `Projecten` aan (Archief > Nieuwe map of Shift-CMD-N)
3. Open deze map in Claude Code via het mapicoon linksonder.
:::
:::
:::if{os=windows}
:::steps
1. Maak een map aan met de naam `Projecten` in je thuisfolder. Installeer het *niet* in je Documenten-map omdat hiermee extra lees- en schrijfrechten worden gevraagd door Windows.
2. Op Windows open je Verkenner en ga je naar de gebruikersmap (`C:/Users/[username]`) en maak hier de submap `Projecten` aan.
3. Open deze map in Claude Code via het mapicoon linksonder.
:::
:::
:::info
**Let op: werk altijd in een aparte projectmap.** Geef Claude Code niet zomaar toegang tot je hele computer. Claude kan bestanden maken, aanpassen en verwijderen. Door in een aparte map te werken, beperk je wat Claude kan doen. Je computer vraagt mogelijk of Claude toegang mag tot "Documenten" - daarmee geef je alleen toegang tot de gekozen map, niet tot alles in Documenten.
:::
## Controleer of alles werkt
Typ in Claude Code: `Hallo Claude, vertel me in welke map je nu bent.`
Claude antwoordt met informatie over je huidige werkmap. Dit bevestigt dat alles werkt.
:::checklist-verify
- Claude abonnement actief
- Desktop app geinstalleerd en geopend
- Ingelogd en overgeschakeld naar Claude Code
- Projecten-map aangemaakt en geopend in Claude Code
:::
## Problemen oplossen
:::troubleshoot[App start niet na installatie]
- **Mac:** Check of je de app naar Applications hebt gesleept
- **Windows:** Probeer de installer opnieuw als administrator uit te voeren
:::
:::troubleshoot[Kan niet inloggen]
- Check of je een Claude Pro of Max account hebt (gratis werkt niet)
- Probeer uit te loggen en opnieuw in te loggen in je browser
:::
:::if{os=windows}
:::troubleshoot[Foutmelding over Git in de desktop app]
- Installeer Git via de stappen hierboven
- Start je computer opnieuw op na de Git-installatie
- Open daarna pas de Claude Code app weer
:::
:::
:::troubleshoot[Geen project directory kunnen kiezen]
- Zorg dat de Projecten-map bestaat en dat je leesrechten hebt
- Maak een nieuwe lege map in je thuisfolder als het niet lukt
:::

13
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "cc-course",
"version": "0.0.0",
"dependencies": {
"marked": "^17.0.5",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0"
@ -2748,6 +2749,18 @@
"yallist": "^3.0.2"
}
},
"node_modules/marked": {
"version": "17.0.5",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz",
"integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",

View file

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"marked": "^17.0.5",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View file

@ -0,0 +1,107 @@
/**
* EditorToolbar.jsx - Toolbar met opmaakknoppen voor de Markdown editor
*
* Biedt knoppen voor veelgebruikte Markdown opmaak en custom directives.
* Elke knop voegt de juiste syntax in rond de huidige selectie in de textarea.
*/
import { useCallback } from 'react';
/**
* @param {object} props
* @param {React.RefObject} props.textareaRef - Ref naar de textarea
* @param {function} props.onChange - Callback wanneer content wijzigt
* @param {function} props.onSave - Callback voor opslaan
* @param {boolean} props.isDirty - Of er onopgeslagen wijzigingen zijn
* @param {boolean} props.isSaving - Of er momenteel opgeslagen wordt
*/
export default function EditorToolbar({ textareaRef, onChange, onSave, isDirty, isSaving }) {
const applyFormat = useCallback((prefix, suffix = '') => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
const selected = value.substring(start, end);
const replacement = selected
? `${prefix}${selected}${suffix}`
: `${prefix}tekst${suffix}`;
const newValue = value.substring(0, start) + replacement + value.substring(end);
textarea.value = newValue;
if (selected) {
textarea.selectionStart = start + prefix.length;
textarea.selectionEnd = start + prefix.length + selected.length;
} else {
textarea.selectionStart = start + prefix.length;
textarea.selectionEnd = start + prefix.length + 'tekst'.length;
}
textarea.focus();
onChange(newValue);
}, [textareaRef, onChange]);
const insertSnippet = useCallback((snippet) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const value = textarea.value;
const before = start > 0 && value[start - 1] !== '\n' ? '\n\n' : '\n';
const text = before + snippet + '\n';
const newValue = value.substring(0, start) + text + value.substring(start);
textarea.value = newValue;
textarea.selectionStart = start + text.length;
textarea.selectionEnd = start + text.length;
textarea.focus();
onChange(newValue);
}, [textareaRef, onChange]);
return (
<div className="flex items-center gap-1 flex-wrap bg-warm-100 border-b border-warm-200 px-3 py-2">
{/* Opmaak knoppen */}
<button onClick={() => applyFormat('**', '**')} title="Vet (Cmd+B)" className="px-2.5 py-1.5 rounded text-warm-700 hover:bg-warm-200 hover:text-warm-900 transition-colors font-bold">B</button>
<button onClick={() => applyFormat('*', '*')} title="Cursief (Cmd+I)" className="px-2.5 py-1.5 rounded text-warm-700 hover:bg-warm-200 hover:text-warm-900 transition-colors italic">I</button>
<button onClick={() => applyFormat('`', '`')} title="Code" className="px-2.5 py-1.5 rounded text-warm-700 hover:bg-warm-200 hover:text-warm-900 transition-colors font-mono text-sm">&lt;&gt;</button>
<button onClick={() => applyFormat('## ', '')} title="Kop 2 (Cmd+Shift+2)" className="px-2.5 py-1.5 rounded text-warm-700 hover:bg-warm-200 hover:text-warm-900 transition-colors text-sm font-bold">H2</button>
<button onClick={() => applyFormat('### ', '')} title="Kop 3 (Cmd+Shift+3)" className="px-2.5 py-1.5 rounded text-warm-700 hover:bg-warm-200 hover:text-warm-900 transition-colors text-sm font-bold">H3</button>
<button onClick={() => applyFormat('[', '](url)')} title="Link (Cmd+K)" className="px-2.5 py-1.5 rounded text-warm-700 hover:bg-warm-200 hover:text-warm-900 transition-colors">&#128279;</button>
<button onClick={() => applyFormat('![alt](', ')')} title="Afbeelding" className="px-2.5 py-1.5 rounded text-warm-700 hover:bg-warm-200 hover:text-warm-900 transition-colors">&#128247;</button>
<button onClick={() => applyFormat('- ', '')} title="Lijst" className="px-2.5 py-1.5 rounded text-warm-700 hover:bg-warm-200 hover:text-warm-900 transition-colors text-lg">&bull;</button>
<div className="w-px h-6 bg-warm-300 mx-1" />
{/* Directive knoppen */}
<button onClick={() => insertSnippet(':::tabs{os}\n::tab[Mac]\nMac instructies hier\n\n::tab[Windows]\nWindows instructies hier\n:::')} title="OS-tabbladen invoegen" className="px-2 py-1.5 rounded text-xs font-medium text-warm-600 hover:bg-warm-200 hover:text-warm-900 transition-colors">Tabs</button>
<button onClick={() => insertSnippet(':::warning\n**Let op:** waarschuwing hier\n:::')} title="Waarschuwingsblok invoegen" className="px-2 py-1.5 rounded text-xs font-medium text-warm-600 hover:bg-warm-200 hover:text-warm-900 transition-colors">Warning</button>
<button onClick={() => insertSnippet(':::info\n**Let op:** informatie hier\n:::')} title="Informatieblok invoegen" className="px-2 py-1.5 rounded text-xs font-medium text-warm-600 hover:bg-warm-200 hover:text-warm-900 transition-colors">Info</button>
<button onClick={() => insertSnippet(':::accordion[Titel]\nInhoud hier\n:::')} title="Uitklapbare sectie invoegen" className="px-2 py-1.5 rounded text-xs font-medium text-warm-600 hover:bg-warm-200 hover:text-warm-900 transition-colors">Accordion</button>
<button onClick={() => insertSnippet(':::steps\n1. Eerste stap\n2. Tweede stap\n3. Derde stap\n:::')} title="Genummerde stappen invoegen" className="px-2 py-1.5 rounded text-xs font-medium text-warm-600 hover:bg-warm-200 hover:text-warm-900 transition-colors">Steps</button>
<button onClick={() => insertSnippet(':::if{os=windows}\nAlleen zichtbaar op Windows\n:::')} title="Conditioneel blok invoegen" className="px-2 py-1.5 rounded text-xs font-medium text-warm-600 hover:bg-warm-200 hover:text-warm-900 transition-colors">If</button>
{/* Spacer */}
<div className="flex-1" />
{/* Status en opslaan */}
<span className={`text-xs mr-2 ${isDirty ? 'text-coral-500' : 'text-teal-500'}`}>
{isSaving ? 'Opslaan...' : isDirty ? 'Niet opgeslagen' : 'Opgeslagen'}
</span>
<button
onClick={onSave}
disabled={!isDirty || isSaving}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
isDirty
? 'bg-coral-500 text-white hover:bg-coral-600'
: 'bg-warm-200 text-warm-400 cursor-not-allowed'
}`}
>
Opslaan
</button>
</div>
);
}

View file

@ -0,0 +1,142 @@
/**
* ImageDropZone.jsx - Drag & drop overlay voor afbeeldingen
*
* Toont een overlay wanneer bestanden over de editor gesleept worden.
* Bij drop: valideert het bestandstype, upload naar de server,
* en voegt de Markdown afbeelding-syntax in op de cursorpositie.
*/
import { useState, useCallback } from 'react';
/**
* @param {object} props
* @param {React.ReactNode} props.children - De editor content
* @param {React.RefObject} props.textareaRef - Ref naar de textarea
* @param {function} props.onChange - Callback wanneer content wijzigt (na afbeelding invoegen)
*/
export default function ImageDropZone({ children, textareaRef, onChange, className = '' }) {
const [isDragging, setIsDragging] = useState(false);
const [uploadStatus, setUploadStatus] = useState(null);
const handleDragEnter = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
// Alleen sluiten als we het drop zone element echt verlaten
if (e.currentTarget.contains(e.relatedTarget)) return;
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(async (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const imageFile = files.find(f => f.type.startsWith('image/'));
if (!imageFile) {
setUploadStatus({ type: 'error', message: 'Alleen afbeeldingen worden geaccepteerd' });
setTimeout(() => setUploadStatus(null), 3000);
return;
}
// Valideer bestandstype
const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowed.includes(imageFile.type)) {
setUploadStatus({ type: 'error', message: `Type ${imageFile.type} niet ondersteund` });
setTimeout(() => setUploadStatus(null), 3000);
return;
}
setUploadStatus({ type: 'uploading', message: 'Uploaden...' });
try {
const formData = new FormData();
formData.append('file', imageFile);
const response = await fetch('/__editor/upload', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Upload mislukt');
}
// Voeg Markdown afbeelding in op de cursorpositie
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const value = textarea.value;
const imgMarkdown = `![${result.filename}](${result.path})`;
// Voeg lege regels toe als de cursor midden in tekst staat
const before = start > 0 && value[start - 1] !== '\n' ? '\n\n' : '';
const after = start < value.length && value[start] !== '\n' ? '\n\n' : '';
const insertion = `${before}${imgMarkdown}${after}`;
const newValue = value.substring(0, start) + insertion + value.substring(start);
textarea.value = newValue;
textarea.selectionStart = start + insertion.length;
textarea.selectionEnd = start + insertion.length;
textarea.focus();
onChange(newValue);
}
setUploadStatus({ type: 'success', message: `${result.filename} toegevoegd` });
setTimeout(() => setUploadStatus(null), 3000);
} catch (err) {
setUploadStatus({ type: 'error', message: err.message });
setTimeout(() => setUploadStatus(null), 5000);
}
}, [textareaRef, onChange]);
return (
<div
className={`relative ${className}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{children}
{/* Drag overlay */}
{isDragging && (
<div className="absolute inset-0 bg-coral-500/10 border-2 border-dashed border-coral-400 rounded-lg flex items-center justify-center z-10 pointer-events-none">
<div className="bg-white rounded-xl px-6 py-4 shadow-lg text-center">
<svg className="w-10 h-10 text-coral-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="font-semibold text-warm-800">Afbeelding hier loslaten</p>
<p className="text-sm text-warm-500">PNG, JPG, GIF of WebP</p>
</div>
</div>
)}
{/* Upload status melding */}
{uploadStatus && (
<div className={`absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm font-medium shadow-lg z-20 ${
uploadStatus.type === 'error' ? 'bg-red-100 text-red-700' :
uploadStatus.type === 'success' ? 'bg-teal-100 text-teal-700' :
'bg-warm-100 text-warm-600'
}`}>
{uploadStatus.message}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,199 @@
/**
* MarkdownEditor.jsx - Hoofd editor component
*
* Split-pane layout met:
* - Links: Markdown textarea met toolbar en sneltoetsen
* - Rechts: Live preview via dezelfde renderer als de pagina
*
* Wordt lazy-loaded en is alleen beschikbaar in dev mode
* via /installatie?editor=<secret>
*/
import { useState, useRef, useCallback } from 'react';
import { parseDocument } from '../../lib/markdown/directives';
import { renderBlocks } from '../../lib/markdown/render';
import EditorToolbar from './EditorToolbar';
import ImageDropZone from './ImageDropZone';
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
/**
* @param {object} props
* @param {string} props.content - Huidige Markdown content
* @param {function} props.onContentChange - Callback bij wijzigingen
*/
export default function MarkdownEditor({ content, onContentChange }) {
const [isOpen, setIsOpen] = useState(true);
const [isDirty, setIsDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [activeOS, setActiveOS] = useState('mac');
const textareaRef = useRef(null);
const handleChange = useCallback((newValue) => {
onContentChange(newValue);
setIsDirty(true);
}, [onContentChange]);
const handleSave = useCallback(async () => {
if (!isDirty || isSaving) return;
setIsSaving(true);
try {
const response = await fetch('/__editor/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
setIsDirty(false);
} catch (err) {
alert(`Opslaan mislukt: ${err.message}`);
} finally {
setIsSaving(false);
}
}, [content, isDirty, isSaving]);
// Keyboard shortcuts registreren
useKeyboardShortcuts(textareaRef, handleChange, handleSave);
// Preview renderen - try-catch zodat typefouten de editor niet crashen
let frontmatter = {};
let blocks = [];
let parseError = null;
try {
const parsed = parseDocument(content);
frontmatter = parsed.frontmatter;
blocks = parsed.blocks;
} catch (err) {
parseError = err.message;
}
const context = { activeOS, setActiveOS };
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-4 right-4 z-50 bg-coral-500 text-white px-4 py-2 rounded-lg shadow-lg hover:bg-coral-600 transition-colors font-medium"
>
Editor openen
</button>
);
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-warm-50">
{/* Header bar */}
<div className="flex items-center justify-between bg-warm-900 text-white px-4 py-2">
<div className="flex items-center gap-3">
<h2 className="font-display font-bold">Markdown Editor</h2>
<span className="text-warm-400 text-sm">installatie.md</span>
</div>
<div className="flex items-center gap-2">
<span className="text-warm-400 text-xs">
Cmd+B vet | Cmd+I cursief | Cmd+K link | Cmd+S opslaan
</span>
<button
onClick={() => setIsOpen(false)}
className="ml-4 px-3 py-1 rounded text-warm-300 hover:text-white hover:bg-warm-700 transition-colors"
title="Editor minimaliseren"
>
Minimaliseren
</button>
<button
onClick={() => {
if (isDirty && !confirm('Je hebt onopgeslagen wijzigingen. Weet je zeker dat je de editor wilt sluiten?')) return;
setIsOpen(false);
}}
className="px-3 py-1 rounded text-warm-300 hover:text-white hover:bg-warm-700 transition-colors"
title="Editor sluiten"
>
Sluiten
</button>
</div>
</div>
{/* Toolbar */}
<EditorToolbar
textareaRef={textareaRef}
onChange={handleChange}
onSave={handleSave}
isDirty={isDirty}
isSaving={isSaving}
/>
{/* Split pane: editor links, preview rechts */}
<div className="flex-1 flex overflow-hidden">
{/* Markdown editor (links) */}
<div className="w-1/2 flex flex-col border-r border-warm-200 min-h-0">
<ImageDropZone
textareaRef={textareaRef}
onChange={handleChange}
className="flex-1 flex flex-col min-h-0"
>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => handleChange(e.target.value)}
className="flex-1 w-full p-4 font-mono text-sm text-warm-800 bg-white resize-none focus:outline-none min-h-0"
spellCheck={false}
placeholder="Typ hier je Markdown..."
/>
</ImageDropZone>
</div>
{/* Live preview (rechts) */}
<div className="w-1/2 overflow-y-auto bg-warm-50">
<div className="max-w-3xl mx-auto p-8">
{/* Preview header */}
<div className="mb-6 pb-4 border-b border-warm-200">
<span className="text-xs font-medium text-warm-400 uppercase tracking-wide">Preview</span>
{/* OS toggle voor preview */}
<div className="flex gap-2 mt-2">
<button
onClick={() => setActiveOS('mac')}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
activeOS === 'mac'
? 'bg-coral-500 text-white'
: 'bg-warm-200 text-warm-600 hover:bg-warm-300'
}`}
>
Mac
</button>
<button
onClick={() => setActiveOS('windows')}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
activeOS === 'windows'
? 'bg-coral-500 text-white'
: 'bg-warm-200 text-warm-600 hover:bg-warm-300'
}`}
>
Windows
</button>
</div>
</div>
{/* Gerenderde content */}
{parseError ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm">
<p className="font-semibold">Parse error (wordt vanzelf opgelost tijdens typen):</p>
<p className="font-mono mt-1">{parseError}</p>
</div>
) : (
<>
{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>
)}
{renderBlocks(blocks, context)}
</>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,161 @@
/**
* useKeyboardShortcuts.js - Custom hook voor Markdown sneltoetsen
*
* Luistert naar keyboard events op een textarea en voegt
* Markdown opmaak in rond de huidige selectie.
*
* Ondersteunde sneltoetsen:
* - Cmd/Ctrl+B : Vet (**selectie**)
* - Cmd/Ctrl+I : Cursief (*selectie*)
* - Cmd/Ctrl+K : Link ([selectie](url))
* - Cmd/Ctrl+S : Opslaan
* - Cmd/Ctrl+Shift+1 : Heading 1 (# )
* - Cmd/Ctrl+Shift+2 : Heading 2 (## )
* - Cmd/Ctrl+Shift+3 : Heading 3 (### )
*/
import { useEffect, useCallback } from 'react';
/**
* @param {React.RefObject} textareaRef - Ref naar de textarea
* @param {function} onChange - Callback wanneer content wijzigt
* @param {function} onSave - Callback voor opslaan (Cmd+S)
*/
export function useKeyboardShortcuts(textareaRef, onChange, onSave) {
const handleKeyDown = useCallback((e) => {
const textarea = textareaRef.current;
if (!textarea) return;
const isMod = e.metaKey || e.ctrlKey;
if (!isMod) return;
// Cmd+S: Opslaan
if (e.key === 's') {
e.preventDefault();
onSave();
return;
}
// Cmd+B: Vet
if (e.key === 'b') {
e.preventDefault();
wrapSelection(textarea, '**', '**', onChange);
return;
}
// Cmd+I: Cursief
if (e.key === 'i') {
e.preventDefault();
wrapSelection(textarea, '*', '*', onChange);
return;
}
// Cmd+K: Link
if (e.key === 'k') {
e.preventDefault();
const selected = getSelection(textarea);
if (selected) {
wrapSelection(textarea, '[', '](url)', onChange);
} else {
insertAtCursor(textarea, '[linktekst](url)', onChange);
}
return;
}
// Cmd+Shift+1/2/3: Headings
if (e.shiftKey && ['1', '2', '3'].includes(e.key)) {
e.preventDefault();
const level = parseInt(e.key);
const prefix = '#'.repeat(level) + ' ';
insertLinePrefix(textarea, prefix, onChange);
return;
}
}, [textareaRef, onChange, onSave]);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.addEventListener('keydown', handleKeyDown);
return () => textarea.removeEventListener('keydown', handleKeyDown);
}, [textareaRef, handleKeyDown]);
}
/**
* Haalt de huidige selectie uit een textarea.
*/
function getSelection(textarea) {
return textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
}
/**
* Wrapt de huidige selectie met prefix en suffix.
* Als er geen selectie is, wordt placeholder tekst ingevoegd.
*/
function wrapSelection(textarea, prefix, suffix, onChange) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
const selected = value.substring(start, end);
const replacement = selected
? `${prefix}${selected}${suffix}`
: `${prefix}tekst${suffix}`;
const newValue = value.substring(0, start) + replacement + value.substring(end);
textarea.value = newValue;
// Plaats cursor na de ingevoegde tekst of selecteer de placeholder
if (selected) {
textarea.selectionStart = start + prefix.length;
textarea.selectionEnd = start + prefix.length + selected.length;
} else {
textarea.selectionStart = start + prefix.length;
textarea.selectionEnd = start + prefix.length + 'tekst'.length;
}
textarea.focus();
onChange(newValue);
}
/**
* Voegt tekst in op de cursorpositie.
*/
function insertAtCursor(textarea, text, onChange) {
const start = textarea.selectionStart;
const value = textarea.value;
const newValue = value.substring(0, start) + text + value.substring(start);
textarea.value = newValue;
textarea.selectionStart = start;
textarea.selectionEnd = start + text.length;
textarea.focus();
onChange(newValue);
}
/**
* Voegt een prefix toe aan het begin van de huidige regel.
* Vervangt bestaande heading prefixes als die er al zijn.
*/
function insertLinePrefix(textarea, prefix, onChange) {
const value = textarea.value;
const cursor = textarea.selectionStart;
// Vind het begin van de huidige regel
const lineStart = value.lastIndexOf('\n', cursor - 1) + 1;
const lineEnd = value.indexOf('\n', cursor);
const actualLineEnd = lineEnd === -1 ? value.length : lineEnd;
const line = value.substring(lineStart, actualLineEnd);
// Verwijder bestaande heading prefix
const cleanLine = line.replace(/^#{1,6}\s*/, '');
const newLine = prefix + cleanLine;
const newValue = value.substring(0, lineStart) + newLine + value.substring(actualLineEnd);
textarea.value = newValue;
textarea.selectionStart = lineStart + prefix.length;
textarea.selectionEnd = lineStart + newLine.length;
textarea.focus();
onChange(newValue);
}

12
src/config/editor.js Normal file
View file

@ -0,0 +1,12 @@
/**
* editor.js - Editor configuratie
*
* Bevat het geheim voor toegang tot de Markdown editor.
* De editor is bereikbaar via /installatie?editor=<secret>
* Pas de secret hieronder aan naar een waarde die je zelf kiest.
*/
export const EDITOR_CONFIG = {
secret: 'frank2026',
enabled: true,
};

View file

@ -8,6 +8,6 @@
export const WORKSHOP_CONFIG = {
totalSpots: 8,
availableSpots: 1,
isSoldOut: false,
availableSpots: 0,
isSoldOut: true,
};

View file

@ -101,4 +101,47 @@
.container-page {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
/* Prose styles voor Markdown-gerenderde content op de Installatie pagina */
.prose-installatie h2 {
@apply font-display text-2xl md:text-3xl font-bold leading-snug text-warm-900 mb-4 mt-10;
}
.prose-installatie h3 {
@apply font-display text-xl md:text-2xl font-semibold leading-snug text-warm-800 mb-3;
}
.prose-installatie p {
@apply text-warm-600 mb-4;
}
.prose-installatie a {
@apply text-coral-500 hover:text-coral-600 font-semibold;
}
.prose-installatie code {
@apply bg-warm-100 text-warm-800 px-1.5 py-0.5 rounded text-sm font-mono;
}
.prose-installatie img {
@apply rounded-xl border border-warm-200 mb-6;
}
.prose-installatie ul {
@apply list-disc pl-6 space-y-1 text-warm-600;
}
.prose-installatie ol {
@apply space-y-4 text-warm-600;
}
.prose-installatie strong {
@apply font-semibold text-warm-900;
}
/* Warnings en info blocks binnen prose */
.prose-warning p {
@apply text-teal-700 mb-1;
}
.prose-warning strong {
@apply text-teal-700 font-semibold;
}
.prose-info p {
@apply text-warm-700 mb-1;
}
.prose-info strong {
@apply text-warm-700 font-semibold;
}
}

View file

@ -0,0 +1,355 @@
/**
* directives.js - Custom directive parser voor Markdown
*
* Parseert custom syntax zoals :::tabs, :::warning, :::accordion etc.
* en splitst het document in typed blocks die de renderer kan verwerken.
*
* Ondersteunde directives:
* - :::tabs{os} OS-tabbladen (Mac/Windows)
* - ::tab[Label] Tab binnen een tabs-blok
* - :::accordion[T] Uitklapbare sectie
* - :::warning Waarschuwingsblok (teal)
* - :::info Informatieblok (warm)
* - :::steps Genummerde stappen met iconen
* - :::checklist Checklijst met vinkjes
* - :::checklist-verify Verificatie checklijst
* - :::command[cmd] Terminal commando met uitleg
* - :::if{os=value} Conditionele content
* - :::image{os=val} Conditionele afbeelding
* - :::troubleshoot[T] Probleemoplossing kaart
*/
/**
* Hoofdfunctie: parseert Markdown met directives naar een block-array.
*
* @param {string} markdown - Raw Markdown tekst met directives
* @returns {{ frontmatter: object, blocks: Array }} Geparsed resultaat
*/
export function parseDocument(markdown) {
const { frontmatter, body } = extractFrontmatter(markdown);
const blocks = parseDirectives(body);
return { frontmatter, blocks };
}
/**
* Extraheert YAML frontmatter (---) uit de Markdown.
*/
function extractFrontmatter(markdown) {
const match = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: markdown };
const frontmatter = {};
match[1].split('\n').forEach(line => {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
frontmatter[key] = value;
}
});
return { frontmatter, body: match[2] };
}
/**
* Parseert de body-tekst in een array van typed blocks.
* Werkt als een line-by-line state machine.
*/
function parseDirectives(text) {
const lines = text.split('\n');
const blocks = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// :::tabs{id}
const tabsMatch = line.match(/^:::tabs\{(\w+)\}/);
if (tabsMatch) {
const result = parseTabs(lines, i, tabsMatch[1]);
blocks.push(result.block);
i = result.nextIndex;
continue;
}
// :::accordion[titel]
const accordionMatch = line.match(/^:::accordion\[(.+?)\]/);
if (accordionMatch) {
const result = parseEnclosed(lines, i);
blocks.push({
type: 'accordion',
title: accordionMatch[1],
blocks: parseDirectives(result.content),
});
i = result.nextIndex;
continue;
}
// :::warning
if (line.trim() === ':::warning') {
const result = parseEnclosed(lines, i);
blocks.push({ type: 'warning', content: result.content });
i = result.nextIndex;
continue;
}
// :::info
if (line.trim() === ':::info') {
const result = parseEnclosed(lines, i);
blocks.push({ type: 'info', content: result.content });
i = result.nextIndex;
continue;
}
// :::steps
if (line.trim() === ':::steps') {
const result = parseEnclosed(lines, i);
blocks.push({ type: 'steps', steps: parseStepItems(result.content) });
i = result.nextIndex;
continue;
}
// :::checklist
if (line.trim() === ':::checklist') {
const result = parseEnclosed(lines, i);
blocks.push({ type: 'checklist', content: result.content });
i = result.nextIndex;
continue;
}
// :::checklist-verify
if (line.trim() === ':::checklist-verify') {
const result = parseEnclosed(lines, i);
blocks.push({ type: 'checklist-verify', content: result.content });
i = result.nextIndex;
continue;
}
// :::command[cmd]
const commandMatch = line.match(/^:::command\[(.+?)\]/);
if (commandMatch) {
const result = parseEnclosed(lines, i);
blocks.push({
type: 'command',
command: commandMatch[1],
content: result.content,
});
i = result.nextIndex;
continue;
}
// :::if{os=value}
const ifMatch = line.match(/^:::if\{(\w+)=(\w+)\}/);
if (ifMatch) {
const result = parseEnclosed(lines, i);
blocks.push({
type: 'conditional',
key: ifMatch[1],
value: ifMatch[2],
blocks: parseDirectives(result.content),
});
i = result.nextIndex;
continue;
}
// :::image{os=value}
const imageIfMatch = line.match(/^:::image\{(\w+)=(\w+)\}/);
if (imageIfMatch) {
const result = parseEnclosed(lines, i);
blocks.push({
type: 'conditional-image',
key: imageIfMatch[1],
value: imageIfMatch[2],
content: result.content,
});
i = result.nextIndex;
continue;
}
// :::troubleshoot[titel]
const troubleshootMatch = line.match(/^:::troubleshoot\[(.+?)\]/);
if (troubleshootMatch) {
const result = parseEnclosed(lines, i);
blocks.push({
type: 'troubleshoot',
title: troubleshootMatch[1],
content: result.content,
});
i = result.nextIndex;
continue;
}
// Onbekende ::: regel? Behandel als gewone tekst en ga verder.
// Dit voorkomt een infinite loop bij onvolledige directives (bijv. tijdens typen).
if (line.match(/^:::/)) {
blocks.push({ type: 'markdown', content: line });
i++;
continue;
}
// Gewone Markdown regel - verzamel tot de volgende directive
const markdownLines = [];
while (i < lines.length && !lines[i].match(/^:::/)) {
markdownLines.push(lines[i]);
i++;
}
const content = markdownLines.join('\n').trim();
if (content) {
blocks.push({ type: 'markdown', content });
}
continue;
}
return blocks;
}
/**
* Parseert een enclosed blok (:::open ... :::close).
* Houdt rekening met geneste directives door een depth counter.
*/
function parseEnclosed(lines, startIndex) {
let depth = 1;
let i = startIndex + 1;
const contentLines = [];
while (i < lines.length && depth > 0) {
const line = lines[i];
// Sluitende :::
if (line.trim() === ':::') {
depth--;
if (depth === 0) {
i++;
break;
}
contentLines.push(line);
i++;
continue;
}
// Openende ::: (genest)
if (line.match(/^:::[a-z]/)) {
depth++;
}
contentLines.push(line);
i++;
}
return {
content: contentLines.join('\n').trim(),
nextIndex: i,
};
}
/**
* Parseert een :::tabs{id} blok met ::tab[Label] sub-tabs.
*/
function parseTabs(lines, startIndex, tabId) {
let depth = 1;
let i = startIndex + 1;
const tabs = [];
let currentTab = null;
let currentLines = [];
while (i < lines.length && depth > 0) {
const line = lines[i];
// Sluitende :::
if (line.trim() === ':::') {
depth--;
if (depth === 0) {
// Sla laatste tab op
if (currentTab) {
tabs.push({
label: currentTab,
blocks: parseDirectives(currentLines.join('\n').trim()),
});
}
i++;
break;
}
currentLines.push(line);
i++;
continue;
}
// Nieuwe tab (depth 1)
const tabMatch = depth === 1 && line.match(/^::tab\[(.+?)\]/);
if (tabMatch) {
// Sla vorige tab op
if (currentTab) {
tabs.push({
label: currentTab,
blocks: parseDirectives(currentLines.join('\n').trim()),
});
}
currentTab = tabMatch[1];
currentLines = [];
i++;
continue;
}
// Geneste opening
if (line.match(/^:::[a-z]/)) {
depth++;
}
currentLines.push(line);
i++;
}
return {
block: { type: 'tabs', id: tabId, tabs },
nextIndex: i,
};
}
/**
* Parseert de inhoud van een :::steps blok in individuele stappen.
* Elke stap heeft een nummer, eerste-regel tekst, en geneste blocks.
*
* Voorbeeld input:
* 1. **Open de app** -- Beschrijving
* :::image{os=mac}
* ![alt](pad.png)
* :::
* 2. Volgende stap
*
* Resultaat: [{ number: '1', text: '**Open de app** -- Beschrijving', blocks: [...] }, ...]
*/
function parseStepItems(content) {
const lines = content.split('\n');
const steps = [];
let currentStep = null;
let currentLines = [];
for (const line of lines) {
const stepMatch = line.match(/^(\d+)\.\s+(.*)/);
if (stepMatch) {
// Sla de vorige stap op
if (currentStep) {
steps.push({
number: currentStep.number,
text: currentStep.text,
blocks: parseDirectives(currentLines.join('\n').trim()),
});
}
currentStep = { number: stepMatch[1], text: stepMatch[2] };
currentLines = [];
} else if (currentStep) {
currentLines.push(line);
}
}
// Sla de laatste stap op
if (currentStep) {
steps.push({
number: currentStep.number,
text: currentStep.text,
blocks: parseDirectives(currentLines.join('\n').trim()),
});
}
return steps;
}

326
src/lib/markdown/render.jsx Normal file
View file

@ -0,0 +1,326 @@
/* eslint-disable react-refresh/only-export-components */
/**
* render.jsx - Markdown-naar-React renderer
*
* Rendert de block-array uit directives.js naar React elementen.
* Elke block-type mapped naar bestaande Tailwind classes uit het project.
* Afbeeldingpaden worden automatisch geprefixed met BASE_URL.
*/
import { useState } from 'react';
import { marked } from 'marked';
const BASE_URL = import.meta.env.BASE_URL;
// Map van directive keys naar context property namen
const CONTEXT_KEY_MAP = { os: 'activeOS' };
/**
* Haalt de context-waarde op voor een directive key (bijv. "os" -> context.activeOS).
*/
function getContextValue(key, context) {
const contextKey = CONTEXT_KEY_MAP[key];
return contextKey ? context[contextKey] : context[key];
}
/**
* Rendert een array van blocks naar React elementen.
*
* @param {Array} blocks - Geparsde block-array uit parseDocument()
* @param {object} context - Gedeelde state: { activeOS, setActiveOS }
* @returns {JSX.Element[]}
*/
export function renderBlocks(blocks, context) {
return blocks.map((block, index) => (
<RenderBlock key={index} block={block} context={context} />
));
}
function RenderBlock({ block, context }) {
switch (block.type) {
case 'markdown':
return <MarkdownBlock content={block.content} />;
case 'tabs':
return <TabsBlock block={block} context={context} />;
case 'accordion':
return <AccordionBlock block={block} context={context} />;
case 'warning':
return <WarningBlock content={block.content} />;
case 'info':
return <InfoBlock content={block.content} />;
case 'steps':
return <StepsBlock block={block} context={context} />;
case 'checklist':
return <ChecklistBlock content={block.content} />;
case 'checklist-verify':
return <ChecklistVerifyBlock content={block.content} />;
case 'command':
return <CommandBlock command={block.command} content={block.content} />;
case 'conditional':
return <ConditionalBlock block={block} context={context} />;
case 'conditional-image':
return <ConditionalImageBlock block={block} context={context} />;
case 'troubleshoot':
return <TroubleshootBlock title={block.title} content={block.content} />;
default:
return null;
}
}
/**
* Zet Markdown om naar HTML met marked, fixt afbeeldingpaden.
*/
function renderMarkdown(content) {
let html = marked.parse(content, { breaks: false });
// Prefix afbeeldingpaden met BASE_URL
html = html.replace(
/src="(?!https?:\/\/|data:)([^"]+)"/g,
`src="${BASE_URL}$1"`
);
return html;
}
/**
* Gewone Markdown paragrafen, headings, links, etc.
*/
function MarkdownBlock({ content }) {
return (
<div
className="prose-installatie"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
);
}
/**
* OS-tabbladen (Mac/Windows).
* Hergebruikt de coral button styling uit de originele pagina.
*/
function TabsBlock({ block, context }) {
const { activeOS, setActiveOS } = context;
const tabMap = { Mac: 'mac', Windows: 'windows' };
return (
<div className="mb-6">
<div className="flex gap-2 mb-6">
{block.tabs.map((tab) => {
const osKey = tabMap[tab.label] || tab.label.toLowerCase();
const isActive = activeOS === osKey;
return (
<button
key={tab.label}
onClick={() => setActiveOS(osKey)}
className={`px-5 py-2 rounded-lg font-semibold transition-all ${
isActive
? 'bg-coral-500 text-white'
: 'bg-white text-warm-700 border border-warm-200 hover:border-warm-300'
}`}
>
{tab.label}
</button>
);
})}
</div>
{block.tabs.map((tab) => {
const osKey = tabMap[tab.label] || tab.label.toLowerCase();
if (activeOS !== osKey) return null;
return (
<div key={tab.label} className="card">
{renderBlocks(tab.blocks, context)}
</div>
);
})}
</div>
);
}
/**
* Uitklapbare accordion sectie.
*/
function AccordionBlock({ block, context }) {
const [open, setOpen] = useState(false);
return (
<div className="bg-white rounded-xl border border-warm-200 overflow-hidden mb-6">
<button
onClick={() => setOpen(!open)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-warm-50 transition-colors"
aria-expanded={open}
>
<span className="heading-3">{block.title}</span>
<svg
className={`w-5 h-5 text-coral-500 flex-shrink-0 transition-transform duration-200 ${
open ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="px-6 pb-6 space-y-4">
{renderBlocks(block.blocks, context)}
</div>
)}
</div>
);
}
/**
* Waarschuwingsblok (teal achtergrond).
*/
function WarningBlock({ content }) {
return (
<div className="bg-teal-50 border border-teal-200 rounded-lg px-4 py-3 mb-6">
<div
className="prose-installatie prose-warning"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
</div>
);
}
/**
* Informatieblok (warm achtergrond).
*/
function InfoBlock({ content }) {
return (
<div className="bg-warm-100 border border-warm-200 rounded-lg px-4 py-3 mb-6">
<div
className="prose-installatie prose-info"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
</div>
);
}
/**
* Genummerde stappen met coral cirkel-iconen.
* Gebruikt pre-parsed steps uit de directive parser.
* Elke stap heeft: number, text (eerste regel), blocks (geneste directives).
*/
function StepsBlock({ block, context }) {
return (
<ol className="space-y-4 text-warm-600">
{block.steps.map((step) => (
<li key={step.number} className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">
{step.number}
</span>
<div className="flex-1">
<div
className="prose-installatie"
dangerouslySetInnerHTML={{ __html: renderMarkdown(step.text) }}
/>
{step.blocks.length > 0 && renderBlocks(step.blocks, context)}
</div>
</li>
))}
</ol>
);
}
/**
* Checklijst met coral vink-iconen.
*/
function ChecklistBlock({ content }) {
const items = content
.split('\n')
.filter(line => line.match(/^-\s/))
.map(line => line.replace(/^-\s+/, ''));
return (
<div className="card">
<ul className="space-y-4">
{items.map((item, i) => (
<li key={i} className="flex gap-3">
<svg className="w-5 h-5 text-coral-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div
className="prose-installatie"
dangerouslySetInnerHTML={{ __html: renderMarkdown(item) }}
/>
</li>
))}
</ul>
</div>
);
}
/**
* Verificatie checklijst met teal vinkjes.
*/
function ChecklistVerifyBlock({ content }) {
const items = content
.split('\n')
.filter(line => line.match(/^-\s/))
.map(line => line.replace(/^-\s+/, ''));
return (
<ul className="space-y-2">
{items.map((item, i) => (
<li key={i} className="flex gap-2 text-warm-600">
<svg className="w-5 h-5 text-teal-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(item) }} />
</li>
))}
</ul>
);
}
/**
* Terminal commando blok (donkere achtergrond).
*/
function CommandBlock({ command, content }) {
return (
<div className="mb-3">
<div className="bg-warm-900 text-warm-100 rounded-lg px-4 py-2 font-mono text-sm">
{command}
</div>
<p className="text-warm-500 text-sm mt-1">{content}</p>
</div>
);
}
/**
* Conditioneel blok - toont alleen als de context-waarde matcht.
*/
function ConditionalBlock({ block, context }) {
const contextValue = getContextValue(block.key, context);
if (contextValue !== block.value) return null;
return <>{renderBlocks(block.blocks, context)}</>;
}
/**
* Conditionele afbeelding - toont alleen als OS matcht.
*/
function ConditionalImageBlock({ block, context }) {
const contextValue = getContextValue(block.key, context);
if (contextValue !== block.value) return null;
return (
<div
className="prose-installatie"
dangerouslySetInnerHTML={{ __html: renderMarkdown(block.content) }}
/>
);
}
/**
* Probleemoplossing kaart.
*/
function TroubleshootBlock({ title, content }) {
return (
<div className="card mb-4">
<p className="font-semibold text-warm-900 mb-2">{title}</p>
<div
className="prose-installatie"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
</div>
);
}

View file

@ -1,18 +1,39 @@
/**
* Installatie.jsx - Installatie-instructies voor workshop deelnemers
*
* Onepager met stap-voor-stap installatie van de Claude Code desktop app.
* Bevat OS-tabs (Mac/Windows) en een accordion voor terminal-uitleg.
* Rendert content uit content/installatie.md via een custom Markdown parser.
* Bevat OS-tabs (Mac/Windows), accordions en andere interactieve elementen.
*
* Editor modus: bereikbaar via /installatie?editor=<secret>
* De secret wordt geconfigureerd in src/config/editor.js
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
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 installatieContent from '../../content/installatie.md?raw';
// Editor lazy-loaded zodat het niet in de productie bundle zit
const MarkdownEditor = lazy(() => import('../components/editor/MarkdownEditor'));
function Installatie() {
// Terminal accordion: standaard dicht
const [terminalOpen, setTerminalOpen] = useState(false);
// OS-tab: standaard Mac
const [activeOS, setActiveOS] = useState('mac');
const [searchParams] = useSearchParams();
const [content, setContent] = useState(installatieContent);
// 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 die gedeeld wordt met de renderer (voor OS-tabs etc.)
const context = { activeOS, setActiveOS };
return (
<div className="min-h-screen bg-warm-50">
@ -34,463 +55,18 @@ function Installatie() {
{/* Content */}
<main className="container-page py-12">
<div className="max-w-3xl mx-auto">
{/* Intro */}
<h1 className="heading-hero mb-4">Installatie-instructies</h1>
<p className="text-xl text-warm-600 mb-10">
Bereid je voor op de workshop door Claude Code alvast te installeren.
Volg onderstaande stappen en je bent binnen 10 minuten klaar.
</p>
{/* Sectie 1: Wat je nodig hebt */}
<section className="mb-10">
<h2 className="heading-2 mb-4">Wat je nodig hebt</h2>
<div className="card">
<ul className="space-y-4">
<li className="flex gap-3">
<svg className="w-5 h-5 text-coral-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="font-semibold text-warm-900">Een Claude abonnement</p>
<p className="text-warm-600">
Claude Code werkt alleen met een{' '}
<a href="https://claude.ai/pricing" target="_blank" rel="noopener noreferrer" className="text-coral-500 hover:text-coral-600">
betaald Claude account
</a>
. Claude Pro kost $18/maand, Claude Max $90/maand. Start met Pro.
</p>
</div>
</li>
<li className="flex gap-3">
<svg className="w-5 h-5 text-coral-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="font-semibold text-warm-900">Een Mac of Windows computer</p>
<p className="text-warm-600">De desktop app werkt op beide platforms. Neem deze laptop mee naar de workshop.</p>
</div>
</li>
<li className="flex gap-3">
<svg className="w-5 h-5 text-coral-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="font-semibold text-warm-900">Een internetverbinding</p>
<p className="text-warm-600">Voor het downloaden en om met Claude te communiceren. Je installeert een app, maar de AI draait online.</p>
</div>
</li>
</ul>
</div>
</section>
{/* Sectie 2: Desktop of terminal? */}
<section className="mb-10">
<h2 className="heading-2 mb-4">Desktop of terminal?</h2>
<p className="text-warm-600 mb-4">
Claude Code kun je op twee manieren gebruiken: via de desktop app of via de terminal (command line).
Beide zijn volwaardig. Voor deze workshop starten we met de desktop app - die is het makkelijkst om mee te beginnen.
</p>
<p className="text-warm-600 mb-6">
Het is wel handig om te weten wat de terminal is. Als je daar al bekend mee bent, sla dit dan over.
</p>
<div className="bg-white rounded-xl border border-warm-200 overflow-hidden">
<button
onClick={() => setTerminalOpen(!terminalOpen)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-warm-50 transition-colors"
aria-expanded={terminalOpen}
>
<span className="heading-3">Wat is de terminal?</span>
<svg
className={`w-5 h-5 text-coral-500 flex-shrink-0 transition-transform duration-200 ${
terminalOpen ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{terminalOpen && (
<div className="px-6 pb-6 space-y-4">
<p className="text-warm-600">
Een terminal is een venster waar je tekstcommando's typt in plaats van te klikken.
Je hoeft dit niet te beheersen voor de workshop, maar het helpt om te weten dat het bestaat.
</p>
<div className="space-y-3">
<p className="font-semibold text-warm-800">Terminal openen:</p>
<ul className="space-y-2 text-warm-600">
<li className="flex gap-2">
<span className="font-semibold text-warm-700">Mac:</span>
<span>Druk <code className="bg-warm-100 text-warm-800 px-1.5 py-0.5 rounded text-sm font-mono">Cmd + Spatie</code>, typ "Terminal", druk Enter</span>
</li>
<li className="flex gap-2">
<span className="font-semibold text-warm-700">Windows:</span>
<span>Druk de Windows-toets, typ "PowerShell", klik erop</span>
</li>
</ul>
</div>
<div className="space-y-3">
<p className="font-semibold text-warm-800">4 basiscommando's:</p>
<div className="space-y-2">
<div>
<div className="bg-warm-900 text-warm-100 rounded-lg px-4 py-2 font-mono text-sm">pwd</div>
<p className="text-warm-500 text-sm mt-1">Waar ben ik? Toont het pad naar je huidige map.</p>
</div>
<div>
<div className="bg-warm-900 text-warm-100 rounded-lg px-4 py-2 font-mono text-sm">ls</div>
<p className="text-warm-500 text-sm mt-1">Wat staat hier? Toont bestanden en mappen. Op Windows: <code className="bg-warm-100 text-warm-800 px-1 py-0.5 rounded text-sm font-mono">dir</code></p>
</div>
<div>
<div className="bg-warm-900 text-warm-100 rounded-lg px-4 py-2 font-mono text-sm">cd mapnaam</div>
<p className="text-warm-500 text-sm mt-1">Ga naar een map. <code className="bg-warm-100 text-warm-800 px-1 py-0.5 rounded text-sm font-mono">cd ..</code> gaat een map terug.</p>
</div>
<div>
<div className="bg-warm-900 text-warm-100 rounded-lg px-4 py-2 font-mono text-sm">mkdir mapnaam</div>
<p className="text-warm-500 text-sm mt-1">Maak een nieuwe map aan.</p>
</div>
</div>
</div>
</div>
{/* Titel en subtitel uit frontmatter */}
{frontmatter.title && (
<h1 className="heading-hero mb-4">{frontmatter.title}</h1>
)}
</div>
</section>
{/* Sectie 3: Desktop app installeren */}
<section className="mb-10">
<h2 className="heading-2 mb-4">Claude Code desktop app installeren</h2>
<p className="text-warm-600 mb-6">
Ga naar{' '}
<a href="https://claude.com/download" target="_blank" rel="noopener noreferrer" className="text-coral-500 hover:text-coral-600 font-semibold">
claude.com/download
</a>
{' '}en download de app voor jouw besturingssysteem.
</p>
<img
src={`${import.meta.env.BASE_URL}images/installatie/desktop-installatie.png`}
alt="Claude Code download pagina"
className="rounded-xl border border-warm-200 mb-6"
/>
{/* OS Tab switcher */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveOS('mac')}
className={`px-5 py-2 rounded-lg font-semibold transition-all ${
activeOS === 'mac'
? 'bg-coral-500 text-white'
: 'bg-white text-warm-700 border border-warm-200 hover:border-warm-300'
}`}
>
Mac
</button>
<button
onClick={() => setActiveOS('windows')}
className={`px-5 py-2 rounded-lg font-semibold transition-all ${
activeOS === 'windows'
? 'bg-coral-500 text-white'
: 'bg-white text-warm-700 border border-warm-200 hover:border-warm-300'
}`}
>
Windows
</button>
</div>
{/* Mac instructies */}
{activeOS === 'mac' && (
<div className="card">
<h3 className="font-semibold text-warm-900 mb-4">Installatie op Mac</h3>
<ol className="space-y-4 text-warm-600">
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">1</span>
<span>Download het .dmg bestand van claude.com/download</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">2</span>
<span>Open het bestand en sleep het Claude icoon naar je Applications map</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">3</span>
<span>Klaar! Open de app vanuit je Applications map of via Spotlight (Cmd + Spatie, typ "Claude")</span>
</li>
</ol>
</div>
{frontmatter.subtitle && (
<p className="text-xl text-warm-600 mb-10">{frontmatter.subtitle}</p>
)}
{/* Windows instructies */}
{activeOS === 'windows' && (
<div className="card">
<h3 className="font-semibold text-warm-900 mb-4">Installatie op Windows</h3>
<ol className="space-y-4 text-warm-600">
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">1</span>
<span>Download het .exe bestand van claude.com/download</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">2</span>
<span>Dubbelklik op het bestand om de installer te starten</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">3</span>
<span>Volg de installatiestappen</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">4</span>
<span>Claude Code wordt automatisch toegevoegd aan je Start menu</span>
</li>
</ol>
{/* Gerenderde Markdown blocks */}
{renderBlocks(blocks, context)}
</div>
)}
</section>
{/* Sectie 4: Git voor Windows (alleen zichtbaar bij Windows) */}
{activeOS === 'windows' && (
<section className="mb-10">
<h2 className="heading-2 mb-4">Git installeren (Windows)</h2>
<div className="card">
<p className="text-warm-600 mb-4">
Op Windows heb je Git nodig om foutmeldingen in de Claude Code desktop app te voorkomen.
Git is een programma dat Claude Code gebruikt om je bestanden bij te houden.
</p>
<img
src={`${import.meta.env.BASE_URL}images/installatie/windows-git-installatie-melding.png`}
alt="Foutmelding in Claude Code wanneer Git niet geinstalleerd is"
className="rounded-xl border border-warm-200 mb-4"
/>
<ol className="space-y-4 text-warm-600 mb-6">
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">1</span>
<span>
Ga naar{' '}
<a href="https://git-scm.com/download/win" target="_blank" rel="noopener noreferrer" className="text-coral-500 hover:text-coral-600 font-semibold">
git-scm.com/download/win
</a>
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">2</span>
<span>Download de "Standalone Installer" (64-bit)</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">3</span>
<span>Voer de installer uit en klik steeds op "Next" (de standaardinstellingen zijn prima)</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">4</span>
<span>Klik op "Install" en wacht tot het klaar is</span>
</li>
</ol>
{/* Waarschuwingsblok: herstart */}
<div className="bg-teal-50 border border-teal-200 rounded-lg px-4 py-3">
<p className="text-teal-700 font-semibold">Start je computer opnieuw op na de installatie van Git.</p>
<p className="text-teal-600 text-sm mt-1">
Door te herstarten worden alle instellingen automatisch goed gezet. Je hoeft niets handmatig aan te passen.
</p>
</div>
</div>
</section>
)}
{/* Sectie 5: Eerste keer opstarten */}
<section className="mb-10">
<h2 className="heading-2 mb-4">Eerste keer opstarten</h2>
<div className="card">
<ol className="space-y-4 text-warm-600">
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">1</span>
<div>
<p className="font-semibold text-warm-900">Open de Claude app</p>
<p>Je ziet een welkomstscherm met de mogelijkheid om in te loggen.</p>
{activeOS === 'mac' ? (
<img
src={`${import.meta.env.BASE_URL}images/installatie/mac-installatie-inloggen.png`}
alt="Claude for Mac welkomstscherm"
className="rounded-xl border border-warm-200 mt-2"
/>
) : (
<img
src={`${import.meta.env.BASE_URL}images/installatie/windows-installatie-inloggen.png`}
alt="Claude Code welkomstscherm met Sign in knop"
className="rounded-xl border border-warm-200 mt-2"
/>
)}
</div>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">2</span>
<div>
<p className="font-semibold text-warm-900">Log in met je Claude account</p>
<p>Kies om in te loggen via email of Google. Je browser opent automatisch. Geef toestemming voor de desktop app.</p>
</div>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">3</span>
<div>
<p className="font-semibold text-warm-900">Schakel over naar Claude Code</p>
<p>Na het inloggen land je in Claude Chat. Bovenin het scherm zie je een schakelaar. Zet die om naar <strong>Claude Code</strong>. Dit hoef je maar een keer te doen.</p>
<img
src={`${import.meta.env.BASE_URL}images/installatie/desktop-installatie-schuif-claude-boven.png`}
alt="Schakelaar bovenin de app om te wisselen tussen Claude Chat en Claude Code"
className="rounded-xl border border-warm-200 mt-2"
/>
</div>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">4</span>
<div>
<p className="font-semibold text-warm-900">Je ziet het Claude Code scherm</p>
<p>Een chat interface in het midden en een overzicht van gesprekken links. Je bent klaar!</p>
</div>
</li>
</ol>
</div>
</section>
{/* Sectie 6: Project directory kiezen */}
<section className="mb-10">
<h2 className="heading-2 mb-4">Projectmap aanmaken</h2>
<div className="card">
<p className="text-warm-600 mb-4">
Claude Code werkt altijd binnen een projectmap. Maak vooraf een map aan waar je tijdens de workshop in gaat werken.
</p>
<ol className="space-y-4 text-warm-600 mb-6">
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">1</span>
<div>
<p>
Maak een map aan met de naam{' '}
<code className="bg-warm-100 text-warm-800 px-1.5 py-0.5 rounded text-sm font-mono">Projecten</code>
{' '}in je Documenten (via Finder op Mac of Verkenner op Windows)
</p>
</div>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">2</span>
<div>
<p>
Open deze map in Claude Code via het mapicoon rechtsboven
(of <code className="bg-warm-100 text-warm-800 px-1.5 py-0.5 rounded text-sm font-mono">Cmd+O</code> op Mac,{' '}
<code className="bg-warm-100 text-warm-800 px-1.5 py-0.5 rounded text-sm font-mono">Ctrl+O</code> op Windows)
</p>
</div>
</li>
</ol>
{/* Waarschuwing over toegang */}
<div className="bg-warm-100 border border-warm-200 rounded-lg px-4 py-3">
<p className="text-warm-700 font-semibold">Let op: werk altijd in een aparte projectmap</p>
<p className="text-warm-600 text-sm mt-1">
Geef Claude Code niet zomaar toegang tot je hele computer. Claude kan bestanden maken, aanpassen en verwijderen.
Door in een aparte map te werken, beperk je wat Claude kan doen. Je computer vraagt mogelijk of Claude toegang mag
tot "Documenten" - daarmee geef je alleen toegang tot de gekozen map, niet tot alles in Documenten.
</p>
</div>
</div>
</section>
{/* Sectie 7: Verificatie */}
<section className="mb-10">
<h2 className="heading-2 mb-4">Controleer of alles werkt</h2>
<div className="card">
<p className="text-warm-600 mb-4">
Typ in Claude Code:{' '}
<code className="bg-warm-100 text-warm-800 px-1.5 py-0.5 rounded text-sm font-mono">Hallo Claude, vertel me waar ik ben.</code>
</p>
<p className="text-warm-600 mb-6">
Claude antwoordt met informatie over je huidige werkmap. Dit bevestigt dat alles werkt.
</p>
<div className="border-t border-warm-200 pt-4">
<p className="font-semibold text-warm-800 mb-3">Checklist:</p>
<ul className="space-y-2">
<li className="flex gap-2 text-warm-600">
<svg className="w-5 h-5 text-teal-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Claude abonnement actief</span>
</li>
<li className="flex gap-2 text-warm-600">
<svg className="w-5 h-5 text-teal-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Desktop app geinstalleerd en geopend</span>
</li>
<li className="flex gap-2 text-warm-600">
<svg className="w-5 h-5 text-teal-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Ingelogd en overgeschakeld naar Claude Code</span>
</li>
<li className="flex gap-2 text-warm-600">
<svg className="w-5 h-5 text-teal-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Projecten-map aangemaakt en geopend in Claude Code</span>
</li>
{activeOS === 'windows' && (
<li className="flex gap-2 text-warm-600">
<svg className="w-5 h-5 text-teal-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Git geinstalleerd en computer herstart</span>
</li>
)}
</ul>
</div>
</div>
</section>
{/* Sectie 8: Problemen oplossen */}
<section className="mb-10">
<h2 className="heading-2 mb-4">Problemen oplossen</h2>
<div className="space-y-4">
<div className="card">
<p className="font-semibold text-warm-900 mb-2">App start niet na installatie</p>
<ul className="list-disc pl-6 space-y-1 text-warm-600">
<li><strong>Mac:</strong> Check of je de app naar Applications hebt gesleept</li>
<li><strong>Windows:</strong> Probeer de installer opnieuw als administrator uit te voeren</li>
</ul>
</div>
<div className="card">
<p className="font-semibold text-warm-900 mb-2">Kan niet inloggen</p>
<ul className="list-disc pl-6 space-y-1 text-warm-600">
<li>Check of je een Claude Pro of Max account hebt (gratis werkt niet)</li>
<li>Probeer uit te loggen en opnieuw in te loggen in je browser</li>
</ul>
</div>
{activeOS === 'windows' && (
<div className="card">
<p className="font-semibold text-warm-900 mb-2">Foutmelding over Git in de desktop app</p>
<ul className="list-disc pl-6 space-y-1 text-warm-600">
<li>Installeer Git via de stappen hierboven</li>
<li>Start je computer opnieuw op na de Git-installatie</li>
<li>Open daarna pas de Claude Code app weer</li>
</ul>
</div>
)}
<div className="card">
<p className="font-semibold text-warm-900 mb-2">Geen project directory kunnen kiezen</p>
<ul className="list-disc pl-6 space-y-1 text-warm-600">
<li>Zorg dat de Projecten-map bestaat en dat je leesrechten hebt</li>
<li>Maak een nieuwe lege map in je Documenten als het niet lukt</li>
</ul>
</div>
</div>
</section>
</main>
{/* Contact */}
<div className="text-center py-6">
@ -501,8 +77,20 @@ function Installatie() {
</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>
</main>
}>
<MarkdownEditor
content={content}
onContentChange={setContent}
/>
</Suspense>
)}
{/* Footer */}
<footer className="bg-warm-900 text-warm-400 py-6">

169
vite-plugin-editor-api.js Normal file
View file

@ -0,0 +1,169 @@
/**
* vite-plugin-editor-api.js - Vite dev server plugin voor de Markdown editor
*
* Biedt API endpoints voor het opslaan van Markdown en het uploaden van afbeeldingen.
* Alleen actief tijdens development (npm run dev), niet in productie builds.
*
* Endpoints:
* - POST /__editor/save - Slaat Markdown content op naar content/installatie.md
* - POST /__editor/upload - Upload een afbeelding naar public/images/installatie/
* - GET /__editor/images - Lijst beschikbare afbeeldingen
*/
/* eslint-disable no-undef */
import fs from 'fs';
import path from 'path';
export function editorApiPlugin() {
return {
name: 'editor-api',
configureServer(server) {
const rootDir = server.config.root;
// POST /__editor/save - Markdown content opslaan
server.middlewares.use('/__editor/save', (req, res, next) => {
if (req.method !== 'POST') return next();
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const { content } = JSON.parse(body);
const filePath = path.join(rootDir, 'content', 'installatie.md');
// Zorg dat de content directory bestaat
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, content, 'utf-8');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, path: filePath }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
});
// POST /__editor/upload - Afbeelding uploaden
server.middlewares.use('/__editor/upload', (req, res, next) => {
if (req.method !== 'POST') return next();
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const boundary = req.headers['content-type'].split('boundary=')[1];
const { filename, fileData } = parseMultipart(buffer, boundary);
if (!filename) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Geen bestand gevonden' }));
return;
}
// Valideer bestandstype
const ext = path.extname(filename).toLowerCase();
const allowed = ['.png', '.jpg', '.jpeg', '.gif', '.webp'];
if (!allowed.includes(ext)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Bestandstype ${ext} niet toegestaan` }));
return;
}
// Bepaal bestandsnaam (voeg timestamp toe bij duplicaat)
const imageDir = path.join(rootDir, 'public', 'images', 'installatie');
if (!fs.existsSync(imageDir)) {
fs.mkdirSync(imageDir, { recursive: true });
}
let finalName = filename;
const destPath = path.join(imageDir, finalName);
if (fs.existsSync(destPath)) {
const base = path.basename(filename, ext);
const timestamp = Date.now();
finalName = `${base}-${timestamp}${ext}`;
}
fs.writeFileSync(path.join(imageDir, finalName), fileData);
const relativePath = `images/installatie/${finalName}`;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, path: relativePath, filename: finalName }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
});
// GET /__editor/images - Lijst beschikbare afbeeldingen
server.middlewares.use('/__editor/images', (req, res, next) => {
if (req.method !== 'GET') return next();
try {
const imageDir = path.join(rootDir, 'public', 'images', 'installatie');
if (!fs.existsSync(imageDir)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ images: [] }));
return;
}
const files = fs.readdirSync(imageDir)
.filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f))
.map(f => ({
filename: f,
path: `images/installatie/${f}`,
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ images: files }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
},
};
}
/**
* Simpele multipart/form-data parser.
* Extraheert het eerste bestand uit de multipart body.
*/
function parseMultipart(buffer, boundary) {
const boundaryBuffer = Buffer.from(`--${boundary}`);
const parts = [];
let start = 0;
while (true) {
const idx = buffer.indexOf(boundaryBuffer, start);
if (idx === -1) break;
if (start > 0) {
// Verwijder de trailing \r\n voor de boundary
parts.push(buffer.slice(start, idx - 2));
}
start = idx + boundaryBuffer.length + 2; // Skip boundary + \r\n
}
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const headers = part.slice(0, headerEnd).toString('utf-8');
const filenameMatch = headers.match(/filename="(.+?)"/);
if (filenameMatch) {
return {
filename: filenameMatch[1],
fileData: part.slice(headerEnd + 4),
};
}
}
return { filename: null, fileData: null };
}

View file

@ -1,9 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { editorApiPlugin } from './vite-plugin-editor-api.js'
// https://vite.dev/config/
// base path alleen voor production build, lokaal blijft het op /
// editorApiPlugin alleen actief in dev mode voor de Markdown editor
export default defineConfig(({ command }) => ({
plugins: [react()],
plugins: [
react(),
command === 'serve' ? editorApiPlugin() : null,
].filter(Boolean),
base: command === 'build' ? '/workshopclaudecode/' : '/',
}))