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:
parent
4ab4f7e4f2
commit
fb52af979a
20 changed files with 1832 additions and 475 deletions
236
content/installatie.md
Normal file
236
content/installatie.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
:::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.
|
||||

|
||||
|
||||

|
||||
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.
|
||||
|
||||

|
||||
|
||||
5. Tegelijk opent je Finder en zie je het bestand *Install Certificates.command*. Dubbelklik hier op.
|
||||
|
||||

|
||||
:::
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
:::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*"
|
||||
|
||||

|
||||
|
||||
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}
|
||||

|
||||
:::
|
||||
:::image{os=windows}
|
||||

|
||||
:::
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
13
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
BIN
public/images/installatie/git-install.png
Normal file
BIN
public/images/installatie/git-install.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
BIN
public/images/installatie/git-time.png
Normal file
BIN
public/images/installatie/git-time.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
BIN
public/images/installatie/python-install-certificates.png
Normal file
BIN
public/images/installatie/python-install-certificates.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 383 KiB |
BIN
public/images/installatie/python-install-warning.png
Normal file
BIN
public/images/installatie/python-install-warning.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 445 KiB |
BIN
public/images/installatie/python-self-installer.png
Normal file
BIN
public/images/installatie/python-self-installer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
107
src/components/editor/EditorToolbar.jsx
Normal file
107
src/components/editor/EditorToolbar.jsx
Normal 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"><></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">🔗</button>
|
||||
<button onClick={() => applyFormat('')} title="Afbeelding" className="px-2.5 py-1.5 rounded text-warm-700 hover:bg-warm-200 hover:text-warm-900 transition-colors">📷</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">•</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>
|
||||
);
|
||||
}
|
||||
142
src/components/editor/ImageDropZone.jsx
Normal file
142
src/components/editor/ImageDropZone.jsx
Normal 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 = ``;
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
199
src/components/editor/MarkdownEditor.jsx
Normal file
199
src/components/editor/MarkdownEditor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
src/components/editor/useKeyboardShortcuts.js
Normal file
161
src/components/editor/useKeyboardShortcuts.js
Normal 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
12
src/config/editor.js
Normal 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,
|
||||
};
|
||||
|
|
@ -8,6 +8,6 @@
|
|||
|
||||
export const WORKSHOP_CONFIG = {
|
||||
totalSpots: 8,
|
||||
availableSpots: 1,
|
||||
isSoldOut: false,
|
||||
availableSpots: 0,
|
||||
isSoldOut: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
355
src/lib/markdown/directives.js
Normal file
355
src/lib/markdown/directives.js
Normal 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}
|
||||
* 
|
||||
* :::
|
||||
* 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
326
src/lib/markdown/render.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,476 +55,43 @@ 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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
</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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-warm-900 text-warm-400 py-6">
|
||||
<div className="container-page text-center text-sm">
|
||||
|
|
|
|||
169
vite-plugin-editor-api.js
Normal file
169
vite-plugin-editor-api.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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/' : '/',
|
||||
}))
|
||||
|
|
|
|||
Loading…
Reference in a new issue