Initial commit
43
Kenteken-Gen-1-main/.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Build Windows Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
CSC_LINK_BASE64: ${{ secrets.CSC_LINK_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- run: npm ci
|
||||
# Optioneel: code signing (alleen uitvoeren als secrets bestaan)
|
||||
- name: Setup code signing
|
||||
if: ${{ env.CSC_LINK_BASE64 != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pfxPath = "$env:RUNNER_TEMP\signing.pfx"
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:CSC_LINK_BASE64))
|
||||
"CSC_LINK=$pfxPath" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
"CSC_KEY_PASSWORD=$env:CSC_KEY_PASSWORD" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
# Bouw én publiceer ALTIJD naar de GitHub Release
|
||||
- name: Build & publish Windows package
|
||||
run: npm run publish:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# (optioneel) ook als Actions artifact
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release
|
||||
path: release/**
|
||||
8
Kenteken-Gen-1-main/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
*.log
|
||||
dist
|
||||
release
|
||||
.vite
|
||||
.env
|
||||
|
||||
|
||||
29
Kenteken-Gen-1-main/AGENTS.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# AGENTS
|
||||
|
||||
This repository contains the Kenteken Generator desktop app built with Electron and React.
|
||||
|
||||
## Project layout
|
||||
- `src/frontend` – React components and styles for the UI.
|
||||
- `electron` – main Electron process and preload scripts.
|
||||
- `src/kenteken_gen` – backend utilities (currently minimal).
|
||||
- `docs` – extra documentation.
|
||||
- `build` – packaging assets such as icons.
|
||||
|
||||
## Conventions
|
||||
- React component files use **PascalCase** (`CarManager.jsx`).
|
||||
- Functions and variables use **camelCase**.
|
||||
- Keep functions small and add brief comments to explain non-obvious logic.
|
||||
|
||||
## Scripts
|
||||
- `npm run dev` – start the Vite dev server.
|
||||
- `npm run build` – build the frontend for production.
|
||||
- `npm run start` – launch Electron with the built assets.
|
||||
- `npm run dist` – build and package the app.
|
||||
- `make lint` – run lint checks (placeholder).
|
||||
- `make test` – run tests (placeholder).
|
||||
|
||||
## Tips
|
||||
- License plate generation and rendering lives in `src/frontend/LicensePlateApp.jsx`.
|
||||
- Car preset management is implemented in `src/frontend/CarManager.jsx`.
|
||||
- IPC helpers are exposed via `electron/preload.cjs` and handled in `electron/main.js`.
|
||||
- Use `rg` (ripgrep) to search the codebase, e.g. `rg saveCars`.
|
||||
7
Kenteken-Gen-1-main/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.1 - 2024-06-10
|
||||
- Persist child car data to JSON via IPC with file debouncing.
|
||||
- Added spacing and left-aligned layout for exports and printing.
|
||||
- Added home navigation and clickable logo.
|
||||
- Fixed dimension inputs and saving feedback in car manager.
|
||||
7
Kenteken-Gen-1-main/Makefile
Normal file
@@ -0,0 +1,7 @@
|
||||
.PHONY: test lint
|
||||
|
||||
test:
|
||||
npm test
|
||||
|
||||
lint:
|
||||
npm run lint
|
||||
50
Kenteken-Gen-1-main/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Kenteken-Gen-1
|
||||
|
||||
Projectstructuur voor Kenteken Gen 1. De broncode bevindt zich in `src/kenteken_gen`, tests in `tests` en documentatie in `docs`.
|
||||
|
||||
Voor uitgebreide documentatie zie [docs/README.md](docs/README.md).
|
||||
Wanneer een kinderauto verschillende afmetingen voor en achter heeft, genereert de app automatisch twee kentekens.
|
||||
|
||||
## Design system
|
||||
|
||||
De frontend gebruikt een eenvoudig Apple-achtig designsysteem met CSS-variabelen:
|
||||
|
||||
```css
|
||||
:root{
|
||||
--bg:#F7F7F8;
|
||||
--card:#FFFFFF;
|
||||
--ink:#0B0B0C;
|
||||
--muted:#70757D;
|
||||
--line:#E7E8EA;
|
||||
--accent:#FFD000;
|
||||
--accent-dark:#111113;
|
||||
}
|
||||
```
|
||||
|
||||
De basistypografie maakt gebruik van het **Inter**-font. Buttons en kaarten hebben afgeronde hoeken en een subtiele schaduw (`0 6px 24px rgba(0,0,0,.06)`).
|
||||
|
||||
### Thema aanpassen
|
||||
|
||||
Alle kleuren en globale spacing zijn gedefinieerd als CSS-variabelen in `src/frontend/styles.css`. Pas deze variabelen aan om het thema te wijzigen.
|
||||
|
||||
## Deployen
|
||||
|
||||
De app is nu een webapp. Gebruik `npm run build` om een productiebuild te maken en host de inhoud van de `dist` map op je webserver.
|
||||
|
||||
|
||||
## Ontwikkeling
|
||||
|
||||
Gebruik de onderstaande scripts voor lokale ontwikkeling:
|
||||
|
||||
- `npm run dev` start de Vite development server.
|
||||
- `npm run build` bouwt de frontend.
|
||||
- `make lint` voert een eenvoudige lint-check uit (placeholder).
|
||||
- `make test` draait de tests (placeholder).
|
||||
|
||||
Projectindeling:
|
||||
|
||||
- `src/frontend` bevat de React componenten en styles.
|
||||
- `electron` bevat legacy Electron scripts (niet meer gebruikt).
|
||||
- `src/kenteken_gen` is gereserveerd voor back-end utilities.
|
||||
|
||||
React componentbestanden gebruiken PascalCase; functies en variabelen gebruiken camelCase.
|
||||
3
Kenteken-Gen-1-main/build/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Build resources
|
||||
|
||||
Place the Windows icon file `icon.ico` in this directory. It will be packaged by electron-builder.
|
||||
BIN
Kenteken-Gen-1-main/build/carkiddoico.ico
Normal file
|
After Width: | Height: | Size: 401 KiB |
36
Kenteken-Gen-1-main/docs/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Kenteken-Gen-1
|
||||
Kenteken Gen 1
|
||||
|
||||
## Frontend
|
||||
A small React frontend lives in `src/frontend` and is powered by [Vite](https://vitejs.dev/).
|
||||
|
||||
Exported license plate images are generated at 180 DPI. This matches the
|
||||
resolution used in our Photoshop documents, so the centimeter values entered in
|
||||
the app correspond to the real-world dimensions when those images are opened or
|
||||
pasted there.
|
||||
|
||||
### Running the frontend
|
||||
1. Install dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
2. Start the development server:
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
Visit http://localhost:5173/ to view the app.
|
||||
3. Create a production build:
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Kinderauto toevoegen
|
||||
|
||||
In de frontend kan een gebruiker een eigen *Kinderauto* toevoegen. Hierbij worden
|
||||
de afmetingen van het kenteken voor ingevoerd en optioneel andere maten voor
|
||||
achter. De opgegeven opties worden in `localStorage` bewaard zodat ze bij een
|
||||
volgende sessie weer beschikbaar zijn.
|
||||
|
||||
Als er voor een kinderauto ook achterafmetingen zijn opgegeven, toont de app
|
||||
automatisch twee kentekens: één voor en één achter. Zonder achterafmetingen wordt
|
||||
er slechts één kenteken gegenereerd.
|
||||
183
Kenteken-Gen-1-main/electron/main.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join, resolve } from 'path';
|
||||
import { existsSync, readFileSync, writeFile, writeFileSync } from 'fs';
|
||||
import pkg from 'electron-updater';
|
||||
|
||||
const { autoUpdater } = pkg;
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function getCarOptionsPath() {
|
||||
return app.isPackaged
|
||||
? join(process.resourcesPath, 'carOptions.json')
|
||||
: resolve(__dirname, '..', 'src', 'frontend', 'carOptions.json');
|
||||
}
|
||||
|
||||
function getSharedCarsPath() {
|
||||
return join(getDefaultCarsFolder(), 'shared-cars.json');
|
||||
}
|
||||
|
||||
function getSettingsPath() {
|
||||
return join(app.getPath('userData'), 'kenteken-settings.json');
|
||||
}
|
||||
|
||||
function getDefaultCarsFolder() {
|
||||
return app.isPackaged
|
||||
? app.getPath('userData')
|
||||
: resolve(__dirname, '..');
|
||||
}
|
||||
|
||||
function normalizeCarsFolder(folder) {
|
||||
if (!folder || typeof folder !== 'string') {
|
||||
return '';
|
||||
}
|
||||
if (!existsSync(folder)) {
|
||||
return '';
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
// Persist the chosen cars folder so users can keep using the same location.
|
||||
try {
|
||||
const raw = readFileSync(getSettingsPath(), 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const carsFolder = normalizeCarsFolder(parsed?.carsFolder);
|
||||
return { carsFolder };
|
||||
} catch {
|
||||
return { carsFolder: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(nextSettings) {
|
||||
writeFileSync(getSettingsPath(), JSON.stringify(nextSettings, null, 2));
|
||||
}
|
||||
|
||||
function getCarsFolder() {
|
||||
return settings.carsFolder || getDefaultCarsFolder();
|
||||
}
|
||||
|
||||
function setCarsFolder(folder) {
|
||||
const normalized = normalizeCarsFolder(folder);
|
||||
settings.carsFolder = normalized;
|
||||
saveSettings(settings);
|
||||
storeFile = join(getCarsFolder(), 'shared-cars.json');
|
||||
}
|
||||
|
||||
function loadBaseCarOptions() {
|
||||
const p = getCarOptionsPath();
|
||||
const json = readFileSync(p, 'utf-8');
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1024,
|
||||
height: 768,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: join(__dirname, 'preload.cjs'),
|
||||
},
|
||||
});
|
||||
|
||||
if (app.isPackaged) {
|
||||
win.loadFile(join(__dirname, '../dist/index.html'));
|
||||
} else {
|
||||
win.loadURL('http://localhost:5173');
|
||||
win.webContents.openDevTools();
|
||||
}
|
||||
}
|
||||
|
||||
let storeFile;
|
||||
let cars = [];
|
||||
let saveTimer;
|
||||
let settings = { carsFolder: '' };
|
||||
|
||||
function loadCars() {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(storeFile, 'utf-8'));
|
||||
if (!Array.isArray(data)) throw new Error('Invalid data');
|
||||
if (
|
||||
!data.every(
|
||||
(c) =>
|
||||
typeof c.name === 'string' &&
|
||||
typeof c.width === 'number' &&
|
||||
typeof c.height === 'number'
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid schema');
|
||||
}
|
||||
cars = data;
|
||||
} catch {
|
||||
cars = loadBaseCarOptions();
|
||||
saveCars(cars, true);
|
||||
}
|
||||
return cars;
|
||||
}
|
||||
|
||||
function saveCars(newCars, immediate = false) {
|
||||
cars = newCars;
|
||||
const write = () =>
|
||||
writeFile(storeFile, JSON.stringify(cars, null, 2), () => {});
|
||||
if (immediate) {
|
||||
write();
|
||||
} else {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(write, 250);
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('cars:load', () => loadCars());
|
||||
ipcMain.handle('cars:save', (_e, newCars) => {
|
||||
saveCars(newCars);
|
||||
return cars;
|
||||
});
|
||||
ipcMain.handle('cars:folder:get', () => getCarsFolder());
|
||||
ipcMain.handle('cars:folder:select', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
if (result.canceled || !result.filePaths?.[0]) {
|
||||
return { canceled: true, folder: getCarsFolder(), cars };
|
||||
}
|
||||
setCarsFolder(result.filePaths[0]);
|
||||
const updatedCars = loadCars();
|
||||
return { canceled: false, folder: getCarsFolder(), cars: updatedCars };
|
||||
});
|
||||
ipcMain.handle('cars:folder:reset', () => {
|
||||
settings.carsFolder = '';
|
||||
saveSettings(settings);
|
||||
storeFile = getSharedCarsPath();
|
||||
const updatedCars = loadCars();
|
||||
return { folder: getCarsFolder(), cars: updatedCars };
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
settings = loadSettings();
|
||||
storeFile = settings.carsFolder
|
||||
? join(getCarsFolder(), 'shared-cars.json')
|
||||
: getSharedCarsPath();
|
||||
loadCars();
|
||||
createWindow();
|
||||
|
||||
try {
|
||||
autoUpdater.checkForUpdatesAndNotify();
|
||||
} catch (e) {
|
||||
// fail silently
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
13
Kenteken-Gen-1-main/electron/preload.cjs
Normal file
@@ -0,0 +1,13 @@
|
||||
// CommonJS preload
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
loadCars: () => ipcRenderer.invoke('cars:load'),
|
||||
saveCars: (cars) => ipcRenderer.invoke('cars:save', cars),
|
||||
getCarsFolder: () => ipcRenderer.invoke('cars:folder:get'),
|
||||
selectCarsFolder: () => ipcRenderer.invoke('cars:folder:select'),
|
||||
resetCarsFolder: () => ipcRenderer.invoke('cars:folder:reset'),
|
||||
|
||||
// Handig als je print via main wilt aanroepen:
|
||||
// print: (opts) => ipcRenderer.invoke('print', opts),
|
||||
});
|
||||
3118
Kenteken-Gen-1-main/package-lock.json
generated
Normal file
30
Kenteken-Gen-1-main/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "kenteken-gen-frontend",
|
||||
"version": "1.2.0",
|
||||
"description": "Kenteken generator web app",
|
||||
"author": "Anis el Youzghi",
|
||||
"repository": "https://github.com/anis010/Kenteken-Gen-1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo \"No lint configured\" && exit 0",
|
||||
"test": "echo \"No tests specified\" && exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.1.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
6
Kenteken-Gen-1-main/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
498
Kenteken-Gen-1-main/shared-cars.json
Normal 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
|
||||
}
|
||||
]
|
||||
402
Kenteken-Gen-1-main/src/frontend/CarManager.jsx
Normal file
@@ -0,0 +1,402 @@
|
||||
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 {
|
||||
loadCars,
|
||||
addCar,
|
||||
updateCar,
|
||||
deleteCarById,
|
||||
verifyPassword,
|
||||
} from './carStorage.js';
|
||||
|
||||
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 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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
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);
|
||||
}
|
||||
await addCar(newCar);
|
||||
const refreshed = await loadCars();
|
||||
applyCars(refreshed);
|
||||
setAddForm({
|
||||
name: '',
|
||||
width: '',
|
||||
height: '',
|
||||
hasRear: false,
|
||||
rearWidth: '',
|
||||
rearHeight: '',
|
||||
});
|
||||
setToast(true);
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const car = options.find((o) => o.name === editName);
|
||||
if (!car) return;
|
||||
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);
|
||||
}
|
||||
await updateCar(car.id, updatedCar);
|
||||
const refreshed = await loadCars();
|
||||
applyCars(refreshed);
|
||||
setEditName(updatedCar.name);
|
||||
setToast(true);
|
||||
};
|
||||
|
||||
const handleDeleteSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const car = options.find((o) => o.name === deleteName);
|
||||
if (!car) return;
|
||||
await deleteCarById(car.id);
|
||||
const refreshed = await loadCars();
|
||||
applyCars(refreshed);
|
||||
setToast(true);
|
||||
};
|
||||
|
||||
const handleAuthSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const valid = await verifyPassword(password);
|
||||
if (valid) {
|
||||
setAuthenticated(true);
|
||||
setPassword('');
|
||||
setError('');
|
||||
} else {
|
||||
setError('Onjuist wachtwoord');
|
||||
}
|
||||
};
|
||||
|
||||
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="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>
|
||||
);
|
||||
}
|
||||
66
Kenteken-Gen-1-main/src/frontend/LandingPage.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
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';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import logo from './assets/CarKiddologo.png';
|
||||
|
||||
export default function LandingPage() {
|
||||
const navigate = useNavigate();
|
||||
const [count, setCount] = useState(0);
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
loadCars().then((opts) => setCount(opts.length));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<div className="hero-bg" />
|
||||
<Header count={count} />
|
||||
<div className="start-container">
|
||||
<div className="start-card">
|
||||
<motion.img
|
||||
src={logo}
|
||||
alt="CarKiddo"
|
||||
className="w-20 h-20 sm:w-24 sm:h-24 mx-auto rounded-2xl"
|
||||
initial={reduceMotion ? false : { scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.4, type: 'spring', stiffness: 200 }}
|
||||
/>
|
||||
<motion.h1
|
||||
className="text-2xl sm:text-3xl font-bold tracking-tight"
|
||||
initial={reduceMotion ? false : { y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1, duration: 0.3 }}
|
||||
>
|
||||
Kenteken Generator
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
className="subtle text-sm sm:text-base -mt-1"
|
||||
initial={reduceMotion ? false : { y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.3 }}
|
||||
>
|
||||
Genereer kentekens voor {count} kinderauto's
|
||||
</motion.p>
|
||||
<motion.div
|
||||
className="flex flex-col gap-3 mt-2"
|
||||
initial={reduceMotion ? false : { y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.3, duration: 0.3 }}
|
||||
>
|
||||
<Button onClick={() => navigate('/generate')} variant="primary">
|
||||
Kentekens genereren
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/manage')} variant="ghost">
|
||||
Kinderauto's beheren
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
630
Kenteken-Gen-1-main/src/frontend/LicensePlateApp.jsx
Normal file
@@ -0,0 +1,630 @@
|
||||
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 [fontsLoaded, setFontsLoaded] = useState(false);
|
||||
const frontRefs = useRef([]);
|
||||
const rearRefs = useRef([]);
|
||||
const frontTextConfigs = useRef([]);
|
||||
const rearTextConfigs = 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: '' }]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.fonts.load('bold 50px KentekenFont').then(() => setFontsLoaded(true));
|
||||
}, []);
|
||||
|
||||
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, textConfig = null) => {
|
||||
if (!svg) return '';
|
||||
await document.fonts.ready;
|
||||
// Explicitly load KentekenFont for canvas use (document.fonts.ready
|
||||
// only covers fonts currently in use in the DOM layout)
|
||||
if (textConfig) {
|
||||
try {
|
||||
await document.fonts.load(`${textConfig.fontWeight} ${textConfig.fontSize}px KentekenFont`);
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
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);
|
||||
if (textConfig) {
|
||||
// The SVG uses preserveAspectRatio="xMidYMid meet" (default), so the
|
||||
// plate image is scaled uniformly and centred within the canvas.
|
||||
// Use that same uniform scale for text so it stays inside the plate.
|
||||
const renderW = textConfig.renderWidth || imgEl.width;
|
||||
const renderH = textConfig.renderHeight || imgEl.height;
|
||||
const scaleX = renderW / textConfig.baseWidth;
|
||||
const scaleY = renderH / textConfig.baseHeight;
|
||||
const uniformScale = Math.min(scaleX, scaleY);
|
||||
const offsetX = (renderW - textConfig.baseWidth * uniformScale) / 2;
|
||||
const offsetY = (renderH - textConfig.baseHeight * uniformScale) / 2;
|
||||
const canvasFontSize = textConfig.fontSize * uniformScale;
|
||||
const canvasX = textConfig.x * uniformScale + offsetX;
|
||||
const canvasY = textConfig.y * uniformScale + offsetY;
|
||||
ctx.font = `${textConfig.fontWeight} ${canvasFontSize}px KentekenFont, monospace`;
|
||||
ctx.fillStyle = textConfig.textColor;
|
||||
ctx.textAlign = textConfig.textAnchor === 'middle' ? 'center' : 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
if (textConfig.dropShadow.stdDeviation > 0) {
|
||||
ctx.shadowOffsetX = textConfig.dropShadow.dx * uniformScale;
|
||||
ctx.shadowOffsetY = textConfig.dropShadow.dy * uniformScale;
|
||||
ctx.shadowBlur = textConfig.dropShadow.stdDeviation * uniformScale;
|
||||
ctx.shadowColor = textConfig.shadowColor;
|
||||
}
|
||||
ctx.fillText(textConfig.text, canvasX, canvasY);
|
||||
}
|
||||
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], frontTextConfigs.current[i]));
|
||||
rearUrls.push(await svgToPng(rearRefs.current[i], rearTextConfigs.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], frontTextConfigs.current[i]);
|
||||
const rearUrl = await svgToPng(rearRefs.current[i], rearTextConfigs.current[i]);
|
||||
items.push({ src: frontUrl, width: frontWidth, height: frontHeight });
|
||||
items.push({ src: rearUrl, width: rearWidth, height: rearHeight });
|
||||
}
|
||||
navigate('/print', { state: { items } });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!fontsLoaded) return;
|
||||
const renderPreviews = async () => {
|
||||
const newPreviews = [];
|
||||
for (let i = 0; i < plates.length; i++) {
|
||||
const frontUrl = frontRefs.current[i]
|
||||
? await svgToPng(frontRefs.current[i], frontTextConfigs.current[i])
|
||||
: '';
|
||||
const rearUrl = rearRefs.current[i]
|
||||
? await svgToPng(rearRefs.current[i], rearTextConfigs.current[i])
|
||||
: '';
|
||||
newPreviews.push({ front: frontUrl, rear: rearUrl });
|
||||
}
|
||||
setPreviews(newPreviews);
|
||||
};
|
||||
renderPreviews();
|
||||
}, [plates, options, fontsLoaded]);
|
||||
|
||||
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;
|
||||
// 0.52 ≈ 0.6 * 0.87: compensates for KentekenFont's glyph-height/em ratio
|
||||
// being 0.906, vs the old SVG fallback font's ~0.716. Slightly increased
|
||||
// from 0.47 per user feedback ("iets te klein").
|
||||
const baseFontSize = baseHeight * 0.56;
|
||||
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 textCenterY = hasBottomBar
|
||||
? (baseHeight - blackHeight) / 2
|
||||
: baseHeight / 2;
|
||||
// Shift text slightly down: KentekenFont cap-height center sits
|
||||
// slightly above the canvas em-midpoint.
|
||||
const textY = textCenterY + baseFontSize * 0.13;
|
||||
// Centered if text fits between the EU strip and the right edge.
|
||||
const maxCenteredTextWidth = maxTextWidth;
|
||||
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 = shouldCenterFront ? 'middle' : 'start';
|
||||
const rearTextAnchor = shouldCenterRear ? 'middle' : 'start';
|
||||
const frontTextX = shouldCenterFront
|
||||
? euWidth + (baseWidth - euWidth) / 2
|
||||
: euWidth + 10;
|
||||
const rearTextX = shouldCenterRear
|
||||
? euWidth + (baseWidth - euWidth) / 2
|
||||
: euWidth + 10;
|
||||
const bottomBarRatio = hasBottomBar ? 0.1 : 0;
|
||||
frontTextConfigs.current[i] = {
|
||||
text: plate.front.replace(/ /g, '\u00A0'),
|
||||
x: frontTextX,
|
||||
y: textY,
|
||||
textAnchor: frontTextAnchor,
|
||||
fontSize: frontFontSize,
|
||||
fontWeight: fontWeightValue,
|
||||
textColor,
|
||||
shadowColor,
|
||||
dropShadow,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
renderWidth: frontWidth,
|
||||
renderHeight: frontHeight,
|
||||
bottomBarRatio,
|
||||
};
|
||||
rearTextConfigs.current[i] = {
|
||||
text: plate.rear.replace(/ /g, '\u00A0'),
|
||||
x: rearTextX,
|
||||
y: textY,
|
||||
textAnchor: rearTextAnchor,
|
||||
fontSize: rearFontSize,
|
||||
fontWeight: fontWeightValue,
|
||||
textColor,
|
||||
shadowColor,
|
||||
dropShadow,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
renderWidth: rearWidthPx,
|
||||
renderHeight: rearHeightPx,
|
||||
bottomBarRatio,
|
||||
};
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{previews[i] && previews[i].front && (
|
||||
<motion.img
|
||||
src={previews[i].front}
|
||||
alt={`Kenteken voor ${i + 1}`}
|
||||
className="plate-preview"
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{previews[i] && previews[i].rear && (
|
||||
<motion.img
|
||||
src={previews[i].rear}
|
||||
alt={`Kenteken achter ${i + 1}`}
|
||||
className="plate-preview"
|
||||
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>
|
||||
);
|
||||
}
|
||||
BIN
Kenteken-Gen-1-main/src/frontend/assets/CarKiddologo.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
Kenteken-Gen-1-main/src/frontend/assets/Kenteken.ttf
Normal file
BIN
Kenteken-Gen-1-main/src/frontend/assets/belgischekenteken.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
Kenteken-Gen-1-main/src/frontend/assets/belgischekentekenv2.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
Kenteken-Gen-1-main/src/frontend/assets/belgiumflag.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
9
Kenteken-Gen-1-main/src/frontend/assets/duitsevlag.svg
Normal 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 |
BIN
Kenteken-Gen-1-main/src/frontend/assets/duitskenteken.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
Kenteken-Gen-1-main/src/frontend/assets/dutchflag.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Kenteken-Gen-1-main/src/frontend/assets/kentekentv.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
22
Kenteken-Gen-1-main/src/frontend/assets/license_plate.svg
Normal 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 |
BIN
Kenteken-Gen-1-main/src/frontend/assets/logolang.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
498
Kenteken-Gen-1-main/src/frontend/carOptions.json
Normal 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
|
||||
}
|
||||
]
|
||||
67
Kenteken-Gen-1-main/src/frontend/carStorage.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import defaultCars from './carOptions.json';
|
||||
|
||||
const normalizeCars = (cars) =>
|
||||
Array.isArray(cars) && cars.length ? cars : defaultCars;
|
||||
|
||||
export const loadCars = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/cars');
|
||||
if (!res.ok) throw new Error('Failed to load cars');
|
||||
const cars = await res.json();
|
||||
return normalizeCars(cars);
|
||||
} catch {
|
||||
return normalizeCars(defaultCars);
|
||||
}
|
||||
};
|
||||
|
||||
export const saveCars = async (cars) => {
|
||||
const normalized = normalizeCars(cars);
|
||||
try {
|
||||
await fetch('/api/cars', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(normalized),
|
||||
});
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
export const addCar = async (car) => {
|
||||
const res = await fetch('/api/cars', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(car),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to add car');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const updateCar = async (id, car) => {
|
||||
const res = await fetch(`/api/cars/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(car),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update car');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const deleteCarById = async (id) => {
|
||||
const res = await fetch(`/api/cars/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete car');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const verifyPassword = async (password) => {
|
||||
const res = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return data.authenticated;
|
||||
};
|
||||
18
Kenteken-Gen-1-main/src/frontend/components/Button.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
Kenteken-Gen-1-main/src/frontend/components/Card.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
Kenteken-Gen-1-main/src/frontend/components/CountUp.jsx
Normal 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>;
|
||||
}
|
||||
69
Kenteken-Gen-1-main/src/frontend/components/Header.jsx
Normal 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 items-center gap-2 sm:gap-3 cursor-pointer min-w-0"
|
||||
onClick={() => navigate('/generate')}
|
||||
>
|
||||
<motion.img
|
||||
src={logo}
|
||||
alt="CarKiddo"
|
||||
className="header-logo"
|
||||
whileHover={reduceMotion ? undefined : { rotate: 2, y: -1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
/>
|
||||
<motion.div
|
||||
className="header-title truncate"
|
||||
initial={reduceMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{title}
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<div className="header-stat">
|
||||
<CountUp value={count} /> auto's
|
||||
</div>
|
||||
{onHome && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="header-btn"
|
||||
onClick={onHome}
|
||||
>
|
||||
<HomeIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Home</span>
|
||||
</Button>
|
||||
)}
|
||||
{onRestart && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="header-btn"
|
||||
onClick={onRestart}
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Opnieuw</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function PlateForm({ children, onSubmit }) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
14
Kenteken-Gen-1-main/src/frontend/components/PreviewCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
Kenteken-Gen-1-main/src/frontend/components/StatCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
Kenteken-Gen-1-main/src/frontend/components/Toast.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
Kenteken-Gen-1-main/src/frontend/fonts.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@font-face {
|
||||
font-family: "KentekenFont";
|
||||
src: url("./assets/Kenteken.ttf") format("truetype");
|
||||
}
|
||||
275
Kenteken-Gen-1-main/src/frontend/index.css
Normal file
@@ -0,0 +1,275 @@
|
||||
@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: rgb(var(--text));
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ── Layout ── */
|
||||
.container {
|
||||
@apply max-w-5xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
.card {
|
||||
@apply rounded-2xl border p-5 sm:p-6;
|
||||
background-color: rgb(var(--surface));
|
||||
border-color: rgb(var(--border));
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow), 0 0 0 1px rgb(var(--border));
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-xl px-5 py-2.5 font-semibold transition-all duration-200 focus:outline-none focus:ring-2 text-sm sm:text-base cursor-pointer select-none;
|
||||
min-height: 44px;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: rgb(var(--accent));
|
||||
color: rgb(var(--accent-contrast));
|
||||
box-shadow: 0 0 20px rgba(var(--glow) / 0.25);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: rgb(var(--accent-hover));
|
||||
box-shadow: 0 0 30px rgba(var(--glow) / 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-primary:focus {
|
||||
--tw-ring-color: rgb(var(--ring) / 0.5);
|
||||
}
|
||||
.btn-ghost {
|
||||
color: rgb(var(--text));
|
||||
background-color: rgb(var(--surface));
|
||||
border: 1px solid rgb(var(--border));
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background-color: rgb(var(--border));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-ghost:focus {
|
||||
--tw-ring-color: rgb(var(--ring) / 0.5);
|
||||
}
|
||||
|
||||
/* ── Inputs - CRUCIAAL: specifieke selectors om browser defaults te overriden ── */
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type="search"],
|
||||
input[type="tel"],
|
||||
input[type="url"],
|
||||
input:not([type]),
|
||||
select,
|
||||
textarea {
|
||||
@apply w-full rounded-xl px-4 py-3 transition-all duration-200 focus:outline-none;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: rgb(var(--surface-input)) !important;
|
||||
border: 1px solid rgb(var(--border));
|
||||
color: rgb(var(--text-input)) !important;
|
||||
min-height: 48px;
|
||||
font-size: 16px;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus,
|
||||
input[type="number"]:focus,
|
||||
input[type="email"]:focus,
|
||||
input[type="search"]:focus,
|
||||
input[type="tel"]:focus,
|
||||
input[type="url"]:focus,
|
||||
input:not([type]):focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: rgb(var(--accent));
|
||||
box-shadow: 0 0 0 3px rgba(var(--ring) / 0.2), inset 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
outline: none;
|
||||
}
|
||||
input::placeholder, textarea::placeholder {
|
||||
color: rgb(var(--muted));
|
||||
opacity: 0.7;
|
||||
}
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca3af' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 14px center;
|
||||
padding-right: 40px;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
@apply w-5 h-5 rounded;
|
||||
appearance: auto;
|
||||
-webkit-appearance: auto;
|
||||
accent-color: rgb(var(--accent));
|
||||
min-height: auto;
|
||||
}
|
||||
label {
|
||||
@apply block text-sm font-medium mb-1.5;
|
||||
color: rgb(var(--muted));
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── Typography ── */
|
||||
.subtle {
|
||||
color: rgb(var(--muted));
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
@apply sticky top-0 z-30 border-b;
|
||||
background-color: rgba(var(--bg) / 0.8);
|
||||
border-color: rgb(var(--border));
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
}
|
||||
.header-inner {
|
||||
@apply container flex items-center justify-between py-3 gap-3;
|
||||
}
|
||||
.header-title {
|
||||
@apply text-xs sm:text-sm font-semibold tracking-tight;
|
||||
}
|
||||
.header-logo {
|
||||
@apply h-8 sm:h-10 w-auto;
|
||||
}
|
||||
.header-right {
|
||||
@apply flex items-center gap-1.5 sm:gap-3 flex-shrink-0;
|
||||
}
|
||||
.header-stat {
|
||||
@apply hidden sm:flex items-center gap-1.5 text-sm;
|
||||
color: rgb(var(--muted));
|
||||
}
|
||||
.header-btn {
|
||||
@apply px-2.5 sm:px-3 py-1.5 text-xs flex items-center gap-1 rounded-lg;
|
||||
min-height: 36px;
|
||||
border: 1px solid rgb(var(--border));
|
||||
background-color: rgb(var(--surface));
|
||||
color: rgb(var(--text));
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background-color: rgb(var(--border));
|
||||
}
|
||||
|
||||
/* ── Flag Selector ── */
|
||||
.flag-selector {
|
||||
@apply flex justify-center items-stretch gap-2 sm:gap-3 mb-5;
|
||||
}
|
||||
.flag-option {
|
||||
@apply flex flex-col items-center gap-1.5 sm:gap-2 rounded-xl px-3 sm:px-4 py-2.5 text-xs font-medium transition-all duration-200;
|
||||
color: rgb(var(--muted));
|
||||
background: rgb(var(--surface));
|
||||
border: 1px solid rgb(var(--border));
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
}
|
||||
.flag-option img {
|
||||
@apply w-12 sm:w-16 rounded-md transition-transform duration-200;
|
||||
}
|
||||
.flag-option:hover {
|
||||
border-color: rgb(var(--muted));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.flag-option:hover img {
|
||||
@apply scale-105;
|
||||
}
|
||||
.flag-option.selected {
|
||||
border-color: rgb(var(--accent));
|
||||
box-shadow: 0 0 16px rgba(var(--glow) / 0.2);
|
||||
color: rgb(var(--text));
|
||||
background: rgba(var(--accent) / 0.08);
|
||||
}
|
||||
|
||||
/* ── Dashboard / Grid ── */
|
||||
.dashboard {
|
||||
@apply grid gap-4 sm:gap-6 mt-5 sm:mt-6 grid-cols-1;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
.side-cards {
|
||||
@apply grid gap-4 sm:gap-5 grid-cols-1 md:grid-cols-3;
|
||||
}
|
||||
|
||||
/* ── Misc Components ── */
|
||||
.preview-card {
|
||||
@apply rounded-2xl p-4 sm:p-8 text-center;
|
||||
background-color: rgba(var(--surface) / 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.toast {
|
||||
@apply fixed bottom-4 sm:bottom-6 left-1/2 -translate-x-1/2 px-5 py-3 rounded-xl shadow-lg z-50 text-sm font-medium;
|
||||
background-color: rgb(var(--accent));
|
||||
color: rgb(var(--accent-contrast));
|
||||
box-shadow: 0 8px 24px rgba(var(--glow) / 0.3);
|
||||
}
|
||||
.stat-card {
|
||||
@apply text-center;
|
||||
}
|
||||
.stat-value {
|
||||
@apply text-3xl font-bold text-accent;
|
||||
}
|
||||
.stat-label {
|
||||
@apply mt-1 text-sm;
|
||||
color: rgb(var(--muted));
|
||||
}
|
||||
.button-row {
|
||||
@apply flex gap-3 flex-wrap mt-5;
|
||||
}
|
||||
.plate-block {
|
||||
@apply mb-5 sm:mb-6 pb-5 sm:pb-6 border-b last:border-b-0 flex flex-col gap-5 md:flex-row md:gap-8;
|
||||
border-color: rgb(var(--border));
|
||||
}
|
||||
|
||||
/* ── Spacing ── */
|
||||
.mt { @apply mt-4; }
|
||||
.mb { @apply mb-2; }
|
||||
.mb-lg { @apply mb-6; }
|
||||
.ml { @apply ml-2; }
|
||||
|
||||
/* ── Forms ── */
|
||||
.form-row {
|
||||
@apply flex flex-col gap-1.5 mb-3;
|
||||
}
|
||||
.form-row.checkbox {
|
||||
@apply flex-row items-center gap-3;
|
||||
}
|
||||
|
||||
/* ── Landing Page ── */
|
||||
.start-container {
|
||||
@apply flex items-center justify-center px-4;
|
||||
height: calc(100vh - 4rem);
|
||||
height: calc(100dvh - 4rem);
|
||||
}
|
||||
.start-card {
|
||||
@apply flex flex-col gap-4 w-full max-w-sm text-center;
|
||||
}
|
||||
|
||||
/* ── Hero Background Pattern ── */
|
||||
.hero-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background-color: rgb(var(--bg));
|
||||
background-image: radial-gradient(rgba(var(--border)) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* ── Preview images ── */
|
||||
.plate-preview {
|
||||
@apply block mt-3 sm:mt-4 w-full h-auto rounded-lg;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
24
Kenteken-Gen-1-main/src/frontend/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#111111" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f3f3f3" media="(prefers-color-scheme: light)" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="icon" type="image/png" href="./assets/CarKiddologo.png" />
|
||||
<link rel="apple-touch-icon" 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>
|
||||
39
Kenteken-Gen-1-main/src/frontend/main.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, 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 (
|
||||
<BrowserRouter>
|
||||
<AnimatedRoutes />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
76
Kenteken-Gen-1-main/src/frontend/print.css
Normal file
@@ -0,0 +1,76 @@
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
.a4-preview {
|
||||
width: 100%;
|
||||
max-width: 210mm;
|
||||
min-height: 297mm;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #ccc;
|
||||
padding: 5mm;
|
||||
box-sizing: border-box;
|
||||
background: white;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.a4-preview {
|
||||
width: 210mm;
|
||||
padding: 10mm;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
html, body, #root {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
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;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
43
Kenteken-Gen-1-main/src/frontend/routes/PrintPage.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
|
||||
const handlePrint = () => {
|
||||
const prev = document.title;
|
||||
document.title = ' ';
|
||||
window.print();
|
||||
document.title = prev;
|
||||
};
|
||||
|
||||
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={handlePrint}>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>
|
||||
);
|
||||
}
|
||||
33
Kenteken-Gen-1-main/src/frontend/tokens.css
Normal file
@@ -0,0 +1,33 @@
|
||||
:root {
|
||||
--bg: 10 10 10;
|
||||
--surface: 23 23 23;
|
||||
--surface-input: 30 30 30;
|
||||
--muted: 161 161 161;
|
||||
--accent: 59 130 246;
|
||||
--accent-hover: 37 99 235;
|
||||
--accent-contrast: 255 255 255;
|
||||
--border: 38 38 38;
|
||||
--ring: 59 130 246;
|
||||
--shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
--text: 250 250 250;
|
||||
--text-input: 250 250 250;
|
||||
--glow: 59 130 246;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: 250 250 250;
|
||||
--surface: 255 255 255;
|
||||
--surface-input: 245 245 245;
|
||||
--muted: 115 115 115;
|
||||
--accent: 59 130 246;
|
||||
--accent-hover: 37 99 235;
|
||||
--accent-contrast: 255 255 255;
|
||||
--border: 237 237 237;
|
||||
--ring: 59 130 246;
|
||||
--shadow: 0 8px 32px rgba(0,0,0,0.08);
|
||||
--text: 23 23 23;
|
||||
--text-input: 23 23 23;
|
||||
--glow: 59 130 246;
|
||||
}
|
||||
}
|
||||
0
Kenteken-Gen-1-main/src/kenteken_gen/__init__.py
Normal file
28
Kenteken-Gen-1-main/tailwind.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export default {
|
||||
content: ['./src/frontend/**/*.{html,js,jsx,ts,tsx}'],
|
||||
darkMode: 'media',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: 'rgb(var(--accent) / <alpha-value>)',
|
||||
'accent-hover': 'rgb(var(--accent-hover) / <alpha-value>)',
|
||||
surface: 'rgb(var(--surface) / <alpha-value>)',
|
||||
border: 'rgb(var(--border) / <alpha-value>)',
|
||||
ring: 'rgb(var(--ring) / <alpha-value>)',
|
||||
glow: 'rgb(var(--glow) / <alpha-value>)',
|
||||
},
|
||||
borderRadius: {
|
||||
'2xl': '1rem',
|
||||
},
|
||||
boxShadow: {
|
||||
fluent: '0 8px 32px rgba(0,0,0,0.25)',
|
||||
glow: '0 0 20px rgba(59,130,246,0.3)',
|
||||
'glow-lg': '0 0 40px rgba(59,130,246,0.4)',
|
||||
},
|
||||
fontSize: {
|
||||
base: ['clamp(1rem,0.9rem+0.2vw,1.125rem)', { lineHeight: '1.5' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
0
Kenteken-Gen-1-main/tests/.gitkeep
Normal file
17
Kenteken-Gen-1-main/vite.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: 'src/frontend',
|
||||
base: '/',
|
||||
build: {
|
||||
outDir: '../../../dist',
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:3456'
|
||||
}
|
||||
}
|
||||
});
|
||||