Initial commit

This commit is contained in:
Ubuntu
2026-03-01 13:23:49 +00:00
commit a8e52093aa
1485 changed files with 453467 additions and 0 deletions

View File

@@ -0,0 +1,459 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from './components/Header.jsx';
import { Card } from './components/Card.jsx';
import { Button } from './components/Button.jsx';
import { PageTransition } from './components/PageTransition.jsx';
import {
getCarsFolder,
loadCars,
resetCarsFolder,
saveCars,
selectCarsFolder,
} from './carStorage.js';
// Admin interface to add, edit, and remove car size presets.
export default function CarManager() {
const navigate = useNavigate();
const [options, setOptions] = useState([]);
const [addForm, setAddForm] = useState({
name: '',
width: '',
height: '',
hasRear: false,
rearWidth: '',
rearHeight: '',
});
const [editName, setEditName] = useState('');
const [editForm, setEditForm] = useState({
name: '',
width: '',
height: '',
hasRear: false,
rearWidth: '',
rearHeight: '',
});
const [deleteName, setDeleteName] = useState('');
const [password, setPassword] = useState('');
const [authenticated, setAuthenticated] = useState(false);
const [error, setError] = useState('');
const [toast, setToast] = useState(false);
const [carsFolder, setCarsFolder] = useState('');
const [folderMessage, setFolderMessage] = useState('');
const applyCars = (cars) => {
const sorted = [...cars].sort((a, b) => a.name.localeCompare(b.name));
setOptions(sorted);
setEditName(sorted[0]?.name || '');
setDeleteName(sorted[0]?.name || '');
};
useEffect(() => {
loadCars().then((opts) => {
applyCars(opts);
});
getCarsFolder().then((folder) => {
setCarsFolder(folder || '');
});
}, []);
useEffect(() => {
const car = options.find((o) => o.name === editName);
if (car) {
setEditForm({
name: car.name,
width: String(car.width),
height: String(car.height),
hasRear: Boolean(car.rearWidth && car.rearHeight),
rearWidth: car.rearWidth ? String(car.rearWidth) : '',
rearHeight: car.rearHeight ? String(car.rearHeight) : '',
});
}
}, [editName, options]);
useEffect(() => {
setDeleteName((dn) =>
options.find((o) => o.name === dn) ? dn : options[0]?.name || ''
);
}, [options]);
useEffect(() => {
if (toast) {
const t = setTimeout(() => setToast(false), 2000);
return () => clearTimeout(t);
}
}, [toast]);
useEffect(() => {
if (folderMessage) {
const t = setTimeout(() => setFolderMessage(''), 2500);
return () => clearTimeout(t);
}
}, [folderMessage]);
const handleAddChange = (e) => {
const { name, value, type, checked } = e.target;
setAddForm((fd) => ({
...fd,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleEditChange = (e) => {
const { name, value, type, checked } = e.target;
setEditForm((fd) => ({
...fd,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleAddSubmit = async (e) => {
e.preventDefault();
const newCar = {
name: addForm.name,
width: parseFloat(addForm.width),
height: parseFloat(addForm.height),
};
if (addForm.hasRear) {
newCar.rearWidth = parseFloat(addForm.rearWidth);
newCar.rearHeight = parseFloat(addForm.rearHeight);
}
const newOptions = [...options, newCar].sort((a, b) =>
a.name.localeCompare(b.name)
);
setOptions(newOptions);
await saveCars(newOptions);
setAddForm({
name: '',
width: '',
height: '',
hasRear: false,
rearWidth: '',
rearHeight: '',
});
setToast(true);
};
const handleEditSubmit = async (e) => {
e.preventDefault();
const updatedCar = {
name: editForm.name,
width: parseFloat(editForm.width),
height: parseFloat(editForm.height),
};
if (editForm.hasRear) {
updatedCar.rearWidth = parseFloat(editForm.rearWidth);
updatedCar.rearHeight = parseFloat(editForm.rearHeight);
}
const newOptions = options
.map((o) => (o.name === editName ? updatedCar : o))
.sort((a, b) => a.name.localeCompare(b.name));
setOptions(newOptions);
setEditName(updatedCar.name);
await saveCars(newOptions);
setToast(true);
};
const handleDeleteSubmit = async (e) => {
e.preventDefault();
const newOptions = options.filter((o) => o.name !== deleteName);
setOptions(newOptions);
setDeleteName(newOptions[0]?.name || '');
await saveCars(newOptions);
setToast(true);
};
const handleAuthSubmit = (e) => {
e.preventDefault();
if (password === 'Rotterdam-010') {
setAuthenticated(true);
setPassword('');
setError('');
} else {
setError('Onjuist wachtwoord');
}
};
const handleSelectFolder = async () => {
const result = await selectCarsFolder();
if (!result || result.canceled) {
setFolderMessage('Geen map gekozen');
return;
}
setCarsFolder(result.folder || '');
if (result.cars) {
applyCars(result.cars);
} else {
const refreshed = await loadCars();
applyCars(refreshed);
}
setFolderMessage('Opslaglocatie bijgewerkt');
};
const handleResetFolder = async () => {
const result = await resetCarsFolder();
if (result?.folder) {
setCarsFolder(result.folder);
}
if (result?.cars) {
applyCars(result.cars);
} else {
const refreshed = await loadCars();
applyCars(refreshed);
}
setFolderMessage('Standaardmap ingesteld');
};
return (
<PageTransition>
<Header
count={options.length}
title="Kinderauto's beheren"
onHome={() => navigate('/')}
/>
{!authenticated ? (
<div className="container mt-6">
<Card title="Toegang vereist">
<form onSubmit={handleAuthSubmit} className="mb-lg">
<div className="form-row">
<label htmlFor="managerPassword">Wachtwoord:</label>
<input
id="managerPassword"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" variant="primary" className="mt">Inloggen</Button>
</form>
</Card>
</div>
) : (
<div className="container mt-6">
<aside className="side-cards">
<Card title="Opslaglocatie">
<p className="text-sm">
Huidige map: <strong>{carsFolder || 'Standaard'}</strong>
</p>
<div className="button-row mt">
<Button type="button" variant="primary" onClick={handleSelectFolder}>
Kies map
</Button>
<Button type="button" variant="ghost" onClick={handleResetFolder}>
Standaardmap
</Button>
</div>
{folderMessage && <p className="text-sm mt">{folderMessage}</p>}
</Card>
<Card title="Kinderauto toevoegen">
<form onSubmit={handleAddSubmit} className="mb-lg">
<div className="form-row">
<label htmlFor="addName">Naam:</label>
<input
id="addName"
name="name"
value={addForm.name}
onChange={handleAddChange}
required
/>
</div>
<div className="form-row">
<label htmlFor="addWidth">Breedte (cm):</label>
<input
id="addWidth"
type="number"
step="0.1"
min="0"
name="width"
value={addForm.width}
onChange={handleAddChange}
required
/>
</div>
<div className="form-row">
<label htmlFor="addHeight">Hoogte (cm):</label>
<input
id="addHeight"
type="number"
step="0.1"
min="0"
name="height"
value={addForm.height}
onChange={handleAddChange}
required
/>
</div>
<div className="form-row checkbox">
<label>
<input
type="checkbox"
name="hasRear"
checked={addForm.hasRear}
onChange={handleAddChange}
/>
Achter heeft andere maat
</label>
</div>
{addForm.hasRear && (
<>
<div className="form-row">
<label htmlFor="addRearWidth">Breedte achter (cm):</label>
<input
id="addRearWidth"
type="number"
step="0.1"
min="0"
name="rearWidth"
value={addForm.rearWidth}
onChange={handleAddChange}
required
/>
</div>
<div className="form-row">
<label htmlFor="addRearHeight">Hoogte achter (cm):</label>
<input
id="addRearHeight"
type="number"
step="0.1"
min="0"
name="rearHeight"
value={addForm.rearHeight}
onChange={handleAddChange}
required
/>
</div>
</>
)}
<Button type="submit" variant="primary" className="mt">
Opslaan
</Button>
</form>
</Card>
<Card title="Kinderauto bewerken">
<form onSubmit={handleEditSubmit} className="mb-lg">
<div className="form-row">
<label htmlFor="editSelect">Selecteer:</label>
<select
id="editSelect"
value={editName}
onChange={(e) => setEditName(e.target.value)}
>
{options.map((opt) => (
<option key={opt.name} value={opt.name}>
{opt.name}
</option>
))}
</select>
</div>
<div className="form-row">
<label htmlFor="editName">Naam:</label>
<input
id="editName"
name="name"
value={editForm.name}
onChange={handleEditChange}
required
/>
</div>
<div className="form-row">
<label htmlFor="editWidth">Breedte (cm):</label>
<input
id="editWidth"
type="number"
step="0.1"
min="0"
name="width"
value={editForm.width}
onChange={handleEditChange}
required
/>
</div>
<div className="form-row">
<label htmlFor="editHeight">Hoogte (cm):</label>
<input
id="editHeight"
type="number"
step="0.1"
min="0"
name="height"
value={editForm.height}
onChange={handleEditChange}
required
/>
</div>
<div className="form-row checkbox">
<label>
<input
type="checkbox"
name="hasRear"
checked={editForm.hasRear}
onChange={handleEditChange}
/>
Achter heeft andere maat
</label>
</div>
{editForm.hasRear && (
<>
<div className="form-row">
<label htmlFor="editRearWidth">Breedte achter (cm):</label>
<input
id="editRearWidth"
type="number"
step="0.1"
min="0"
name="rearWidth"
value={editForm.rearWidth}
onChange={handleEditChange}
required
/>
</div>
<div className="form-row">
<label htmlFor="editRearHeight">Hoogte achter (cm):</label>
<input
id="editRearHeight"
type="number"
step="0.1"
min="0"
name="rearHeight"
value={editForm.rearHeight}
onChange={handleEditChange}
required
/>
</div>
</>
)}
<Button type="submit" variant="primary" className="mt">
Bijwerken
</Button>
</form>
</Card>
<Card title="Kinderauto verwijderen">
<form onSubmit={handleDeleteSubmit} className="mb-lg">
<div className="form-row">
<label htmlFor="deleteSelect">Selecteer:</label>
<select
id="deleteSelect"
value={deleteName}
onChange={(e) => setDeleteName(e.target.value)}
>
{options.map((opt) => (
<option key={opt.name} value={opt.name}>
{opt.name}
</option>
))}
</select>
</div>
<Button type="submit" variant="ghost" className="mt">
Verwijder
</Button>
</form>
</Card>
</aside>
</div>
)}
{toast && <div className="toast">Opgeslagen</div>}
</PageTransition>
);
}

View File

@@ -0,0 +1,30 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from './components/Header.jsx';
import { Button } from './components/Button.jsx';
import { PageTransition } from './components/PageTransition.jsx';
import { loadCars } from './carStorage.js';
export default function LandingPage() {
const navigate = useNavigate();
const [count, setCount] = useState(0);
useEffect(() => {
loadCars().then((opts) => setCount(opts.length));
}, []);
return (
<PageTransition>
<Header count={count} />
<div className="start-container">
<div className="card start-card">
<Button onClick={() => navigate('/generate')} variant="primary">
Kentekens genereren
</Button>
<Button onClick={() => navigate('/manage')} variant="ghost">
Kinderauto beheren
</Button>
</div>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,597 @@
import React, { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Header } from './components/Header.jsx';
import { Button } from './components/Button.jsx';
import { PageTransition } from './components/PageTransition.jsx';
import { useNavigate } from 'react-router-dom';
import dutchPlate from './assets/kentekentv.png';
import belgianPlate from './assets/belgischekentekenv2.png';
import germanPlate from './assets/duitskenteken.png';
import dutchFlag from './assets/dutchflag.png';
import belgiumFlag from './assets/belgiumflag.png';
import germanFlag from './assets/duitsevlag.svg';
import logolangLogo from './assets/logolang.png';
import kentekenFontUrl from './assets/Kenteken.ttf';
import { loadCars } from './carStorage.js';
// Browsers assume 96 DPI when printing images on A4 paper. Converting
// centimeters with this resolution ensures the generated plates have the
// intended physical dimensions when downloaded or printed.
export const DEFAULT_DPI = 96;
export function mmToPx(mm, dpi = DEFAULT_DPI) {
return (mm / 25.4) * dpi;
}
export function cmToPx(cm, dpi = DEFAULT_DPI) {
return mmToPx(cm * 10, dpi);
}
export default function LicensePlateApp() {
const [options, setOptions] = useState([]);
const [plates, setPlates] = useState([]);
const [previews, setPreviews] = useState([]);
const frontRefs = useRef([]);
const rearRefs = useRef([]);
const navigate = useNavigate();
const countryOptions = [
{ code: 'NL', label: 'Nederland', flag: dutchFlag, alt: 'Nederlandse vlag' },
{ code: 'BE', label: 'België', flag: belgiumFlag, alt: 'Belgische vlag' },
{ code: 'DE', label: 'Duitsland', flag: germanFlag, alt: 'Duitse vlag' },
];
useEffect(() => {
loadCars().then((opts) => {
const sorted = [...opts].sort((a, b) => a.name.localeCompare(b.name));
setOptions(sorted);
setPlates([
{
carName: sorted[0]?.name || '',
front: 'AB-123-CD',
rear: 'AB-123-CD',
country: 'NL',
},
]);
setPreviews([{ front: '', rear: '' }]);
});
}, []);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const measureTextWidth = (text, size, fontWeightValue) => {
ctx.font = `${fontWeightValue} ${size}px KentekenFont, monospace`;
return ctx.measureText(text).width;
};
const getFontSize = (text, baseFontSize, maxTextWidth, fontWeightValue) => {
let size = baseFontSize;
const processed = text.replace(/ /g, '\u00A0');
while (
measureTextWidth(processed, size, fontWeightValue) > maxTextWidth &&
size > 1
) {
size -= 1;
}
return size;
};
const svgToPng = async (svg) => {
if (!svg) return '';
await document.fonts.ready;
const clone = svg.cloneNode(true);
const images = clone.querySelectorAll('image');
await Promise.all(
Array.from(images).map(async (img) => {
const href = img.getAttribute('href');
const response = await fetch(href);
const blob = await response.blob();
const dataUrl = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
img.setAttribute('href', dataUrl);
})
);
const fontResponse = await fetch(kentekenFontUrl);
const fontBlob = await fontResponse.blob();
const fontDataUrl = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(fontBlob);
});
const style = document.createElement('style');
style.textContent = `@font-face { font-family: 'KentekenFont'; src: url(${fontDataUrl}) format('truetype'); }`;
clone.insertBefore(style, clone.firstChild);
const xml = new XMLSerializer().serializeToString(clone);
const svg64 = btoa(unescape(encodeURIComponent(xml)));
const image64 = `data:image/svg+xml;base64,${svg64}`;
return await new Promise((resolve) => {
const imgEl = new Image();
imgEl.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = imgEl.width;
canvas.height = imgEl.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgEl, 0, 0);
resolve(canvas.toDataURL('image/png'));
};
imgEl.src = image64;
});
};
const downloadCombinedPlates = async () => {
const frontUrls = [];
const rearUrls = [];
const dims = [];
for (let i = 0; i < plates.length; i++) {
const car =
options.find((o) => o.name === plates[i].carName) || options[0];
const hasRear = car.rearWidth && car.rearHeight;
const frontWidth = cmToPx(car.width);
const frontHeight = cmToPx(car.height);
const rearWidth = hasRear ? cmToPx(car.rearWidth) : frontWidth;
const rearHeight = hasRear ? cmToPx(car.rearHeight) : frontHeight;
frontUrls.push(await svgToPng(frontRefs.current[i]));
rearUrls.push(await svgToPng(rearRefs.current[i]));
dims.push({ frontWidth, frontHeight, rearWidth, rearHeight });
}
const a4Width = cmToPx(21);
const a4Height = cmToPx(29.7);
const margin = cmToPx(1.5);
const gap = 15;
const maxWidth = Math.max(
...dims.map((d) => Math.max(d.frontWidth, d.rearWidth))
);
const totalHeight = dims.reduce(
(sum, d) => sum + d.frontHeight + d.rearHeight,
0
);
const nImages = dims.length * 2;
const scale = Math.min(
1,
(a4Width - margin) / maxWidth,
(a4Height - gap * (nImages - 1)) / totalHeight
);
const canvas = document.createElement('canvas');
canvas.width = a4Width;
canvas.height = a4Height;
const ctx = canvas.getContext('2d');
const loadImage = (url) =>
new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.src = url;
});
let y = 0;
for (let i = 0; i < plates.length; i++) {
const fImg = await loadImage(frontUrls[i]);
ctx.drawImage(
fImg,
margin,
y,
fImg.width * scale,
fImg.height * scale
);
y += dims[i].frontHeight * scale + gap;
const rImg = await loadImage(rearUrls[i]);
ctx.drawImage(
rImg,
margin,
y,
rImg.width * scale,
rImg.height * scale
);
y += dims[i].rearHeight * scale;
if (i < plates.length - 1) y += gap;
}
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.download = 'kentekens.png';
a.href = dataUrl;
a.click();
};
const openPrintPreview = async () => {
const items = [];
for (let i = 0; i < plates.length; i++) {
const car =
options.find((o) => o.name === plates[i].carName) || options[0];
const hasRear = car.rearWidth && car.rearHeight;
const frontWidth = car.width * 10;
const frontHeight = car.height * 10;
const rearWidth = hasRear ? car.rearWidth * 10 : frontWidth;
const rearHeight = hasRear ? car.rearHeight * 10 : frontHeight;
const frontUrl = await svgToPng(frontRefs.current[i]);
const rearUrl = await svgToPng(rearRefs.current[i]);
items.push({ src: frontUrl, width: frontWidth, height: frontHeight });
items.push({ src: rearUrl, width: rearWidth, height: rearHeight });
}
navigate('/print', { state: { items } });
};
useEffect(() => {
const renderPreviews = async () => {
const newPreviews = [];
for (let i = 0; i < plates.length; i++) {
const frontUrl = frontRefs.current[i]
? await svgToPng(frontRefs.current[i])
: '';
const rearUrl = rearRefs.current[i]
? await svgToPng(rearRefs.current[i])
: '';
newPreviews.push({ front: frontUrl, rear: rearUrl });
}
setPreviews(newPreviews);
};
renderPreviews();
}, [plates, options]);
const addPlate = () => {
setPlates((pls) => [
...pls,
{
carName: options[0]?.name || '',
front: 'AB-123-CD',
rear: 'AB-123-CD',
country: 'NL',
},
]);
setPreviews((prv) => [...prv, { front: '', rear: '' }]);
};
const updatePlate = (index, field, value) => {
setPlates((pls) => {
const updated = [...pls];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const removePlate = (index) => {
setPlates((pls) => pls.filter((_, i) => i !== index));
setPreviews((prv) => prv.filter((_, i) => i !== index));
frontRefs.current.splice(index, 1);
rearRefs.current.splice(index, 1);
};
return (
<PageTransition>
<Header
count={options.length}
onRestart={() => window.location.reload()}
onHome={() => navigate('/')}
/>
<div className="container dashboard">
<div className="card">
{plates.map((plate, i) => {
const car = options.find((o) => o.name === plate.carName) || options[0];
const hasRear = car.rearWidth && car.rearHeight;
const frontWidth = cmToPx(car.width);
const frontHeight = cmToPx(car.height);
const rearWidthPx = hasRear ? cmToPx(car.rearWidth) : frontWidth;
const rearHeightPx = hasRear ? cmToPx(car.rearHeight) : frontHeight;
const country = plate.country;
const countryConfig = {
NL: {
baseWidth: 624,
baseHeight: 139,
plateImage: dutchPlate,
flag: dutchFlag,
euWidthRatio: 0.096,
fontWeight: 'bold',
textColor: '#000',
shadowColor: '#ffeb3b',
dropShadow: { dx: 3, dy: 3, stdDeviation: 1 },
hasBottomBar: true,
allowsCentering: true,
},
BE: {
baseWidth: 1848,
baseHeight: 382,
plateImage: belgianPlate,
flag: belgiumFlag,
euWidthRatio: 0.098,
fontWeight: 'normal',
textColor: '#a51f1f',
shadowColor: '#a51f1f',
dropShadow: { dx: 0.5, dy: 0.5, stdDeviation: 0.2 },
hasBottomBar: false,
allowsCentering: true,
},
DE: {
baseWidth: 1668,
baseHeight: 360,
plateImage: germanPlate,
flag: germanFlag,
euWidthRatio: 0.11,
fontWeight: 'bold',
textColor: '#000',
shadowColor: 'transparent',
dropShadow: { dx: 0, dy: 0, stdDeviation: 0 },
hasBottomBar: false,
allowsCentering: true,
},
};
const selectedCountry = countryConfig[country] || countryConfig.NL;
const {
baseWidth,
baseHeight,
plateImage,
euWidthRatio,
fontWeight,
textColor,
shadowColor,
dropShadow,
hasBottomBar,
allowsCentering,
} = selectedCountry;
const euWidth = baseWidth * euWidthRatio;
const baseFontSize = baseHeight * 0.6;
const blackHeight = hasBottomBar ? baseHeight * 0.1 : 0;
const logoHeight = blackHeight * 0.9;
const logoWidth = logoHeight * (1481 / 240);
const logoX = (baseWidth - logoWidth) / 2;
const logoY =
baseHeight - blackHeight + (blackHeight - logoHeight) / 2;
const maxTextWidth = baseWidth - euWidth - 20;
const fontWeightValue = fontWeight;
const textY = hasBottomBar
? (baseHeight - blackHeight) / 2
: baseHeight / 2;
const maxCenteredTextWidth = baseWidth - 2 * (euWidth + 10);
const textShadowValue =
dropShadow.stdDeviation === 0
? 'none'
: `${dropShadow.dx}px ${dropShadow.dy}px ${dropShadow.stdDeviation}px ${shadowColor}`;
const frontFontSize = getFontSize(
plate.front,
baseFontSize,
maxTextWidth,
fontWeightValue
);
const rearFontSize = getFontSize(
plate.rear,
baseFontSize,
maxTextWidth,
fontWeightValue
);
const frontTextWidth = measureTextWidth(
plate.front.replace(/ /g, '\u00A0'),
frontFontSize,
fontWeightValue
);
const rearTextWidth = measureTextWidth(
plate.rear.replace(/ /g, '\u00A0'),
rearFontSize,
fontWeightValue
);
const shouldCenterFront =
allowsCentering && frontTextWidth <= maxCenteredTextWidth;
const shouldCenterRear =
allowsCentering && rearTextWidth <= maxCenteredTextWidth;
const frontTextAnchor =
allowsCentering && (country === 'BE' || shouldCenterFront)
? 'middle'
: 'start';
const rearTextAnchor =
allowsCentering && (country === 'BE' || shouldCenterRear)
? 'middle'
: 'start';
const frontTextX =
country === 'BE'
? euWidth + (baseWidth - euWidth) / 2
: shouldCenterFront
? baseWidth / 2
: euWidth + 10;
const rearTextX =
country === 'BE'
? euWidth + (baseWidth - euWidth) / 2
: shouldCenterRear
? baseWidth / 2
: euWidth + 10;
return (
<div key={i} className="plate-block">
<div className="flex-1">
<div
className="flag-selector mb"
role="group"
aria-label="Kies land voor kenteken"
>
{countryOptions.map((option) => (
<button
key={option.code}
type="button"
onClick={() => updatePlate(i, 'country', option.code)}
className={`flag-option ${
country === option.code ? 'selected' : ''
}`}
>
<img src={option.flag} alt={option.alt} />
<span>{option.label}</span>
</button>
))}
</div>
<div className="form-row mb">
<label htmlFor={`car-${i}`}>Kinderauto:</label>
<select
id={`car-${i}`}
value={plate.carName}
onChange={(e) =>
updatePlate(i, 'carName', e.target.value)
}
>
{options.map((opt) => (
<option key={opt.name} value={opt.name}>
{opt.name}
</option>
))}
</select>
</div>
<div className="form-row mb">
<label htmlFor={`front-${i}`}>{`Kenteken voor ${i + 1}:`}</label>
<input
id={`front-${i}`}
value={plate.front}
onChange={(e) =>
updatePlate(i, 'front', e.target.value.toUpperCase())
}
aria-label={`Plate text front ${i + 1}`}
className="font-[KentekenFont] uppercase"
/>
</div>
<div className="form-row mb">
<label htmlFor={`rear-${i}`}>{`Kenteken achter ${i + 1}:`}</label>
<input
id={`rear-${i}`}
value={plate.rear}
onChange={(e) =>
updatePlate(i, 'rear', e.target.value.toUpperCase())
}
aria-label={`Plate text rear ${i + 1}`}
className="font-[KentekenFont] uppercase"
/>
</div>
{plates.length > 1 && (
<Button
type="button"
onClick={() => removePlate(i)}
variant="ghost"
className="mt"
>
Verwijder kenteken
</Button>
)}
</div>
<div className="flex-1 flex flex-col items-center">
<svg
ref={(el) => (frontRefs.current[i] = el)}
width={frontWidth}
height={frontHeight}
className="hidden"
viewBox={`0 0 ${baseWidth} ${baseHeight}`}
>
<image href={plateImage} width={baseWidth} height={baseHeight} />
{country === 'NL' && (
<>
<rect
x="0"
y={baseHeight - blackHeight}
width={baseWidth}
height={blackHeight}
fill="#000"
/>
<image
href={logolangLogo}
x={logoX}
y={logoY}
width={logoWidth}
height={logoHeight}
/>
</>
)}
<text
x={frontTextX}
y={textY}
textAnchor={frontTextAnchor}
dominantBaseline="middle"
fontFamily="KentekenFont, monospace"
fontWeight={fontWeightValue}
fontSize={frontFontSize}
fill={textColor}
xmlSpace="preserve"
style={{
textShadow: textShadowValue,
}}
>
{plate.front.replace(/ /g, '\u00A0')}
</text>
</svg>
{previews[i] && previews[i].front && (
<motion.img
src={previews[i].front}
alt={`Kenteken voor ${i + 1}`}
className="block mt-4 max-w-full h-auto"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.25 }}
/>
)}
<svg
ref={(el) => (rearRefs.current[i] = el)}
width={rearWidthPx}
height={rearHeightPx}
className="hidden"
viewBox={`0 0 ${baseWidth} ${baseHeight}`}
>
<image href={plateImage} width={baseWidth} height={baseHeight} />
{country === 'NL' && (
<>
<rect
x="0"
y={baseHeight - blackHeight}
width={baseWidth}
height={blackHeight}
fill="#000"
/>
<image
href={logolangLogo}
x={logoX}
y={logoY}
width={logoWidth}
height={logoHeight}
/>
</>
)}
<text
x={rearTextX}
y={textY}
textAnchor={rearTextAnchor}
dominantBaseline="middle"
fontFamily="KentekenFont, monospace"
fontWeight={fontWeightValue}
fontSize={rearFontSize}
fill={textColor}
xmlSpace="preserve"
style={{
textShadow: textShadowValue,
}}
>
{plate.rear.replace(/ /g, '\u00A0')}
</text>
</svg>
{previews[i] && previews[i].rear && (
<motion.img
src={previews[i].rear}
alt={`Kenteken achter ${i + 1}`}
className="block mt-4 max-w-full h-auto"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.25 }}
/>
)}
</div>
</div>
);
})}
<Button type="button" onClick={addPlate} variant="primary" className="mt">
Nog een kenteken
</Button>
<div className="button-row">
<Button type="button" onClick={downloadCombinedPlates} variant="ghost">
Download kentekens
</Button>
<Button type="button" onClick={openPrintPreview} variant="ghost">
Print kentekens
</Button>
</div>
</div>
</div>
</PageTransition>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600" viewBox="0 0 5 3">
<desc>Flag of Germany</desc>
<rect id="black_stripe" width="5" height="3" y="0" x="0" fill="#000"/>
<rect id="red_stripe" width="5" height="2" y="1" x="0" fill="#D00"/>
<rect id="gold_stripe" width="5" height="1" y="2" x="0" fill="#FFCE00"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="120" viewBox="0 0 512 120">
<rect x="4.8" y="4.8" width="502.4" height="110.4" rx="21.6" ry="21.6" fill="#FCD116" stroke="#000" stroke-width="9.6"/>
<rect width="24" height="120" fill="#003399"/>
<g transform="translate(12,60)">
<g id="star">
<polygon points="0,-4 1.2,-1.236 3.804,-1.236 1.636,0.471 2.4,3.236 0,1.6 -2.4,3.236 -1.636,0.471 -3.804,-1.236 -1.2,-1.236" fill="#FFCC00"/>
</g>
<use href="#star" transform="rotate(0) translate(0,-8)"/>
<use href="#star" transform="rotate(30) translate(0,-8)"/>
<use href="#star" transform="rotate(60) translate(0,-8)"/>
<use href="#star" transform="rotate(90) translate(0,-8)"/>
<use href="#star" transform="rotate(120) translate(0,-8)"/>
<use href="#star" transform="rotate(150) translate(0,-8)"/>
<use href="#star" transform="rotate(180) translate(0,-8)"/>
<use href="#star" transform="rotate(210) translate(0,-8)"/>
<use href="#star" transform="rotate(240) translate(0,-8)"/>
<use href="#star" transform="rotate(270) translate(0,-8)"/>
<use href="#star" transform="rotate(300) translate(0,-8)"/>
<use href="#star" transform="rotate(330) translate(0,-8)"/>
</g>
<text x="12" y="96" font-family="sans-serif" font-size="42" font-weight="bold" fill="#ffffff" text-anchor="middle">NL</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,498 @@
[
{
"name": "Aprilia",
"width": 10,
"height": 6.5
},
{
"name": "AUDI E-TRON",
"width": 15,
"height": 3.3
},
{
"name": "AUDI HORCH",
"width": 22.5,
"height": 3.6
},
{
"name": "AUDI Q5 GROOT",
"width": 21.1,
"height": 4.7
},
{
"name": "AUDI R8",
"width": 12.7,
"height": 2.7
},
{
"name": "Audi R8 GROOT",
"width": 14.5,
"height": 3.7
},
{
"name": "AUDI RS-GT",
"width": 11,
"height": 2.6
},
{
"name": "AUDI RS6",
"width": 13.1,
"height": 2.9
},
{
"name": "Audi RSQ8",
"width": 13,
"height": 2.9
},
{
"name": "Bentley bentayga",
"width": 16.6,
"height": 3
},
{
"name": "BENTLEY BENTAYGA",
"width": 16.3,
"height": 3
},
{
"name": "Bentley Continental",
"width": 16.8,
"height": 4.2
},
{
"name": "Bentley EXP12",
"width": 14.4,
"height": 3.5
},
{
"name": "BMW I4 1 Persoons",
"width": 14.7,
"height": 2
},
{
"name": "BMW M5",
"width": 17.2,
"height": 3.5
},
{
"name": "BMW M5 Loopauto",
"width": 7.8,
"height": 1.6
},
{
"name": "BMW MOTOR",
"width": 13.5,
"height": 6
},
{
"name": "BMW POLITIE MOTOR",
"width": 10,
"height": 2.8
},
{
"name": "BMW X6 2-persoons",
"width": 19.3,
"height": 4.5
},
{
"name": "Brandweer/Politie Loopauto",
"width": 8,
"height": 3
},
{
"name": "Brandweerauto",
"width": 10,
"height": 2.5
},
{
"name": "BROTHERS JEEP",
"width": 16,
"height": 2.8
},
{
"name": "BUGATTI DIVO",
"width": 16.7,
"height": 3.8
},
{
"name": "BUGGY ALPHA 24V",
"width": 16,
"height": 4.7
},
{
"name": "BUGGY POLTIE/ZWART",
"width": 13.9,
"height": 9
},
{
"name": "Camo Buggy Groot",
"width": 14.2,
"height": 3.6
},
{
"name": "CAN-AM Maverick",
"width": 14,
"height": 2.5
},
{
"name": "CHINO tractor",
"width": 11.5,
"height": 2.5
},
{
"name": "DODGE POLITIE",
"width": 11,
"height": 4.5
},
{
"name": "Driftkart",
"width": 8,
"height": 2.9
},
{
"name": "Ducati crossmotor",
"width": 14.3,
"height": 5.9
},
{
"name": "FIAT 500C loopauto",
"width": 9,
"height": 2.5
},
{
"name": "FORD LOOPAUTO",
"width": 10,
"height": 2.6
},
{
"name": "g",
"width": 14.5,
"height": 3.5
},
{
"name": "G63 1-pers XL 24V",
"width": 15.1,
"height": 3.5
},
{
"name": "G63 2-persoon CHROOM",
"width": 16.8,
"height": 3.6
},
{
"name": "G63 XXL 2-p",
"width": 20.8,
"height": 4.2
},
{
"name": "GLADIATOR jeep",
"width": 12.3,
"height": 3.7
},
{
"name": "Heftruck",
"width": 17.5,
"height": 3.5
},
{
"name": "High SPEED BUGGY",
"width": 22,
"height": 5.2
},
{
"name": "Jaguar SVR",
"width": 14.2,
"height": 3
},
{
"name": "John Deere Ground Loader",
"width": 12,
"height": 3
},
{
"name": "Kleine G650 Nieuw mini",
"width": 13.8,
"height": 3
},
{
"name": "KLEINE Maybach G650",
"width": 15.1,
"height": 2.6
},
{
"name": "LAMBO Aventador 2-persoons",
"width": 15.2,
"height": 3.9
},
{
"name": "Lamborghin Aventador",
"width": 12.5,
"height": 3
},
{
"name": "LAMBORGHINI GT",
"width": 8,
"height": 1.8
},
{
"name": "LAMBORGHINI HURACAN",
"width": 12,
"height": 3
},
{
"name": "Lamborghini Huracan 2-persoons",
"width": 15.2,
"height": 3.1
},
{
"name": "Lamborghini Sian",
"width": 9.8,
"height": 2.7
},
{
"name": "LAMBORGHINI STO",
"width": 14.5,
"height": 2.6
},
{
"name": "Lamborghini SV",
"width": 12.5,
"height": 3
},
{
"name": "Lamborghini Urus",
"width": 20.2,
"height": 3.2,
"rearWidth": 15.4,
"rearHeight": 3.6
},
{
"name": "Lamborghini Urus KLEIN",
"width": 9.4,
"height": 2.6,
"rearWidth": 12,
"rearHeight": 2.9
},
{
"name": "LAMBORGHINI VENENO",
"width": 15,
"height": 2.5
},
{
"name": "LOOPAUTO C63",
"width": 6.6,
"height": 1.6
},
{
"name": "MACLAREN",
"width": 15.1,
"height": 3.9
},
{
"name": "Maseratti",
"width": 13.7,
"height": 2.6
},
{
"name": "MCLAREN QUAD",
"width": 10,
"height": 2.5
},
{
"name": "Mercedes 300S",
"width": 14,
"height": 4.4
},
{
"name": "Mercedes 300s loopauto",
"width": 11,
"height": 2.8
},
{
"name": "Mercedes 6x6 klein",
"width": 12,
"height": 2.2
},
{
"name": "Mercedes Actros",
"width": 10.8,
"height": 2.3,
"rearWidth": 20,
"rearHeight": 4
},
{
"name": "Mercedes C63s",
"width": 13.8,
"height": 2.9
},
{
"name": "Mercedes G63 24V 1 Persoons",
"width": 15.3,
"height": 3.5
},
{
"name": "Mercedes G63 6X6",
"width": 15.8,
"height": 3.8
},
{
"name": "Mercedes G63 6x6 2-Persoons",
"width": 18.7,
"height": 3.8
},
{
"name": "Mercedes G63 KLEIN",
"width": 13.3,
"height": 3.2
},
{
"name": "Mercedes G650 Maybach BIG",
"width": 23.2,
"height": 4.3
},
{
"name": "MERCEDES GLC 2-zits",
"width": 19,
"height": 4.2
},
{
"name": "Mercedes GLC63",
"width": 15.6,
"height": 3.2
},
{
"name": "Mercedes GTR",
"width": 13.1,
"height": 2.7,
"rearWidth": 13.3,
"rearHeight": 2.9
},
{
"name": "Mercedes Loopauto",
"width": 7.35,
"height": 1.65,
"rearWidth": 9.6,
"rearHeight": 1.5
},
{
"name": "Mercedes M-CLASS",
"width": 15.6,
"height": 3.2
},
{
"name": "Mercedes S klasse",
"width": 16,
"height": 3.35
},
{
"name": "Mercedes Unimog",
"width": 15,
"height": 3.5
},
{
"name": "Miniquad 6V",
"width": 10,
"height": 3
},
{
"name": "Monster truck",
"width": 15,
"height": 3
},
{
"name": "NEW HOLLAND TRACTOR",
"width": 16.5,
"height": 4.5,
"rearWidth": 9.5,
"rearHeight": 4.5
},
{
"name": "Politie Dodge Charger SRT",
"width": 9.9,
"height": 4.2
},
{
"name": "QUAD 1000W",
"width": 8.2,
"height": 2.8
},
{
"name": "QUAD 800W ACHTER",
"width": 8.5,
"height": 3.2
},
{
"name": "RANGE ROVER 2-PERSOONS",
"width": 17.8,
"height": 5
},
{
"name": "Range Rover EVOQUE",
"width": 14.1,
"height": 3.1
},
{
"name": "Range Rover Velar",
"width": 15.3,
"height": 3.4
},
{
"name": "SIAN 2-P",
"width": 13.4,
"height": 3.4
},
{
"name": "Super truck",
"width": 13.7,
"height": 3.5
},
{
"name": "TOYOTA HILUX",
"width": 14.8,
"height": 4.1
},
{
"name": "Tractor Loopauto",
"width": 9.5,
"height": 2
},
{
"name": "TRIKE",
"width": 12,
"height": 7
},
{
"name": "UTV",
"width": 14.5,
"height": 3,
"rearWidth": 10,
"rearHeight": 3
},
{
"name": "UTV BUGGY",
"width": 13.8,
"height": 3.5,
"rearWidth": 21.5,
"rearHeight": 3.5
},
{
"name": "Vespa",
"width": 10.5,
"height": 3
},
{
"name": "VOLVO S90",
"width": 17,
"height": 3.9
},
{
"name": "WILLY 1-PERSOON",
"width": 15,
"height": 3.5
},
{
"name": "Willy's jeep",
"width": 20,
"height": 4
}
]

View File

@@ -0,0 +1,76 @@
import defaultCars from './carOptions.json';
const storageKey = 'kentekenCars';
const normalizeCars = (cars) =>
Array.isArray(cars) && cars.length ? cars : defaultCars;
export const loadCars = async () => {
if (typeof window === 'undefined') {
return normalizeCars(defaultCars);
}
if (window.api?.loadCars) {
try {
const cars = await window.api.loadCars();
return normalizeCars(cars);
} catch (error) {
return normalizeCars(defaultCars);
}
}
const stored = window.localStorage.getItem(storageKey);
if (!stored) {
const fallback = normalizeCars(defaultCars);
window.localStorage.setItem(storageKey, JSON.stringify(fallback));
return fallback;
}
try {
const parsed = JSON.parse(stored);
return normalizeCars(parsed);
} catch (error) {
const fallback = normalizeCars(defaultCars);
window.localStorage.setItem(storageKey, JSON.stringify(fallback));
return fallback;
}
};
export const saveCars = async (cars) => {
if (typeof window === 'undefined') {
return;
}
if (window.api?.saveCars) {
await window.api.saveCars(normalizeCars(cars));
return;
}
const normalized = normalizeCars(cars);
window.localStorage.setItem(storageKey, JSON.stringify(normalized));
};
export const getCarsFolder = async () => {
if (typeof window === 'undefined') {
return '';
}
if (window.api?.getCarsFolder) {
return window.api.getCarsFolder();
}
return '';
};
export const selectCarsFolder = async () => {
if (typeof window === 'undefined') {
return null;
}
if (window.api?.selectCarsFolder) {
return window.api.selectCarsFolder();
}
return null;
};
export const resetCarsFolder = async () => {
if (typeof window === 'undefined') {
return null;
}
if (window.api?.resetCarsFolder) {
return window.api.resetCarsFolder();
}
return null;
};

View File

@@ -0,0 +1,18 @@
import { motion, useReducedMotion } from 'framer-motion';
export function Button({ variant = 'primary', className = '', children, ...props }) {
const reduceMotion = useReducedMotion();
const variantClass =
variant === 'ghost' ? 'btn-ghost' : variant === 'primary' ? 'btn-primary' : '';
const combined = ['btn', variantClass, className].filter(Boolean).join(' ');
return (
<motion.button
className={combined}
whileHover={reduceMotion ? undefined : { y: -1, boxShadow: '0 4px 12px rgba(0,0,0,0.2)' }}
whileTap={reduceMotion ? undefined : { scale: 0.98 }}
{...props}
>
{children}
</motion.button>
);
}

View File

@@ -0,0 +1,9 @@
export function Card({ title, subtitle, children }) {
return (
<div className="card space-y-2">
{title && <h2 className="text-lg font-semibold">{title}</h2>}
{subtitle && <p className="subtle">{subtitle}</p>}
{children}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { useState, useEffect } from 'react';
export function CountUp({ value }) {
const [v, setV] = useState(0);
useEffect(() => {
const start = v;
const end = value;
const dur = 600;
const t0 = performance.now();
let raf;
const step = (t) => {
const p = Math.min(1, (t - t0) / dur);
setV(Math.round(start + (end - start) * p));
if (p < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [value]);
return <span>{v}</span>;
}

View File

@@ -0,0 +1,69 @@
import { CountUp } from './CountUp.jsx';
import { ArrowPathIcon, HomeIcon } from '@heroicons/react/24/outline';
import { motion, useReducedMotion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import logo from '../assets/logolang.png';
import { Button } from './Button.jsx';
export function Header({ count, title = 'Kenteken Generator', onRestart, onHome }) {
const navigate = useNavigate();
const reduceMotion = useReducedMotion();
return (
<motion.header
className="header"
initial={reduceMotion ? { opacity: 1 } : { y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: reduceMotion ? 0 : 0.28 }}
>
<div className="header-inner">
<div
className="flex flex-col items-center cursor-pointer"
onClick={() => navigate('/generate')}
>
<motion.img
src={logo}
alt="CarKiddo"
className="h-10 w-auto"
whileHover={reduceMotion ? undefined : { rotate: 2, y: -1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
/>
<motion.div
className="header-title mt-1"
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{title}
</motion.div>
</div>
<div className="flex items-center gap-3">
<div className="subtle flex items-center gap-1 text-sm">
🚗 <CountUp value={count} /> kinderautos
</div>
{onHome && (
<Button
type="button"
variant="ghost"
className="px-3 py-1.5 text-xs flex items-center gap-1"
onClick={onHome}
>
<HomeIcon className="w-4 h-4" />
Home
</Button>
)}
{onRestart && (
<Button
type="button"
variant="ghost"
className="px-3 py-1.5 text-xs flex items-center gap-1"
onClick={onRestart}
>
<ArrowPathIcon className="w-4 h-4" />
Opnieuw
</Button>
)}
</div>
</div>
</motion.header>
);
}

View File

@@ -0,0 +1,15 @@
import { motion, useReducedMotion } from 'framer-motion';
export function PageTransition({ children }) {
const reduce = useReducedMotion();
return (
<motion.div
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 1 } : { opacity: 0, y: -8 }}
transition={{ duration: reduce ? 0 : 0.28 }}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,7 @@
export function PlateForm({ children, onSubmit }) {
return (
<form onSubmit={onSubmit} className="space-y-4">
{children}
</form>
);
}

View File

@@ -0,0 +1,14 @@
import { motion } from 'framer-motion';
export function PreviewCard({ children }) {
return (
<motion.div
className="preview-card"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.25 }}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,10 @@
import { CountUp } from './CountUp.jsx';
export function StatCard({ value, label }) {
return (
<div className="card stat-card">
<div className="stat-value"><CountUp value={value} /></div>
<div className="stat-label subtle">{label}</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
export function Toast({ message, onClose }) {
useEffect(() => {
if (!message) return;
const t = setTimeout(() => onClose && onClose(), 3000);
return () => clearTimeout(t);
}, [message, onClose]);
return (
<AnimatePresence>
{message && (
<motion.div
className="toast"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
{message}
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,4 @@
@font-face {
font-family: "KentekenFont";
src: url("./assets/Kenteken.ttf") format("truetype");
}

141
src_frontend_orig/index.css Normal file
View File

@@ -0,0 +1,141 @@
@import './tokens.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html, body, #root {
@apply h-full;
}
body {
@apply antialiased;
background-color: rgb(var(--bg));
color: white;
font-family: 'Segoe UI', 'Inter', sans-serif;
}
@media (prefers-color-scheme: light) {
body {
color: black;
}
}
}
@layer components {
.container {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
.card {
@apply rounded-2xl backdrop-blur-md border p-6;
background-color: rgb(var(--surface));
border-color: rgb(var(--border));
box-shadow: var(--shadow);
}
.btn {
@apply inline-flex items-center justify-center rounded-xl px-4 py-2 font-semibold transition focus:outline-none focus:ring-2;
}
.btn-primary {
background-color: rgb(var(--accent));
color: rgb(var(--accent-contrast));
@apply focus:ring-[color:rgb(var(--ring)/0.6)];
}
.btn-ghost {
@apply bg-transparent text-white hover:bg-white/10 focus:ring-[color:rgb(var(--ring)/0.6)];
}
input, select, textarea {
@apply w-full rounded-lg px-3 py-2 bg-transparent focus:outline-none focus:ring-2;
background-color: rgb(var(--surface));
border: 1px solid rgb(var(--border));
color: white;
}
@media (prefers-color-scheme: light) {
input, select, textarea {
color: black;
}
}
label {
@apply block text-sm font-medium mb-1;
color: rgb(var(--muted));
}
.subtle {
color: rgb(var(--muted));
}
.header {
@apply sticky top-0 z-10 backdrop-blur-md border-b shadow-sm;
background-color: rgb(var(--surface));
border-color: rgb(var(--border));
}
.header-inner {
@apply container flex items-center justify-between py-3;
}
.header-title {
@apply text-sm font-semibold;
}
.flag-selector {
@apply flex justify-center items-center gap-4 mb-4;
}
.flag-option {
@apply flex flex-col items-center gap-2 rounded-md px-2 py-1 text-xs font-medium text-white/80 transition;
background: transparent;
border: none;
}
.flag-option img {
@apply w-20 rounded-md transition-transform shadow;
}
.flag-option:hover img {
@apply scale-105;
}
.flag-option.selected {
@apply ring-2 ring-accent text-white;
}
.dashboard {
@apply grid gap-6 mt-6 grid-cols-1;
}
.side-cards {
@apply grid gap-6 md:grid-cols-3;
}
.preview-card {
@apply bg-white/60 dark:bg-neutral-800/60 backdrop-blur-md rounded-2xl p-8 text-center;
}
.toast {
@apply fixed bottom-6 right-6 bg-neutral-900 text-white px-4 py-3 rounded-lg shadow-lg;
}
.stat-card {
@apply text-center;
}
.stat-value {
@apply text-3xl font-bold text-accent;
}
.stat-label {
@apply mt-1 text-sm text-gray-500 dark:text-gray-400;
}
.button-row {
@apply flex gap-2 flex-wrap mt-4;
}
.plate-block {
@apply mb-6 pb-6 border-b border-gray-200 last:border-b-0 flex flex-col gap-4 md:flex-row md:gap-8;
}
.mt {
@apply mt-4;
}
.mb {
@apply mb-2;
}
.mb-lg {
@apply mb-6;
}
.ml {
@apply ml-2;
}
.form-row {
@apply flex flex-col gap-2 mb-2;
}
.form-row.checkbox {
@apply flex-row items-center;
}
.start-container {
@apply flex items-center justify-center h-[calc(100vh-4rem)];
}
.start-card {
@apply flex flex-col gap-4 w-full max-w-xs;
}
}

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="./assets/CarKiddologo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>Kenteken Generator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,39 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { AnimatePresence } from 'framer-motion';
import LicensePlateApp from './LicensePlateApp.jsx';
import CarManager from './CarManager.jsx';
import LandingPage from './LandingPage.jsx';
import PrintPage from './routes/PrintPage.jsx';
import './fonts.css';
import './index.css';
function AnimatedRoutes() {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route path="/" element={<LandingPage />} />
<Route path="/generate" element={<LicensePlateApp />} />
<Route path="/manage" element={<CarManager />} />
<Route path="/print" element={<PrintPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AnimatePresence>
);
}
function App() {
return (
<HashRouter>
<AnimatedRoutes />
</HashRouter>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,57 @@
@page {
size: A4;
margin: 10mm;
}
.a4-preview {
width: 210mm;
height: 297mm;
margin: 0 auto;
border: 1px solid #ccc;
padding: 10mm;
box-sizing: border-box;
background: white;
}
.print-grid {
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(auto-fit, minmax(var(--plate-width, 100mm), 1fr));
gap: 8mm;
justify-content: flex-start;
align-content: flex-start;
justify-items: start;
}
.plate {
break-inside: avoid;
page-break-inside: avoid;
display: block;
}
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.print-grid {
gap: 8mm;
justify-content: flex-start;
align-content: flex-start;
}
.plate {
break-inside: avoid;
page-break-inside: avoid;
}
header,
.no-print {
display: none !important;
}
.a4-preview {
width: auto;
height: auto;
border: none;
padding: 0;
margin: 0;
}
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Button } from '../components/Button.jsx';
import { PageTransition } from '../components/PageTransition.jsx';
import '../print.css';
export default function PrintPage() {
const { state } = useLocation();
const items = state?.items || [];
const navigate = useNavigate();
const maxWidth = items.reduce((max, i) => Math.max(max, i.width), 0) || 100;
return (
<PageTransition>
<div className="container py-4">
<div className="no-print mb-4 flex gap-2">
<Button variant="ghost" onClick={() => navigate('/generate')}>Terug</Button>
<Button variant="primary" onClick={() => window.print()}>Print</Button>
</div>
<div className="a4-preview">
<div className="print-grid" style={{ '--plate-width': `${maxWidth}mm` }}>
{items.map((item, idx) => (
<img
key={idx}
src={item.src}
alt={`plate-${idx}`}
className="plate"
style={{ width: `${item.width}mm`, height: `${item.height}mm` }}
/>
))}
</div>
</div>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,23 @@
:root {
--bg: 17 17 17;
--surface: 31 31 31 / 0.6;
--muted: 160 160 160;
--accent: 37 99 235;
--accent-contrast: 255 255 255;
--border: 255 255 255 / 0.1;
--ring: 37 99 235;
--shadow: 0 8px 30px rgba(0,0,0,0.25);
}
@media (prefers-color-scheme: light) {
:root {
--bg: 243 243 243;
--surface: 255 255 255 / 0.7;
--muted: 60 60 60;
--accent: 37 99 235;
--accent-contrast: 255 255 255;
--border: 0 0 0 / 0.1;
--ring: 37 99 235;
--shadow: 0 8px 30px rgba(0,0,0,0.1);
}
}