feat:vriendenboek

This commit is contained in:
2026-04-07 15:38:36 +02:00
parent 89ad7d16c3
commit a97b1cf526
36 changed files with 2856 additions and 349 deletions

View File

@@ -8,6 +8,7 @@
"serve": "vite preview",
"start": "vite",
"check-types": "tsc --noEmit",
"check-types:worker": "tsc --noEmit -p tsconfig.worker.json",
"dev": "vite dev",
"dev:bare": "vite dev"
},
@@ -17,6 +18,7 @@
"@zias/content": "workspace:*",
"@zias/env": "workspace:*",
"canvas-confetti": "^1.9.4",
"html-to-image": "^1.11.13",
"react": "19.2.3",
"react-dom": "19.2.3"
},
@@ -31,6 +33,8 @@
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"typescript": "catalog:",
"@cloudflare/vite-plugin": "^1.13.14",
"@cloudflare/workers-types": "^4.20250805.0",
"vite": "^6.2.2"
}
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from "react";
import { useColors } from "@/components/color";
import { GardenSprite } from "./garden-sprite";
import type { SpriteDefinition } from "./types";
import { fromPixelGrid } from "./utils";
interface DrawingMeta {
id: string;
naam: string;
drawing_data: string;
}
interface PlacedDrawing {
instanceId: string;
spriteName: string;
authorNaam: string;
definition: SpriteDefinition;
x: number;
skyOffset: number; // vh units, 0 = ground
rotation: number;
animDelay: number;
moves: boolean;
sky: boolean;
moveDuration: number;
moveRtl: boolean;
}
function seededRand(seed: string): () => number {
let h = 0x811c9dc5;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return () => {
h ^= h << 13;
h ^= h >> 17;
h ^= h << 5;
return (h >>> 0) / 0x100000000;
};
}
function buildPlaced(items: DrawingMeta[]): PlacedDrawing[] {
const result: PlacedDrawing[] = [];
for (let idx = 0; idx < items.length; idx++) {
const { id, naam, drawing_data } = items[idx]!;
let parsed: { grid: (string | null)[][]; name?: string; moves?: boolean; sky?: boolean };
try {
parsed = JSON.parse(drawing_data);
if (!parsed || !Array.isArray(parsed.grid)) continue;
} catch {
continue;
}
const rng = seededRand(id);
const xBase = (idx / Math.max(items.length, 1)) * 86 + 4;
const spriteName = parsed.name?.trim() || naam;
const moves = parsed.moves ?? false;
const sky = parsed.sky ?? false;
result.push({
instanceId: id,
spriteName,
authorNaam: naam,
definition: fromPixelGrid(
{ id: `drawing-${id}`, name: spriteName, pixelSize: 4, flippable: moves },
parsed.grid,
),
x: Math.max(3, Math.min(91, xBase + (rng() - 0.5) * 9)),
skyOffset: sky ? 30 + rng() * 20 : 0, // 3050vh above ground
rotation: moves ? 0 : (rng() - 0.5) * 8,
animDelay: rng() * 15,
moves,
sky,
moveDuration: 35 + rng() * 30, // 3565s per pass
moveRtl: rng() > 0.5,
});
}
return result;
}
interface GardenDrawingsProps {
visible: boolean;
}
export function GardenDrawings({ visible }: GardenDrawingsProps) {
const [drawings, setDrawings] = useState<PlacedDrawing[]>([]);
const fetched = useRef(false);
const { partyMode: _partyMode } = useColors(); // subscribe to re-render on mode change
useEffect(() => {
if (!visible || fetched.current) return;
fetched.current = true;
fetch("/api/vriendenboek/drawings")
.then((r) => (r.ok ? (r.json() as Promise<DrawingMeta[]>) : []))
.then((data) => setDrawings(buildPlaced(data)))
.catch(() => {});
}, [visible]);
if (!visible || drawings.length === 0) return null;
return (
<>
{drawings.map((d) => {
if (d.moves) {
// Walker-style: full-width scroll animation, position determined by animation
return (
<div
key={d.instanceId}
className={`garden-drawing-walker${d.sky ? " garden-drawing-walker-sky" : ""}`}
style={{
bottom: d.sky ? `${d.skyOffset}vh` : "9px",
animationDuration: `${d.moveDuration}s`,
animationDelay: `${d.animDelay}s`,
animationDirection: d.moveRtl ? "reverse" : "normal",
}}
>
<div className="garden-drawing-inner">
<div className="garden-drawing-bubble">my name is {d.spriteName}</div>
<GardenSprite
definition={d.definition}
partyMode={true}
flip={d.moveRtl}
/>
</div>
</div>
);
}
// Still sprite: fixed position
return (
<div
key={d.instanceId}
className="garden-drawing-note"
style={{
left: `${d.x}%`,
bottom: d.sky ? `${d.skyOffset}vh` : "5px",
rotate: `${d.rotation}deg`,
animationDelay: `${d.animDelay}s`,
}}
>
<div className="garden-drawing-bubble">my name is {d.spriteName}</div>
<GardenSprite definition={d.definition} partyMode={true} />
</div>
);
})}
</>
);
}

View File

@@ -1,74 +1,12 @@
import { useMemo } from "react";
import { useColors } from "@/components/color";
import { GardenSprite } from "./garden-sprite";
import { BUNNY, CAT, PARTY_HAT } from "./sprites";
import { GardenDrawings } from "./garden-drawings";
import { useGarden } from "./use-garden";
/** Simple SVG cloud shape */
function Cloud({ scale = 1 }: { scale?: number }) {
return (
<svg
width={Math.round(160 * scale)}
height={Math.round(60 * scale)}
viewBox="0 0 160 60"
fill="white"
aria-hidden="true"
>
<ellipse cx="80" cy="40" rx="70" ry="22" />
<ellipse cx="55" cy="30" rx="35" ry="26" />
<ellipse cx="105" cy="32" rx="30" ry="22" />
<ellipse cx="78" cy="20" rx="28" ry="22" />
</svg>
);
}
/** Pixel-art bird — two flapping poses blended via CSS */
function PixelBird({ scale = 1 }: { scale?: number }) {
const px = Math.max(1, Math.round(4 * scale));
// Simple M-wing bird shape
const bitmap = ["G.G", ".G."];
const cols = 3;
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${px}px)`,
gridTemplateRows: `repeat(2, ${px}px)`,
imageRendering: "pixelated",
opacity: 0.75,
}}
aria-hidden="true"
>
{bitmap.flatMap((row, ri) =>
Array.from(row).map((char, ci) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: stable grid
key={`${ri}-${ci}`}
style={{
width: px,
height: px,
backgroundColor: char === "G" ? "#1a1a1a" : "transparent",
}}
/>
)),
)}
</div>
);
}
/**
* Full-screen overlay that renders the digital garden scene:
* sky, sun, clouds, birds, walking animals, and ground sprites.
* Full-screen overlay that renders the digital garden scene.
* pointer-events: none — never blocks page interaction.
*/
export function GardenLayer() {
const { gardenVisible, placedSprites, registeredSprites } = useGarden();
const { partyMode } = useColors();
const spriteMap = useMemo(
() => Object.fromEntries(registeredSprites.map((s) => [s.id, s])),
[registeredSprites],
);
const { gardenVisible } = useGarden();
return (
<div
@@ -80,91 +18,8 @@ export function GardenLayer() {
<div className="garden-sun-disc" />
</div>
{/* Clouds */}
<div className="garden-clouds">
<div className="garden-cloud garden-cloud-1">
<Cloud scale={1.1} />
</div>
<div className="garden-cloud garden-cloud-2">
<Cloud scale={0.75} />
</div>
<div className="garden-cloud garden-cloud-3">
<Cloud scale={0.9} />
</div>
<div className="garden-cloud garden-cloud-4">
<Cloud scale={0.6} />
</div>
</div>
{/* Birds */}
<div className="garden-birds">
<div className="garden-bird garden-bird-1">
<div style={{ display: "flex", gap: "16px" }}>
<PixelBird scale={1.2} />
<PixelBird scale={0.9} />
<PixelBird scale={1.1} />
</div>
</div>
<div className="garden-bird garden-bird-2">
<div style={{ display: "flex", gap: "24px" }}>
<PixelBird scale={1} />
<PixelBird scale={0.8} />
</div>
</div>
<div className="garden-bird garden-bird-3">
<PixelBird scale={1.3} />
</div>
</div>
{/* Walking animals */}
<div className="garden-walker garden-walker-1">
<GardenSprite definition={BUNNY} partyMode={partyMode} scale={0.9} />
</div>
<div className="garden-walker garden-walker-2">
<GardenSprite
definition={CAT}
partyMode={partyMode}
scale={0.85}
flip
/>
</div>
{/* Ground sprites */}
{placedSprites.map((placed) => {
const def = spriteMap[placed.spriteId];
if (!def) return null;
const showHat = partyMode && !def.noHat;
return (
<div
key={placed.instanceId}
className="garden-sprite-instance"
style={{
left: `${placed.x}%`,
bottom: `${5 + placed.yOffset}px`,
animationDelay: `${placed.animDelay}s`,
}}
>
<div style={{ position: "relative", display: "inline-block" }}>
{showHat && (
<div className="garden-hat">
<GardenSprite
definition={PARTY_HAT}
partyMode={true}
scale={placed.scale}
/>
</div>
)}
<GardenSprite
definition={def}
partyMode={partyMode}
flip={placed.flip}
scale={placed.scale}
/>
</div>
</div>
);
})}
{/* Friend drawings */}
<GardenDrawings visible={gardenVisible} />
<div className="garden-ground" />
</div>

View File

@@ -1,7 +1,6 @@
export { GardenLayer } from "./garden-layer";
export { GardenProvider } from "./garden-provider";
export { GardenSprite } from "./garden-sprite";
export { DEFAULT_SPRITES, PARTY_HAT } from "./sprites";
export type {
GardenContextValue,
GardenProviderProps,

View File

@@ -1,160 +1,6 @@
import type { SpriteDefinition } from "./types";
/**
* Default garden sprites — hand-crafted pixel bitmaps.
*
* Bitmap key:
* '.' = transparent
* Any other character maps to an entry in `palette` / `monoPalette`.
*
* To add Aseprite-drawn sprites, use `fromPixelGrid()` from `./utils`.
* Friends can contribute via the `customSprites` prop on GardenProvider.
*/
const grassA: SpriteDefinition = {
id: "grass_a",
name: "Short Grass",
bitmap: [".G.", "GGG"],
palette: { G: "#4ade80" },
monoPalette: { G: "#1c1c1c" },
pixelSize: 4,
weight: 4,
noHat: true,
};
const grassB: SpriteDefinition = {
id: "grass_b",
name: "Tall Grass",
bitmap: ["G.G", "G.G", ".G.", "GGG"],
palette: { G: "#22c55e" },
monoPalette: { G: "#222222" },
pixelSize: 4,
weight: 3,
noHat: true,
};
const grassC: SpriteDefinition = {
id: "grass_c",
name: "Wispy Grass",
bitmap: [".G.G.", "G.G.G", ".GGG.", "GGGGG"],
palette: { G: "#86efac" },
monoPalette: { G: "#333333" },
pixelSize: 4,
weight: 3,
noHat: true,
};
const daisy: SpriteDefinition = {
id: "daisy",
name: "Daisy",
bitmap: [
"..W.W..",
".W.W.W.",
"..WYW..",
".WWYWW.",
"..WYW..",
".W.W.W.",
"..W.W..",
"...S...",
"...S...",
"...S...",
],
palette: { W: "#ffffff", Y: "#facc15", S: "#15803d" },
monoPalette: { W: "#e8e8e8", Y: "#888888", S: "#1a1a1a" },
pixelSize: 4,
weight: 2,
};
const tulip: SpriteDefinition = {
id: "tulip",
name: "Tulip",
bitmap: [
"..P..",
".PPP.",
"PPPPP",
".PPP.",
"..P..",
"..S..",
"..S..",
".SSS.",
"..S..",
"..S..",
],
palette: { P: "#f472b6", S: "#166534" },
monoPalette: { P: "#777777", S: "#111111" },
pixelSize: 4,
weight: 2,
};
const mushroom: SpriteDefinition = {
id: "mushroom",
name: "Mushroom",
bitmap: ["..RRR..", ".RRRRR.", "RRWRWRR", ".RRRRR.", "..www..", "...w..."],
palette: { R: "#ef4444", W: "#fef2f2", w: "#f5f5f5" },
monoPalette: { R: "#2a2a2a", W: "#cccccc", w: "#e8e8e8" },
pixelSize: 4,
weight: 1,
};
const tree: SpriteDefinition = {
id: "tree",
name: "Small Tree",
bitmap: [
"...L...",
"..LLL..",
".LLLLL.",
"LLLLLLL",
"..LLL..",
".LLLLL.",
"...T...",
"...T...",
"...T...",
],
palette: { L: "#16a34a", T: "#92400e" },
monoPalette: { L: "#1a1a1a", T: "#555555" },
pixelSize: 4,
weight: 1,
flippable: false,
};
const rock: SpriteDefinition = {
id: "rock",
name: "Rock",
bitmap: [".RRR.", "RRRRR", ".RRR."],
palette: { R: "#9ca3af" },
monoPalette: { R: "#aaaaaa" },
pixelSize: 4,
weight: 2,
flippable: false,
noHat: true,
};
const pebble: SpriteDefinition = {
id: "pebble",
name: "Pebble",
bitmap: [".P.", "PPP"],
palette: { P: "#6b7280" },
monoPalette: { P: "#999999" },
pixelSize: 3,
weight: 3,
flippable: false,
noHat: true,
};
const fernA: SpriteDefinition = {
id: "fern_a",
name: "Small Fern",
bitmap: ["F....F", "FF..FF", ".FFFF.", "..FF..", "..FF..", "..FF.."],
palette: { F: "#4ade80" },
monoPalette: { F: "#1c1c1c" },
pixelSize: 4,
weight: 2,
};
/**
* Party hat — rendered above sprites when party mode is active.
* Not placed randomly (weight: 0).
*/
/** Party hat — rendered above user sprites in party mode. */
export const PARTY_HAT: SpriteDefinition = {
id: "_party_hat",
name: "Party Hat",
@@ -166,33 +12,5 @@ export const PARTY_HAT: SpriteDefinition = {
noHat: true,
};
export const BUNNY: SpriteDefinition = {
id: "_bunny",
name: "Bunny",
bitmap: ["W.W..", "WWW..", "WWWW.", "WWWWW", ".www.", "W.W.."],
palette: { W: "#f5f5f5", w: "#e0d0d0" },
monoPalette: { W: "#cccccc", w: "#aaaaaa" },
pixelSize: 4,
};
export const CAT: SpriteDefinition = {
id: "_cat",
name: "Cat",
bitmap: ["T..T.", "TTTTT", "TtTtT", "TTTTT", ".TTT.", "T...T"],
palette: { T: "#d4a96a", t: "#c0874a" },
monoPalette: { T: "#888888", t: "#666666" },
pixelSize: 4,
};
export const DEFAULT_SPRITES: SpriteDefinition[] = [
grassA,
grassB,
grassC,
daisy,
tulip,
mushroom,
tree,
rock,
pebble,
fernA,
];
/** No built-in sprites — the garden is filled entirely by user drawings. */
export const DEFAULT_SPRITES: SpriteDefinition[] = [];

View File

@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from "react";
const BG = "#fafaf8";
const INK = "#1a1a18";
interface DrawingCanvasProps {
onChange: (dataUrl: string | null) => void;
}
export function DrawingCanvas({ onChange }: DrawingCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [isEmpty, setIsEmpty] = useState(true);
const lastPoint = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = BG;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineWidth = 3;
ctx.strokeStyle = INK;
ctx.fillStyle = INK;
}, []);
const getPos = (e: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (canvas.width / rect.width),
y: (e.clientY - rect.top) * (canvas.height / rect.height),
};
};
const startDrawing = (e: React.PointerEvent<HTMLCanvasElement>) => {
e.preventDefault();
const canvas = canvasRef.current!;
canvas.setPointerCapture(e.pointerId);
const pos = getPos(e);
const ctx = canvas.getContext("2d")!;
ctx.beginPath();
ctx.arc(pos.x, pos.y, 1.5, 0, Math.PI * 2);
ctx.fill();
lastPoint.current = pos;
setIsDrawing(true);
if (isEmpty) setIsEmpty(false);
};
const draw = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
e.preventDefault();
const canvas = canvasRef.current!;
const ctx = canvas.getContext("2d")!;
const pos = getPos(e);
const from = lastPoint.current ?? pos;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastPoint.current = pos;
};
const stopDrawing = () => {
if (!isDrawing) return;
setIsDrawing(false);
lastPoint.current = null;
onChange(canvasRef.current!.toDataURL("image/png"));
};
const clear = () => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = BG;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = INK;
ctx.strokeStyle = INK;
setIsEmpty(true);
onChange(null);
};
return (
<div className="vb-field">
<div className="vb-field-header">
<span className="vb-label">Teken iets voor in de tuin</span>
{!isEmpty && (
<button type="button" className="vb-draw-clear" onClick={clear}>
wissen
</button>
)}
</div>
<div className="vb-draw-wrap">
<canvas
ref={canvasRef}
width={600}
height={220}
className="vb-draw-canvas"
onPointerDown={startDrawing}
onPointerMove={draw}
onPointerUp={stopDrawing}
onPointerCancel={stopDrawing}
onPointerLeave={stopDrawing}
/>
{isEmpty && (
<span className="vb-draw-placeholder">teken hier iets...</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { Question } from "../types";
interface FormFieldProps {
question: Question;
value: string;
onChange: (value: string) => void;
}
export function FormField({ question, value, onChange }: FormFieldProps) {
const remaining = question.maxLength - value.length;
const showCounter = remaining <= 20;
return (
<div className="vb-field">
<div className="vb-field-header">
<label className="vb-label" htmlFor={question.id}>
{question.label}
{question.required && <span className="vb-required"> *</span>}
</label>
{showCounter && (
<span className={`vb-char-count ${remaining <= 5 ? "vb-char-count--warn" : ""}`}>
{remaining}
</span>
)}
</div>
{question.multiline ? (
<textarea
id={question.id}
className="vb-input"
placeholder={question.placeholder}
value={value}
maxLength={question.maxLength}
onChange={(e) => onChange(e.target.value)}
rows={3}
/>
) : (
<input
id={question.id}
type="text"
className="vb-input"
placeholder={question.placeholder}
value={value}
maxLength={question.maxLength}
onChange={(e) => onChange(e.target.value)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useRef } from "react";
interface PhotoUploadProps {
imageDataUrl: string | null;
onFileChange: (file: File) => void;
}
export function PhotoUpload({ imageDataUrl, onFileChange }: PhotoUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) onFileChange(file);
};
return (
<div className="vb-field">
<span className="vb-label">Foto</span>
<button
type="button"
className="vb-photo-btn"
onClick={() => inputRef.current?.click()}
aria-label="Upload foto"
>
{imageDataUrl ? (
<img src={imageDataUrl} alt="jouw foto" className="vb-photo-preview" />
) : (
<span className="vb-photo-placeholder">+ foto toevoegen</span>
)}
</button>
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleChange}
className="hidden"
/>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from "react";
import type { DrawingData, PixelGrid } from "../types";
const GRID = 16;
const PALETTE: string[] = [
"#1a1a18", // black
"#888884", // gray
"#f0ebe0", // paper white
"#c87840", // brown
"#7ab34b", // grass green
"#3a6828", // dark green
"#5898d0", // sky blue
"#d84840", // red
"#e8c030", // yellow
"#e87828", // orange
"#c060a0", // pink
"#a8d0f0", // light blue
];
function emptyGrid(): PixelGrid {
return Array.from({ length: GRID }, () => Array<string | null>(GRID).fill(null));
}
interface PixelCanvasProps {
initialData?: DrawingData | null;
onChange: (data: DrawingData | null) => void;
}
export function PixelCanvas({ initialData, onChange }: PixelCanvasProps) {
const [grid, setGrid] = useState<PixelGrid>(() => initialData?.grid ?? emptyGrid());
const [name, setName] = useState(() => initialData?.name ?? "");
const [moves, setMoves] = useState(() => initialData?.moves ?? false);
const [sky, setSky] = useState(() => initialData?.sky ?? false);
const [color, setColor] = useState<string | null>(PALETTE[0]!);
const painting = useRef(false);
const gridRef = useRef<HTMLDivElement>(null);
const onChangeRef = useRef(onChange);
useEffect(() => { onChangeRef.current = onChange; });
useEffect(() => {
const hasPixel = grid.some((row) => row.some((c) => c !== null));
onChangeRef.current(hasPixel ? { grid, name, moves, sky } : null);
}, [grid, name, moves, sky]);
const getCell = (e: React.PointerEvent): [number, number] | null => {
const div = gridRef.current;
if (!div) return null;
const rect = div.getBoundingClientRect();
const col = Math.floor(((e.clientX - rect.left) / rect.width) * GRID);
const row = Math.floor(((e.clientY - rect.top) / rect.height) * GRID);
if (row < 0 || row >= GRID || col < 0 || col >= GRID) return null;
return [row, col];
};
const paintAt = (row: number, col: number) => {
setGrid((prev) => {
if (prev[row]![col] === color) return prev;
const next = prev.map((r) => [...r]);
next[row]![col] = color;
return next;
});
};
const onPointerDown = (e: React.PointerEvent) => {
e.preventDefault();
gridRef.current?.setPointerCapture(e.pointerId);
painting.current = true;
const cell = getCell(e);
if (cell) paintAt(cell[0], cell[1]);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!painting.current) return;
const cell = getCell(e);
if (cell) paintAt(cell[0], cell[1]);
};
const onPointerUp = () => { painting.current = false; };
const clear = () => setGrid(emptyGrid);
const hasPixel = grid.some((row) => row.some((c) => c !== null));
return (
<div className="vb-field">
<div className="vb-field-header">
<span className="vb-label">Teken iets voor in de tuin</span>
{hasPixel && (
<button type="button" className="vb-draw-clear" onClick={clear}>
wissen
</button>
)}
</div>
{/* Pixel grid */}
<div
ref={gridRef}
className="vb-pixel-grid"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{grid.map((row, ri) =>
row.map((cell, ci) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: stable grid positions
key={`${ri}-${ci}`}
className="vb-pixel-cell"
style={{ backgroundColor: cell ?? undefined }}
/>
)),
)}
{!hasPixel && (
<span className="vb-pixel-placeholder">teken hier iets...</span>
)}
</div>
{/* Color palette */}
<div className="vb-pixel-palette">
{PALETTE.map((c) => (
<button
key={c}
type="button"
className={`vb-pixel-swatch${color === c ? " selected" : ""}`}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
aria-label={c}
/>
))}
<button
type="button"
className={`vb-pixel-swatch vb-pixel-eraser${color === null ? " selected" : ""}`}
onClick={() => setColor(null)}
aria-label="gum"
>
</button>
</div>
{/* Sprite options */}
<div className="vb-pixel-options">
{/* Name */}
<input
type="text"
className="vb-input vb-pixel-name"
placeholder="geef je tekening een naam..."
maxLength={40}
value={name}
onChange={(e) => setName(e.target.value)}
/>
{/* Move + sky toggles */}
<div className="vb-pixel-toggles">
<button
type="button"
className={`vb-pixel-toggle${moves ? " active" : ""}`}
onClick={() => setMoves((v) => !v)}
>
{moves ? "beweegt 🚶" : "staat stil"}
</button>
<button
type="button"
className={`vb-pixel-toggle${sky ? " active" : ""}`}
onClick={() => setSky((v) => !v)}
>
{sky ? "in de lucht ☁" : "op het gras"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { QUESTIONS } from "../questions";
import type { Answers, DrawingData } from "../types";
import { FormField } from "./form-field";
import { PixelCanvas } from "./pixel-canvas";
import { PhotoUpload } from "./photo-upload";
// Match result-card layout exactly
const LEFT_IDS = ["verjaardag", "woonplaats", "kleur"];
const EXCLUDED_IDS = ["naam", ...LEFT_IDS, "bericht"];
interface VriendenboekFormProps {
answers: Answers;
imageDataUrl: string | null;
drawingData: DrawingData | null;
submitting: boolean;
error: string | null;
onAnswerChange: (id: string, value: string) => void;
onImageChange: (file: File) => void;
onDrawingChange: (data: DrawingData | null) => void;
onSubmit: (e: React.FormEvent) => void;
}
export function VriendenboekForm({
answers,
imageDataUrl,
drawingData,
submitting,
error,
onAnswerChange,
onImageChange,
onDrawingChange,
onSubmit,
}: VriendenboekFormProps) {
const naamQ = QUESTIONS.find((q) => q.id === "naam")!;
const leftQuestions = QUESTIONS.filter((q) => LEFT_IDS.includes(q.id));
const mainQuestions = QUESTIONS.filter((q) => !EXCLUDED_IDS.includes(q.id));
const berichtQ = QUESTIONS.find((q) => q.id === "bericht")!;
return (
<form onSubmit={onSubmit} className="vb-card">
{/* Header — naam */}
<div className="vb-card-header">
<FormField
question={naamQ}
value={answers.naam ?? ""}
onChange={(v) => onAnswerChange("naam", v)}
/>
</div>
{/* Two-page spread — mirrors result card */}
<div className="vb-spread">
{/* Left page — photo + basic fields */}
<div className="vb-page-left">
<PhotoUpload
imageDataUrl={imageDataUrl}
onFileChange={onImageChange}
/>
<div className="vb-page-basic">
{leftQuestions.map((q) => (
<FormField
key={q.id}
question={q}
value={answers[q.id] ?? ""}
onChange={(v) => onAnswerChange(q.id, v)}
/>
))}
</div>
</div>
{/* Spine divider */}
<div className="vb-spine" aria-hidden />
{/* Right page — Q&A grid + bericht */}
<div className="vb-page-right">
<div className="vb-qa-grid">
{mainQuestions.map((q) => (
<FormField
key={q.id}
question={q}
value={answers[q.id] ?? ""}
onChange={(v) => onAnswerChange(q.id, v)}
/>
))}
</div>
<div className="vb-bericht-block">
<FormField
question={berichtQ}
value={answers.bericht ?? ""}
onChange={(v) => onAnswerChange("bericht", v)}
/>
</div>
</div>
</div>
{/* Drawing */}
<div className="vb-drawing-section">
<PixelCanvas initialData={drawingData} onChange={onDrawingChange} />
</div>
{/* Footer — submit */}
<div className="vb-form-footer">
{error && (
<p className="text-small opacity-60" role="alert">
{error}
</p>
)}
<button type="submit" className="vb-submit" disabled={submitting}>
{submitting ? "even wachten..." : "verzenden →"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,22 @@
export function useImageResize(maxPx = 500) {
const resize = (file: File): Promise<string> =>
new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (ev) => {
const src = ev.target?.result as string;
const img = new window.Image();
img.onload = () => {
const canvas = document.createElement("canvas");
const scale = Math.min(maxPx / img.width, maxPx / img.height, 1);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
canvas.getContext("2d")!.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL("image/jpeg", 0.8));
};
img.src = src;
};
reader.readAsDataURL(file);
});
return { resize };
}

View File

@@ -0,0 +1,69 @@
import { useState } from "react";
import { emptyAnswers } from "../questions";
import type { Answers, DrawingData } from "../types";
import { useImageResize } from "./use-image-resize";
export function useVriendenboek() {
const [answers, setAnswers] = useState<Answers>(emptyAnswers);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imageDataUrl, setImageDataUrl] = useState<string | null>(null);
const [drawingData, setDrawingData] = useState<DrawingData | null>(null);
const [submittedId, setSubmittedId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { resize } = useImageResize();
const handleImageChange = async (file: File) => {
setImageFile(file);
const dataUrl = await resize(file);
setImageDataUrl(dataUrl);
};
const submit = async () => {
if (!answers.naam?.trim()) {
setError("Vul je naam in.");
return;
}
setSubmitting(true);
setError(null);
try {
const fd = new FormData();
fd.append("answers", JSON.stringify(answers));
if (imageFile) fd.append("photo", imageFile);
if (drawingData) fd.append("drawing_data", JSON.stringify(drawingData));
const res = await fetch("/api/vriendenboek", {
method: "POST",
body: fd,
});
if (!res.ok) throw new Error("submission failed");
const { id } = (await res.json()) as { id: string };
setSubmittedId(id);
} catch {
setError("Er ging iets mis. Probeer opnieuw.");
} finally {
setSubmitting(false);
}
};
const reset = () => {
setSubmittedId(null);
setError(null);
};
return {
answers,
setAnswers,
imageDataUrl,
handleImageChange,
drawingData,
setDrawingData,
submittedId,
submitting,
error,
submit,
reset,
};
}

View File

@@ -0,0 +1,7 @@
export { VriendenboekForm } from "./form/vriendenboek-form";
export { ResultPage } from "./result/result-page";
export { ResultCard } from "./result/result-card";
export { ExportCard } from "./result/export-card";
export { useVriendenboek } from "./hooks/use-vriendenboek";
export { QUESTIONS, emptyAnswers } from "./questions";
export type { Answers, Submission, Question, PixelGrid, DrawingData } from "./types";

View File

@@ -0,0 +1,94 @@
import type { Question } from "./types";
// ─── Edit this list to add, remove, or reorder questions ─────────────────────
export const QUESTIONS: Question[] = [
{ id: "naam", label: "Naam", placeholder: "jouw naam...", multiline: false, maxLength: 60, required: true },
{
id: "verjaardag",
label: "Verjaardag",
placeholder: "dd/mm/jjjj",
multiline: false,
maxLength: 20,
},
{
id: "woonplaats",
label: "Woonplaats",
placeholder: "waar woon je?",
multiline: false,
maxLength: 60,
},
{
id: "kleur",
label: "Favoriete kleur",
placeholder: "...",
multiline: false,
maxLength: 40,
},
{
id: "muziek",
label: "Favoriete artiest of album",
placeholder: "...",
multiline: false,
maxLength: 100,
},
{
id: "film",
label: "Favoriete film",
placeholder: "...",
multiline: false,
maxLength: 100,
},
{
id: "hobby",
label: "Wat doe je het liefst in je vrije tijd?",
placeholder: "...",
multiline: false,
maxLength: 120,
},
{
id: "superkracht",
label: "Als je één superkracht kon hebben...",
placeholder: "dan zou het zijn...",
multiline: false,
maxLength: 100,
},
{
id: "droom",
label: "Je grootste droom",
placeholder: "...",
multiline: false,
maxLength: 100,
},
{
id: "angst",
label: "Ik ben bang voor...",
placeholder: "...",
multiline: false,
maxLength: 100,
},
{
id: "guilty_pleasure",
label: "Mijn guilty pleasure is...",
placeholder: "vertel me alles",
multiline: false,
maxLength: 100,
},
{
id: "quote",
label: "Jouw levensquote",
placeholder: "...",
multiline: false,
maxLength: 150,
},
{
id: "bericht",
label: "Bericht aan Zias",
placeholder: "vertel me iets...",
multiline: true,
maxLength: 500,
},
];
export function emptyAnswers(): Record<string, string> {
return Object.fromEntries(QUESTIONS.map((q) => [q.id, ""]));
}

View File

@@ -0,0 +1,97 @@
import type { RefObject } from "react";
import { GardenSprite, fromPixelGrid } from "@/components/garden";
import { QUESTIONS } from "../questions";
import type { Answers, DrawingData } from "../types";
const LEFT_IDS = ["verjaardag", "woonplaats", "kleur"];
const SKIP_IDS = ["naam", ...LEFT_IDS, "bericht"];
interface ExportCardProps {
answers: Answers;
imageSrc: string | null;
drawingData?: DrawingData | null;
cardRef: RefObject<HTMLDivElement | null>;
}
export function ExportCard({ answers, imageSrc, drawingData, cardRef }: ExportCardProps) {
const name = answers.naam || "anoniem";
const mainQuestions = QUESTIONS.filter((q) => !SKIP_IDS.includes(q.id));
const bericht = answers.bericht;
return (
// Rendered off-screen; captured at 540×960 then exported @pixelRatio:2 → 1080×1920
<div className="vb-export-wrap" aria-hidden>
<div className="vb-export-story" ref={cardRef}>
{/* Photo */}
<div className="vb-export-story-photo-wrap">
<div className="vb-export-story-polaroid">
{imageSrc ? (
<img src={imageSrc} alt={name} crossOrigin="anonymous" />
) : (
<div className="vb-export-story-polaroid-empty">geen foto</div>
)}
</div>
</div>
{/* Name */}
<div className="vb-export-story-name">{name}</div>
{/* Basic info row */}
<div className="vb-export-story-basic">
{LEFT_IDS.map((id) => {
const q = QUESTIONS.find((q) => q.id === id)!;
return (
<div key={id} className="vb-export-field">
<span className="vb-export-label">{q.label}</span>
<span className="vb-export-value">{answers[id] || "—"}</span>
</div>
);
})}
</div>
{/* Q&A grid */}
<div className="vb-export-story-qa">
{mainQuestions.map((q) => (
<div key={q.id} className="vb-export-field">
<span className="vb-export-label">{q.label}</span>
<span
className="vb-export-value"
title={answers[q.id] || "—"}
>
{answers[q.id] || "—"}
</span>
</div>
))}
</div>
{/* Bericht */}
{bericht && (
<div className="vb-export-story-bericht">
<span className="vb-export-label">Bericht aan Zias</span>
<span className="vb-export-value" style={{ fontStyle: "italic" }}>
{bericht}
</span>
</div>
)}
{/* Drawing */}
{drawingData && (
<div className="vb-export-story-drawing">
<GardenSprite
definition={fromPixelGrid(
{ id: "drawing-export", name: drawingData.name || "tekening", pixelSize: 5 },
drawingData.grid,
)}
partyMode={true}
/>
</div>
)}
{/* Footer */}
<div className="vb-export-story-footer">
<span className="vb-export-stamp">vriendenboek · zias.be</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
interface PolaroidProps {
src: string | null;
alt: string;
}
export function Polaroid({ src, alt }: PolaroidProps) {
return (
<div className="vb-polaroid">
{src ? (
<img src={src} alt={alt} className="vb-polaroid-img" />
) : (
<div className="vb-polaroid-empty">
<span>geen foto</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { GardenSprite, fromPixelGrid } from "@/components/garden";
import { QUESTIONS } from "../questions";
import type { Answers, DrawingData } from "../types";
import { Polaroid } from "./polaroid";
const LEFT_IDS = ["verjaardag", "woonplaats", "kleur"];
const EXCLUDED_IDS = ["naam", ...LEFT_IDS];
interface ResultCardProps {
answers: Answers;
imageSrc: string | null;
drawingData?: DrawingData | null;
/** Compact mode for export (fixed dimensions, smaller text) */
compact?: boolean;
}
export function ResultCard({ answers, imageSrc, drawingData, compact = false }: ResultCardProps) {
const name = answers.naam || "anoniem";
const rightQuestions = QUESTIONS.filter((q) => !EXCLUDED_IDS.includes(q.id));
const bericht = answers.bericht;
const mainQuestions = rightQuestions.filter((q) => q.id !== "bericht");
return (
<div className={`vb-card ${compact ? "vb-card-compact" : ""}`}>
{/* Header — name */}
<div className="vb-card-header">
<span className="vb-card-title">{name}</span>
</div>
{/* Two-page spread */}
<div className="vb-spread">
{/* Left page — photo + basic info */}
<div className="vb-page-left">
<Polaroid src={imageSrc} alt={name} />
<div className="vb-page-basic">
{LEFT_IDS.map((id) => {
const q = QUESTIONS.find((q) => q.id === id)!;
return (
<div key={id} className="vb-result-field">
<span className="vb-result-label">{q.label}</span>
<span className="vb-result-value">{answers[id] || "—"}</span>
</div>
);
})}
</div>
</div>
{/* Spine divider */}
<div className="vb-spine" aria-hidden />
{/* Right page — Q&A */}
<div className="vb-page-right">
<div className="vb-qa-grid">
{mainQuestions.map((q) => (
<div key={q.id} className="vb-result-field">
<span className="vb-result-label">{q.label}</span>
<span className="vb-result-value">{answers[q.id] || "—"}</span>
</div>
))}
</div>
{bericht && (
<div className="vb-bericht-block">
<span className="vb-result-label">Bericht aan Zias</span>
<span className="vb-result-value vb-result-bericht">
{bericht}
</span>
</div>
)}
</div>
</div>
{drawingData && (
<div className="vb-drawing-block">
<span className="vb-result-label">
{drawingData.name || "tekening voor de tuin"}
</span>
<div className="vb-drawing-sprite">
<GardenSprite
definition={fromPixelGrid(
{ id: "drawing-preview", name: drawingData.name || "tekening", pixelSize: 6 },
drawingData.grid,
)}
partyMode={true}
/>
</div>
<span className="vb-drawing-meta">
{drawingData.sky ? "☁ in de lucht" : "🌱 op het gras"}
{" · "}
{drawingData.moves ? "beweegt" : "staat stil"}
</span>
</div>
)}
{/* Footer */}
<div className="vb-card-footer">
<span className="vb-stamp">zias.be</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { useRef, useState } from "react";
import { toPng } from "html-to-image";
import type { Answers, DrawingData } from "../types";
import { ResultCard } from "./result-card";
import { ExportCard } from "./export-card";
interface ResultPageProps {
answers: Answers;
/** Data URL (fresh submission) or `/api/...` path (shared link) */
imageSrc: string | null;
drawingData?: DrawingData | null;
submittedId: string;
onEdit: () => void;
hideActions?: boolean;
}
export function ResultPage({
answers,
imageSrc,
drawingData,
submittedId,
onEdit,
hideActions = false,
}: ResultPageProps) {
const [copied, setCopied] = useState(false);
const [exporting, setExporting] = useState(false);
const exportRef = useRef<HTMLDivElement>(null);
const copyLink = () => {
const url = `${window.location.origin}/vriendenboek/?id=${submittedId}`;
navigator.clipboard.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2500);
});
};
const exportImage = async () => {
if (!exportRef.current) return;
setExporting(true);
try {
const dataUrl = await toPng(exportRef.current, {
width: 540,
height: 960,
pixelRatio: 2,
});
const link = document.createElement("a");
link.download = `vriendenboek-${answers.naam || "anoniem"}.png`;
link.href = dataUrl;
link.click();
} catch (e) {
console.error("export failed", e);
} finally {
setExporting(false);
}
};
return (
<>
<p className="text-small opacity-50 mb-6 uppercase tracking-widest">
vriendenboek
</p>
<ResultCard answers={answers} imageSrc={imageSrc} drawingData={drawingData} />
{!hideActions && (
<>
<div className="vb-actions">
<button type="button" className="mode-toggle" onClick={copyLink}>
{copied ? "gekopieerd ✓" : "kopieer link"}
</button>
<button
type="button"
className="mode-toggle"
onClick={exportImage}
disabled={exporting}
>
{exporting ? "exporteren..." : "exporteer story"}
</button>
<button type="button" className="mode-toggle" onClick={onEdit}>
aanpassen
</button>
</div>
<p className="text-small opacity-40 text-center mt-4">
stuur deze link of een screenshot naar Zias
</p>
</>
)}
{/* Off-screen 16:9 export card */}
<ExportCard answers={answers} imageSrc={imageSrc} drawingData={drawingData} cardRef={exportRef} />
</>
);
}

View File

@@ -0,0 +1,30 @@
export interface Question {
id: string;
label: string;
placeholder: string;
multiline: boolean;
maxLength: number;
required?: boolean;
}
export type Answers = Record<string, string>;
/** 2D grid of hex colors (null = transparent) — mirrors fromPixelGrid() input */
export type PixelGrid = (string | null)[][];
export interface DrawingData {
grid: PixelGrid;
/** Name the user gave their sprite */
name: string;
/** Whether the sprite walks across the screen */
moves: boolean;
/** Whether the sprite floats in the sky instead of standing on the ground */
sky: boolean;
}
export interface Submission {
id: string;
answers: Answers;
photo_key: string | null;
created_at: number;
}

View File

@@ -763,6 +763,76 @@
animation: garden-grow 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* Friend drawings in the garden */
.garden-drawing-note {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
transform-origin: bottom center;
animation: garden-grow 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
translate: -50% 0;
}
/* Moving drawing — walks across screen like walkers */
.garden-drawing-walker {
position: absolute;
animation: walker-lr linear infinite;
}
.garden-drawing-walker-sky {
animation-name: walker-lr-sky;
}
@keyframes walker-lr-sky {
from { transform: translateX(-120px); }
to { transform: translateX(calc(100vw + 120px)); }
}
.garden-drawing-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.garden-drawing-bubble {
font-family: "Caveat", cursive;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
background: #fafaf8;
color: #1a1a18;
border: 1.5px solid #1a1a18;
border-radius: 4px 5px 5px 4px / 5px 4px 4px 5px;
padding: 1px 6px 2px;
position: relative;
margin-bottom: 4px;
}
/* Speech bubble tail pointing down */
.garden-drawing-bubble::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
translate: -50% 0;
border: 4px solid transparent;
border-top-color: #1a1a18;
}
.garden-drawing-bubble::before {
content: "";
position: absolute;
top: calc(100% - 1.5px);
left: 50%;
translate: -50% 0;
border: 3px solid transparent;
border-top-color: #fafaf8;
z-index: 1;
}
.garden-hat {
position: absolute;
bottom: 100%;
@@ -940,6 +1010,691 @@
100% { transform: translateX(0); }
}
/* ─── Vriendenboek ───────────────────────────────────────────── */
.vb-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.vb-field-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.vb-required {
opacity: 0.5;
}
.vb-char-count {
font-family: "Lora", Georgia, serif;
font-size: 0.65rem;
opacity: 0.4;
flex-shrink: 0;
}
.vb-char-count--warn {
opacity: 0.8;
color: currentColor;
}
.vb-label {
font-family: "Lora", Georgia, serif;
font-size: 0.8rem;
font-weight: 400;
opacity: 0.65;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.vb-input {
font-family: "Caveat", cursive;
font-size: clamp(1.25rem, 3.5vw, 1.6rem);
font-weight: 600;
background: transparent;
border: none;
border-bottom: 1.5px solid currentColor;
outline: none;
color: inherit;
padding: 0.25rem 0;
width: 100%;
opacity: 0.9;
resize: none;
line-height: 1.5;
transition: border-color 0.2s ease, opacity 0.2s ease;
}
.vb-input::placeholder {
opacity: 0.25;
}
.vb-input:focus {
opacity: 1;
border-bottom-color: currentColor;
}
.vb-photo-btn {
width: 140px;
height: 140px;
border: 1.5px dashed currentColor;
border-radius: 4px 6px 5px 4px / 6px 4px 5px 6px;
background: transparent;
cursor: pointer;
padding: 0;
overflow: hidden;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
@media (hover: hover) {
.vb-photo-btn:hover {
opacity: 0.7;
}
}
.vb-photo-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.vb-photo-placeholder {
font-family: "Caveat", cursive;
font-size: 1rem;
opacity: 0.4;
font-weight: 600;
}
.vb-submit {
font-family: "Caveat", cursive;
font-size: clamp(1.1rem, 3vw, 1.4rem);
font-weight: 600;
background: var(--vibrant-text);
color: var(--vibrant-bg);
border: none;
padding: 0.75rem 2rem;
cursor: pointer;
border-radius: 3px 5px 4px 3px / 5px 3px 4px 5px;
width: 100%;
letter-spacing: 0.02em;
transition: opacity 0.2s ease;
}
@media (hover: hover) {
.vb-submit:hover {
opacity: 0.8;
}
}
/* Result card */
.vb-card {
background: var(--vibrant-bg);
border: 1.5px solid currentColor;
border-radius: 3px 5px 4px 3px / 5px 3px 4px 5px;
overflow: hidden;
}
[data-party="true"] .vb-card {
box-shadow: 4px 4px 0 currentColor;
}
.vb-card-header {
border-bottom: 1.5px solid currentColor;
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.vb-card-title {
font-family: "Caveat", cursive;
font-size: clamp(1.5rem, 5vw, 2.2rem);
font-weight: 700;
}
/* Two-page spread */
.vb-spread {
display: flex;
flex-direction: column;
}
@media (min-width: 680px) {
.vb-spread {
flex-direction: row;
align-items: stretch;
}
}
.vb-page-left {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
padding: 1.5rem 1.25rem;
border-bottom: 1.5px solid currentColor;
}
@media (min-width: 680px) {
.vb-page-left {
width: 220px;
flex-shrink: 0;
border-bottom: none;
border-right: 1.5px solid currentColor;
}
}
.vb-page-basic {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.vb-spine {
display: none;
}
.vb-page-right {
flex: 1;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.vb-qa-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
@media (min-width: 680px) {
.vb-qa-grid {
grid-template-columns: 1fr 1fr;
column-gap: 1.5rem;
}
}
.vb-bericht-block {
display: flex;
flex-direction: column;
gap: 0.25rem;
border-top: 1.5px solid currentColor;
padding-top: 0.85rem;
margin-top: 0.25rem;
}
.vb-polaroid {
flex-shrink: 0;
width: 130px;
background: var(--vibrant-bg);
border: 1.5px solid currentColor;
padding: 6px 6px 24px 6px;
rotate: -1.5deg;
box-shadow: 2px 3px 0 rgba(0, 0, 0, 0.12);
}
.vb-polaroid-img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.vb-polaroid-empty {
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.25;
font-family: "Lora", serif;
font-size: 0.7rem;
border: 1px dashed currentColor;
}
.vb-result-field {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.vb-result-label {
font-family: "Lora", Georgia, serif;
font-size: 0.65rem;
font-weight: 400;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vb-result-value {
font-family: "Caveat", cursive;
font-size: clamp(1rem, 3vw, 1.25rem);
font-weight: 600;
line-height: 1.3;
}
.vb-result-bericht {
font-style: italic;
opacity: 0.85;
}
.vb-card-footer {
border-top: 1.5px solid currentColor;
padding: 0.5rem 1.25rem;
display: flex;
justify-content: flex-end;
}
.vb-stamp {
font-family: "Lora", Georgia, serif;
font-size: 0.7rem;
opacity: 0.5;
text-transform: lowercase;
letter-spacing: 0.1em;
}
.vb-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
flex-wrap: wrap;
}
/* ─── Pixel art editor ────────────────────────────────────────── */
.vb-drawing-section {
border-top: 1.5px solid currentColor;
padding: 1rem 1.25rem;
}
.vb-pixel-grid {
position: relative;
display: grid;
grid-template-columns: repeat(16, 1fr);
grid-template-rows: repeat(16, 1fr);
aspect-ratio: 1;
width: 100%;
max-width: 320px;
border: 1.5px solid currentColor;
border-radius: 2px;
overflow: hidden;
cursor: crosshair;
touch-action: none;
image-rendering: pixelated;
background:
repeating-conic-gradient(rgba(128, 128, 128, 0.06) 0% 25%, transparent 0% 50%)
0 0 / calc(100% / 8) calc(100% / 8);
}
.vb-pixel-cell {
width: 100%;
height: 100%;
}
.vb-pixel-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: "Caveat", cursive;
font-size: 1rem;
font-weight: 600;
opacity: 0.25;
pointer-events: none;
}
.vb-pixel-palette {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.6rem;
max-width: 320px;
}
.vb-pixel-swatch {
width: 22px;
height: 22px;
border: 1.5px solid transparent;
border-radius: 3px;
cursor: pointer;
padding: 0;
transition: transform 0.1s ease, border-color 0.1s ease;
flex-shrink: 0;
}
.vb-pixel-swatch.selected {
border-color: currentColor;
transform: scale(1.2);
}
.vb-pixel-eraser {
background: transparent;
border-color: currentColor;
opacity: 0.45;
font-size: 0.65rem;
color: inherit;
display: flex;
align-items: center;
justify-content: center;
}
.vb-pixel-eraser.selected {
opacity: 1;
}
.vb-pixel-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.6rem;
max-width: 320px;
}
.vb-pixel-name {
font-size: clamp(1rem, 3vw, 1.3rem) !important;
}
.vb-pixel-toggles {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.vb-pixel-toggle {
font-family: "Caveat", cursive;
font-size: 1rem;
font-weight: 600;
background: transparent;
border: 1.5px solid currentColor;
color: inherit;
padding: 0.2rem 0.7rem;
border-radius: 3px 5px 4px 3px / 5px 3px 4px 5px;
cursor: pointer;
opacity: 0.45;
transition: opacity 0.15s ease;
}
.vb-pixel-toggle.active {
opacity: 1;
}
@media (hover: hover) {
.vb-pixel-toggle:hover {
opacity: 0.8;
}
.vb-pixel-toggle.active:hover {
opacity: 1;
}
}
.vb-draw-clear {
font-family: "Lora", Georgia, serif;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.5;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
color: inherit;
transition: opacity 0.15s ease;
}
@media (hover: hover) {
.vb-draw-clear:hover {
opacity: 0.9;
}
}
.vb-drawing-block {
border-top: 1.5px solid currentColor;
padding: 0.75rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.vb-drawing-sprite {
display: flex;
}
.vb-drawing-meta {
font-family: "Lora", Georgia, serif;
font-size: 0.65rem;
opacity: 0.4;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.vb-export-story-drawing {
padding: 0 16px;
margin-bottom: 8px;
display: flex;
}
/* ─── Form footer ─────────────────────────────────────────────── */
.vb-form-footer {
border-top: 1.5px solid currentColor;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Naam input in header — match card title size */
.vb-card-header .vb-field {
width: 100%;
}
.vb-card-header .vb-input {
font-size: clamp(1.5rem, 5vw, 2.2rem);
font-weight: 700;
}
/* Photo button fills the left column */
.vb-page-left .vb-photo-btn {
width: 100%;
height: auto;
aspect-ratio: 1;
}
/* ─── Export card (9:16 story, off-screen) ────────────────────── */
.vb-export-wrap {
position: absolute;
left: -9999px;
top: 0;
width: 540px;
height: 960px;
overflow: hidden;
pointer-events: none;
}
.vb-export-story {
width: 540px;
height: 960px;
background: var(--vibrant-bg);
color: var(--vibrant-text);
display: flex;
flex-direction: column;
border: 2px solid currentColor;
font-family: "Caveat", cursive;
overflow: hidden;
}
/* Photo section */
.vb-export-story-photo-wrap {
display: flex;
justify-content: center;
align-items: center;
padding: 28px 28px 16px;
border-bottom: 1.5px solid currentColor;
}
.vb-export-story-polaroid {
width: 220px;
background: var(--vibrant-bg);
border: 2px solid currentColor;
padding: 8px 8px 36px 8px;
rotate: -1.5deg;
box-shadow: 3px 4px 0 rgba(0, 0, 0, 0.12);
}
.vb-export-story-polaroid img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.vb-export-story-polaroid-empty {
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed currentColor;
opacity: 0.2;
font-size: 0.75rem;
font-family: "Lora", serif;
}
/* Name */
.vb-export-story-name {
font-family: "Caveat", cursive;
font-size: 2.4rem;
font-weight: 700;
line-height: 1;
padding: 14px 24px 0;
}
/* Basic info row */
.vb-export-story-basic {
display: flex;
flex-direction: row;
gap: 6px 20px;
flex-wrap: wrap;
padding: 10px 24px 14px;
border-bottom: 1.5px solid currentColor;
}
/* Q&A grid */
.vb-export-story-qa {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 20px;
padding: 14px 24px;
flex: 1;
overflow: hidden;
}
/* Bericht */
.vb-export-story-bericht {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 24px 12px;
border-top: 1.5px solid currentColor;
padding-top: 10px;
}
/* Footer */
.vb-export-story-footer {
border-top: 1.5px solid currentColor;
padding: 8px 24px;
display: flex;
justify-content: flex-end;
margin-top: auto;
}
/* Shared field/label/value styles */
.vb-export-field {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.vb-export-label {
font-family: "Lora", Georgia, serif;
font-size: 0.6rem;
font-weight: 400;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vb-export-value {
font-family: "Caveat", cursive;
font-size: 1.1rem;
font-weight: 600;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vb-export-story-bericht .vb-export-value {
font-style: italic;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.vb-export-stamp {
font-family: "Lora", serif;
font-size: 0.65rem;
opacity: 0.5;
letter-spacing: 0.08em;
}
/* ─── Admin ──────────────────────────────────────────────────── */
.vb-admin-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
@media (min-width: 680px) {
.vb-admin-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 1200px) {
.vb-admin-grid {
grid-template-columns: 1fr 1fr 1fr;
}
}
.vb-admin-card-date {
font-family: "Lora", Georgia, serif;
font-size: 0.7rem;
opacity: 0.45;
margin-top: 0.5rem;
letter-spacing: 0.03em;
}
.vb-admin-pagination {
display: flex;
align-items: center;
gap: 1.5rem;
margin-top: 3rem;
}
/* ─── Scrollbar ──────────────────────────────────────────────── */
::-webkit-scrollbar {

View File

@@ -11,7 +11,9 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as WorkIndexRouteImport } from './routes/work/index'
import { Route as VriendenboekIndexRouteImport } from './routes/vriendenboek/index'
import { Route as BlogIndexRouteImport } from './routes/blog/index'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as WorkSlugRouteImport } from './routes/work/$slug'
import { Route as BlogSlugRouteImport } from './routes/blog/$slug'
@@ -25,11 +27,21 @@ const WorkIndexRoute = WorkIndexRouteImport.update({
path: '/work/',
getParentRoute: () => rootRouteImport,
} as any)
const VriendenboekIndexRoute = VriendenboekIndexRouteImport.update({
id: '/vriendenboek/',
path: '/vriendenboek/',
getParentRoute: () => rootRouteImport,
} as any)
const BlogIndexRoute = BlogIndexRouteImport.update({
id: '/blog/',
path: '/blog/',
getParentRoute: () => rootRouteImport,
} as any)
const AdminIndexRoute = AdminIndexRouteImport.update({
id: '/admin/',
path: '/admin/',
getParentRoute: () => rootRouteImport,
} as any)
const WorkSlugRoute = WorkSlugRouteImport.update({
id: '/work/$slug',
path: '/work/$slug',
@@ -45,14 +57,18 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/blog/$slug': typeof BlogSlugRoute
'/work/$slug': typeof WorkSlugRoute
'/admin/': typeof AdminIndexRoute
'/blog/': typeof BlogIndexRoute
'/vriendenboek/': typeof VriendenboekIndexRoute
'/work/': typeof WorkIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/blog/$slug': typeof BlogSlugRoute
'/work/$slug': typeof WorkSlugRoute
'/admin': typeof AdminIndexRoute
'/blog': typeof BlogIndexRoute
'/vriendenboek': typeof VriendenboekIndexRoute
'/work': typeof WorkIndexRoute
}
export interface FileRoutesById {
@@ -60,22 +76,48 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/blog/$slug': typeof BlogSlugRoute
'/work/$slug': typeof WorkSlugRoute
'/admin/': typeof AdminIndexRoute
'/blog/': typeof BlogIndexRoute
'/vriendenboek/': typeof VriendenboekIndexRoute
'/work/': typeof WorkIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/blog/$slug' | '/work/$slug' | '/blog/' | '/work/'
fullPaths:
| '/'
| '/blog/$slug'
| '/work/$slug'
| '/admin/'
| '/blog/'
| '/vriendenboek/'
| '/work/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/blog/$slug' | '/work/$slug' | '/blog' | '/work'
id: '__root__' | '/' | '/blog/$slug' | '/work/$slug' | '/blog/' | '/work/'
to:
| '/'
| '/blog/$slug'
| '/work/$slug'
| '/admin'
| '/blog'
| '/vriendenboek'
| '/work'
id:
| '__root__'
| '/'
| '/blog/$slug'
| '/work/$slug'
| '/admin/'
| '/blog/'
| '/vriendenboek/'
| '/work/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BlogSlugRoute: typeof BlogSlugRoute
WorkSlugRoute: typeof WorkSlugRoute
AdminIndexRoute: typeof AdminIndexRoute
BlogIndexRoute: typeof BlogIndexRoute
VriendenboekIndexRoute: typeof VriendenboekIndexRoute
WorkIndexRoute: typeof WorkIndexRoute
}
@@ -95,6 +137,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WorkIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/vriendenboek/': {
id: '/vriendenboek/'
path: '/vriendenboek'
fullPath: '/vriendenboek/'
preLoaderRoute: typeof VriendenboekIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/blog/': {
id: '/blog/'
path: '/blog'
@@ -102,6 +151,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BlogIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/': {
id: '/admin/'
path: '/admin'
fullPath: '/admin/'
preLoaderRoute: typeof AdminIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/work/$slug': {
id: '/work/$slug'
path: '/work/$slug'
@@ -123,7 +179,9 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BlogSlugRoute: BlogSlugRoute,
WorkSlugRoute: WorkSlugRoute,
AdminIndexRoute: AdminIndexRoute,
BlogIndexRoute: BlogIndexRoute,
VriendenboekIndexRoute: VriendenboekIndexRoute,
WorkIndexRoute: WorkIndexRoute,
}
export const routeTree = rootRouteImport

View File

@@ -10,7 +10,6 @@ import { useEffect, useState } from "react";
import "../index.css";
import { ColorProvider, useColors } from "@/components/color";
import { Controls } from "@/components/controls/controls";
import { DiscoBall } from "@/components/disco/disco-ball";
import { GardenLayer, GardenProvider, useGarden } from "@/components/garden";
import { LoadingOverlay } from "@/components/loading";
@@ -92,7 +91,6 @@ function RootLayout() {
<Outlet />
</div>
<GardenLayer />
<DiscoBall />
<Controls />
</>
)}

View File

@@ -0,0 +1,245 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState, useEffect } from "react";
import { Header } from "@/components/layout";
import { ResultCard } from "@/components/vriendenboek";
import type { Answers, Submission } from "@/components/vriendenboek";
export const Route = createFileRoute("/admin/")({
component: AdminPage,
head: () => ({
meta: [{ title: "Admin | ZIAS" }, { name: "robots", content: "noindex" }],
}),
});
const TOKEN_KEY = "admin_token";
const PAGE_SIZE = 9;
function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
function saveToken(token: string) {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {}
}
function clearToken() {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {}
}
function authHeaders(token: string): HeadersInit {
return { Authorization: `Bearer ${token}` };
}
// ─── Login ────────────────────────────────────────────────────────────────────
function LoginForm({ onToken }: { onToken: (t: string) => void }) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/admin/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!res.ok) throw new Error("wrong password");
const { token } = (await res.json()) as { token: string };
saveToken(token);
onToken(token);
} catch {
setError("Fout wachtwoord.");
} finally {
setLoading(false);
}
};
return (
<div className="page">
<Header>
<Link to="/" className="nav-link opacity-60">
HOME
</Link>
</Header>
<main className="flex-1 w-full max-w-sm mx-auto flex flex-col justify-center gap-6">
<h1 className="heading-lg">admin</h1>
<form onSubmit={submit} className="flex flex-col gap-6">
<div className="vb-field">
<label className="vb-label" htmlFor="password">
Wachtwoord
</label>
<input
id="password"
type="password"
className="vb-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
</div>
{error && (
<p className="text-small opacity-60" role="alert">
{error}
</p>
)}
<button type="submit" className="vb-submit" disabled={loading}>
{loading ? "laden..." : "inloggen →"}
</button>
</form>
</main>
</div>
);
}
// ─── Submission card ──────────────────────────────────────────────────────────
function SubmissionCard({ submission, token }: { submission: Submission; token: string }) {
const [imageSrc, setImageSrc] = useState<string | null>(null);
useEffect(() => {
if (!submission.photo_key) return;
const key = encodeURIComponent(submission.photo_key);
fetch(`/api/admin/image/${key}`, { headers: authHeaders(token) })
.then(async (res) => {
if (res.ok) {
const blob = await res.blob();
setImageSrc(URL.createObjectURL(blob));
}
})
.catch(() => {});
}, [submission.photo_key, token]);
const answers: Answers =
typeof submission.answers === "string"
? JSON.parse(submission.answers)
: submission.answers;
const date = new Date(submission.created_at).toLocaleDateString("nl-BE", {
day: "numeric",
month: "long",
year: "numeric",
});
return (
<div>
<ResultCard answers={answers} imageSrc={imageSrc} />
<p className="vb-admin-card-date">{date}</p>
</div>
);
}
// ─── Submissions list ─────────────────────────────────────────────────────────
function SubmissionsList({ token, onLogout }: { token: string; onLogout: () => void }) {
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(0);
useEffect(() => {
fetch("/api/admin/submissions", { headers: authHeaders(token) })
.then(async (res) => {
if (res.status === 401) { onLogout(); return; }
if (!res.ok) throw new Error("fetch failed");
const data = (await res.json()) as { submissions: Submission[] };
setSubmissions(data.submissions);
})
.catch(() => setError("Kon submissions niet laden."))
.finally(() => setLoading(false));
}, [token, onLogout]);
const totalPages = Math.ceil(submissions.length / PAGE_SIZE);
const visible = submissions.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="page">
<Header>
<Link to="/" className="nav-link opacity-60">HOME</Link>
<button type="button" className="nav-link opacity-60" onClick={onLogout}>
UITLOGGEN
</button>
</Header>
<main className="flex-1 w-full max-w-6xl">
<h1 className="heading-lg mb-8">
vriendenboek
{submissions.length > 0 && (
<span className="opacity-40 text-[1.5rem] ml-3">{submissions.length}</span>
)}
</h1>
{loading && <p className="text-body opacity-50">laden...</p>}
{error && <p className="text-body opacity-60">{error}</p>}
{!loading && submissions.length === 0 && (
<p className="text-body opacity-50">
nog niemand ingevuld. stuur de link!
</p>
)}
{visible.length > 0 && (
<div className="vb-admin-grid">
{visible.map((s) => (
<SubmissionCard key={s.id} submission={s} token={token} />
))}
</div>
)}
{totalPages > 1 && (
<div className="vb-admin-pagination">
<button
type="button"
className="mode-toggle"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
vorige
</button>
<span className="text-small opacity-50">
{page + 1} / {totalPages}
</span>
<button
type="button"
className="mode-toggle"
disabled={page === totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
volgende
</button>
</div>
)}
<div className="mt-16" />
</main>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
function AdminPage() {
const [token, setToken] = useState<string | null>(getToken);
const logout = () => {
clearToken();
setToken(null);
};
if (!token) {
return <LoginForm onToken={setToken} />;
}
return <SubmissionsList token={token} onLogout={logout} />;
}

View File

@@ -0,0 +1,169 @@
import { createFileRoute, Link, useSearch } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { BRAND, SEO, getCanonicalUrl } from "@zias/content";
import { Header } from "@/components/layout";
import {
ResultPage,
VriendenboekForm,
useVriendenboek,
} from "@/components/vriendenboek";
import type { Answers, DrawingData } from "@/components/vriendenboek";
export const Route = createFileRoute("/vriendenboek/")({
component: VriendenboekComponent,
validateSearch: (search: Record<string, unknown>) => ({
id: typeof search.id === "string" ? search.id : undefined,
}),
head: () => {
const title = `VRIENDENBOEK | ${BRAND.name}`;
const description = "Laat je sporen na in mijn digitale vriendenboek!";
const url = getCanonicalUrl("/vriendenboek");
return {
meta: [
{ title },
{ name: "description", content: description },
{ property: "og:url", content: url },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:image", content: `${SEO.siteUrl}${SEO.ogImage}` },
],
links: [{ rel: "canonical", href: url }],
};
},
});
// ─── Shared link view ─────────────────────────────────────────────────────────
function SharedView({ id }: { id: string }) {
const [answers, setAnswers] = useState<Answers | null>(null);
const [drawingData, setDrawingData] = useState<DrawingData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/vriendenboek/${id}`)
.then(async (res) => {
if (!res.ok) throw new Error("not found");
const data = (await res.json()) as { answers: Answers; drawing_data?: string | null };
setAnswers(data.answers);
if (data.drawing_data) {
try {
setDrawingData(JSON.parse(data.drawing_data) as DrawingData);
} catch {
// ignore invalid drawing data
}
}
})
.catch(() => setAnswers(null))
.finally(() => setLoading(false));
}, [id]);
return (
<div className="page">
<Header>
<Link to="/" className="nav-link opacity-60">
HOME
</Link>
</Header>
<main className="flex-1 w-full max-w-5xl">
{loading && <p className="text-body opacity-50">laden...</p>}
{!loading && !answers && (
<p className="text-body opacity-60">niet gevonden.</p>
)}
{answers && (
<ResultPage
answers={answers}
imageSrc={`/api/vriendenboek/${id}/image`}
drawingData={drawingData}
submittedId={id}
onEdit={() => {}}
hideActions
/>
)}
<div className="mt-16" />
</main>
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
function VriendenboekComponent() {
const { id } = useSearch({ from: "/vriendenboek/" });
const {
answers,
setAnswers,
imageDataUrl,
handleImageChange,
drawingData,
setDrawingData,
submittedId,
submitting,
error,
submit,
reset,
} = useVriendenboek();
if (id && !submittedId) {
return <SharedView id={id} />;
}
if (submittedId) {
return (
<div className="page">
<Header>
<Link to="/" className="nav-link opacity-60">
HOME
</Link>
</Header>
<main className="flex-1 w-full max-w-5xl">
<ResultPage
answers={answers}
imageSrc={imageDataUrl}
drawingData={drawingData}
submittedId={submittedId}
onEdit={reset}
/>
<div className="mt-16" />
</main>
</div>
);
}
return (
<div className="page">
<Header>
<Link to="/" className="nav-link opacity-60">
HOME
</Link>
</Header>
<main className="flex-1 w-full max-w-5xl">
<h1 className="heading-lg mb-2">vriendenboek</h1>
<p className="text-body opacity-60 mb-10">
vul dit in en stuur het naar mij
</p>
<VriendenboekForm
answers={answers}
imageDataUrl={imageDataUrl}
drawingData={drawingData}
submitting={submitting}
error={error}
onAnswerChange={(fieldId, value) =>
setAnswers((prev) => ({ ...prev, [fieldId]: value }))
}
onImageChange={handleImageChange}
onDrawingChange={setDrawingData}
onSubmit={(e) => {
e.preventDefault();
submit();
}}
/>
<div className="mt-16" />
</main>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import type { Env, SubmissionRow } from "../env";
function isAuthorized(request: Request, env: Env): boolean {
const auth = request.headers.get("Authorization");
return auth === `Bearer ${env.ADMIN_SECRET}`;
}
function unauthorized(): Response {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
export async function handleAuth(
request: Request,
env: Env,
): Promise<Response> {
let body: { password?: string };
try {
body = await request.json();
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
if (body.password === env.ADMIN_SECRET) {
return Response.json({ token: env.ADMIN_SECRET });
}
return unauthorized();
}
export async function handleAdmin(
request: Request,
env: Env,
): Promise<Response> {
if (!isAuthorized(request, env)) return unauthorized();
const url = new URL(request.url);
// GET /api/admin/submissions
if (url.pathname === "/api/admin/submissions") {
const { results } = await env.DB.prepare(
"SELECT id, answers, photo_key, created_at FROM submissions ORDER BY created_at DESC",
).all<SubmissionRow>();
return Response.json({ submissions: results });
}
// GET /api/admin/submissions/:id
const subMatch = url.pathname.match(/^\/api\/admin\/submissions\/([^/]+)$/);
if (subMatch) {
const row = await env.DB.prepare(
"SELECT id, answers, photo_key, created_at FROM submissions WHERE id = ?",
)
.bind(subMatch[1])
.first<SubmissionRow>();
if (!row) return Response.json({ error: "not found" }, { status: 404 });
return Response.json({ submission: row });
}
// GET /api/admin/image/:key — proxy R2 object through Worker
const imgMatch = url.pathname.match(/^\/api\/admin\/image\/(.+)$/);
if (imgMatch) {
const key = decodeURIComponent(imgMatch[1]!);
const obj = await env.PHOTOS.get(key);
if (!obj) return new Response("not found", { status: 404 });
return new Response(obj.body, {
headers: {
"Content-Type": obj.httpMetadata?.contentType ?? "image/jpeg",
"Cache-Control": "private, max-age=3600",
},
});
}
return Response.json({ error: "not found" }, { status: 404 });
}

View File

@@ -0,0 +1,175 @@
import type { Env } from "../env";
const FIELD_LIMITS: Record<string, number> = {
naam: 60,
verjaardag: 20,
woonplaats: 60,
kleur: 40,
muziek: 100,
film: 100,
hobby: 120,
superkracht: 100,
droom: 100,
angst: 100,
guilty_pleasure: 100,
quote: 150,
bericht: 500,
};
const PHOTO_MAX_BYTES = 5 * 1024 * 1024; // 5 MB
const DRAWING_DATA_MAX_CHARS = 50_000; // ~16×16 grid JSON
export async function handleSubmit(
request: Request,
env: Env,
): Promise<Response> {
let formData: FormData;
try {
formData = await request.formData();
} catch {
return Response.json({ error: "invalid form data" }, { status: 400 });
}
const answersRaw = formData.get("answers");
if (!answersRaw || typeof answersRaw !== "string") {
return Response.json({ error: "answers required" }, { status: 400 });
}
let answers: Record<string, unknown>;
try {
answers = JSON.parse(answersRaw);
} catch {
return Response.json({ error: "invalid answers JSON" }, { status: 400 });
}
// naam is required
const naam = typeof answers.naam === "string" ? answers.naam.trim() : "";
if (!naam) {
return Response.json({ error: "naam is required" }, { status: 400 });
}
// enforce per-field length limits and strip unknown fields
const sanitized: Record<string, string> = {};
for (const [field, limit] of Object.entries(FIELD_LIMITS)) {
if (typeof answers[field] === "string") {
const val = (answers[field] as string).trim().slice(0, limit);
if (val) sanitized[field] = val;
}
}
const id = crypto.randomUUID();
const now = Date.now();
let photoKey: string | null = null;
const photoFile = formData.get("photo") as File | null;
if (photoFile && photoFile.size > 0) {
if (photoFile.size > PHOTO_MAX_BYTES) {
return Response.json({ error: "photo too large (max 5 MB)" }, { status: 400 });
}
const type = photoFile.type || "image/jpeg";
if (!type.startsWith("image/")) {
return Response.json({ error: "photo must be an image" }, { status: 400 });
}
photoKey = `photos/${id}`;
await env.PHOTOS.put(photoKey, await photoFile.arrayBuffer(), {
httpMetadata: { contentType: type },
});
}
let drawingData: string | null = null;
const drawingRaw = formData.get("drawing_data");
if (typeof drawingRaw === "string" && drawingRaw.length > 0) {
if (drawingRaw.length > DRAWING_DATA_MAX_CHARS) {
return Response.json({ error: "drawing too large" }, { status: 400 });
}
// Validate that it's valid JSON with a grid field
try {
const parsed = JSON.parse(drawingRaw);
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.grid)) throw new Error();
drawingData = drawingRaw;
} catch {
return Response.json({ error: "invalid drawing data" }, { status: 400 });
}
}
await env.DB.prepare(
"INSERT INTO submissions (id, answers, photo_key, drawing_data, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(id, JSON.stringify(sanitized), photoKey, drawingData, now)
.run();
return Response.json({ id }, { status: 201 });
}
export async function handleGetSubmission(
request: Request,
env: Env,
id: string,
): Promise<Response> {
const row = await env.DB.prepare(
"SELECT id, answers, photo_key, drawing_data, created_at FROM submissions WHERE id = ?",
)
.bind(id)
.first<{ id: string; answers: string; photo_key: string | null; drawing_data: string | null; created_at: number }>();
if (!row) return Response.json({ error: "not found" }, { status: 404 });
return Response.json({
id: row.id,
answers: JSON.parse(row.answers),
hasPhoto: row.photo_key !== null,
drawing_data: row.drawing_data ?? null,
created_at: row.created_at,
});
}
export async function handleGetSubmissionImage(
request: Request,
env: Env,
id: string,
): Promise<Response> {
const row = await env.DB.prepare(
"SELECT photo_key FROM submissions WHERE id = ?",
)
.bind(id)
.first<{ photo_key: string | null }>();
if (!row || !row.photo_key) {
return new Response("not found", { status: 404 });
}
const obj = await env.PHOTOS.get(row.photo_key);
if (!obj) return new Response("not found", { status: 404 });
return new Response(obj.body, {
headers: {
"Content-Type": obj.httpMetadata?.contentType ?? "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
export async function handleGetAllDrawings(
request: Request,
env: Env,
): Promise<Response> {
const rows = await env.DB.prepare(
"SELECT id, answers, drawing_data FROM submissions WHERE drawing_data IS NOT NULL ORDER BY created_at DESC LIMIT 60",
).all<{ id: string; answers: string; drawing_data: string }>();
const result = (rows.results ?? []).map((row) => {
let naam = "vriend";
try {
const parsed = JSON.parse(row.answers);
if (typeof parsed.naam === "string" && parsed.naam.trim()) {
naam = parsed.naam.trim().split(" ")[0]!.slice(0, 20);
}
} catch {
// ignore
}
return { id: row.id, naam, drawing_data: row.drawing_data };
});
return Response.json(result);
}

View File

@@ -0,0 +1,13 @@
export interface Env {
DB: D1Database;
PHOTOS: R2Bucket;
ADMIN_SECRET: string;
ASSETS: Fetcher;
}
export interface SubmissionRow {
id: string;
answers: string;
photo_key: string | null;
created_at: number;
}

View File

@@ -0,0 +1,49 @@
import type { Env } from "./env";
import {
handleSubmit,
handleGetSubmission,
handleGetSubmissionImage,
handleGetAllDrawings,
} from "./api/vriendenboek";
import { handleAdmin, handleAuth } from "./api/admin";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const { pathname, method } = { pathname: url.pathname, method: request.method };
if (pathname === "/api/vriendenboek" && method === "POST") {
return handleSubmit(request, env);
}
if (pathname === "/api/vriendenboek/drawings" && method === "GET") {
return handleGetAllDrawings(request, env);
}
// Public read endpoints
const submissionMatch = pathname.match(/^\/api\/vriendenboek\/([^/]+)$/);
if (submissionMatch && method === "GET") {
return handleGetSubmission(request, env, submissionMatch[1]!);
}
const imageMatch = pathname.match(/^\/api\/vriendenboek\/([^/]+)\/image$/);
if (imageMatch && method === "GET") {
return handleGetSubmissionImage(request, env, imageMatch[1]!);
}
if (pathname === "/api/admin/auth" && method === "POST") {
return handleAuth(request, env);
}
if (pathname.startsWith("/api/admin/")) {
return handleAdmin(request, env);
}
// Unmatched /api routes → 404 (don't fall through to SPA)
if (pathname.startsWith("/api/")) {
return Response.json({ error: "not found" }, { status: 404 });
}
return env.ASSETS.fetch(request);
},
} satisfies ExportedHandler<Env>;

View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS submissions (
id TEXT PRIMARY KEY,
answers TEXT NOT NULL, -- JSON blob (flexible when questions change)
photo_key TEXT, -- R2 object key, nullable
created_at INTEGER NOT NULL -- Unix ms
);

View File

@@ -0,0 +1 @@
ALTER TABLE submissions ADD COLUMN drawing_key TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE submissions ADD COLUMN drawing_data TEXT;

View File

@@ -14,5 +14,6 @@
"paths": {
"@/*": ["./src/*"]
}
}
},
"exclude": ["src/worker"]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types"]
},
"include": ["src/worker/**/*.ts"],
"exclude": []
}

View File

@@ -2,10 +2,11 @@ import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import alchemy from "alchemy/cloudflare/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), tanstackRouter({}), react()],
plugins: [alchemy(), tailwindcss(), tanstackRouter({}), react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),

100
bun.lock
View File

@@ -24,11 +24,13 @@
"@zias/content": "workspace:*",
"@zias/env": "workspace:*",
"canvas-confetti": "^1.9.4",
"html-to-image": "^1.11.13",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.3.1",
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.13.14",
"@cloudflare/workers-types": "^4.20250805.0",
"@tanstack/router-plugin": "^1.141.1",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.13.14",
@@ -205,6 +207,8 @@
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.7.7", "", { "peerDependencies": { "unenv": "2.0.0-rc.21", "workerd": "^1.20250927.0" }, "optionalPeers": ["workerd"] }, "sha512-HtZuh166y0Olbj9bqqySckz0Rw9uHjggJeoGbDx5x+sgezBXlxO6tQSig2RZw5tgObF8mWI8zaPvQMkQZtAODw=="],
"@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.31.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.0", "miniflare": "4.20260401.0", "unenv": "2.0.0-rc.24", "wrangler": "4.80.0", "ws": "8.18.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" } }, "sha512-wkIoqOTVltHMsl8Zpt2bcndbdf+w7czICJ8SbxQq+VzvGprf8glJt5y7iyMCj9YeofkUdsR6AlyTZvZ8kpx0FQ=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260305.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-chhKOpymo0Eh9J3nymrauMqKGboCc4uz/j0gA1G4gioMnKsN2ZDKJ+qjRZDnCoVGy8u2C4pxlmyIfsXCAfIzhQ=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260305.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K9aG2OQk5bBfOP+fyGPqLcqZ9OR3ra6uwnxJ8f2mveq2A2LsCI7ZeGxQiAj75Ti80ytH/gJffZIx4Np2JtU3aQ=="],
@@ -723,6 +727,8 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
@@ -919,8 +925,6 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
@@ -1015,6 +1019,16 @@
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@cloudflare/vite-plugin/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="],
"@cloudflare/vite-plugin/miniflare": ["miniflare@4.20260401.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260401.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-lngHPzZFN9sxYG/mhzvnWiBMNVAN5MsO/7g32ttJ07rymtiK/ZBalODTKb8Od+BQdlU5DOR4CjVt9NydjnUyYg=="],
"@cloudflare/vite-plugin/unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"@cloudflare/vite-plugin/wrangler": ["wrangler@4.80.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260401.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260401.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260401.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-2ZKF7uPeOZy65BGk3YfvqBCPo/xH1MrAlMmH9mVP+tCNBrTUMnwOHSj1HrZHgR8LttkAqhko0fGz+I4ax1rzyQ=="],
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
@@ -1079,6 +1093,14 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@cloudflare/vite-plugin/miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
"@cloudflare/vite-plugin/miniflare/workerd": ["workerd@1.20260401.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260401.1", "@cloudflare/workerd-darwin-arm64": "1.20260401.1", "@cloudflare/workerd-linux-64": "1.20260401.1", "@cloudflare/workerd-linux-arm64": "1.20260401.1", "@cloudflare/workerd-windows-64": "1.20260401.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-mUYCd+ohaWJWF5nhDzxugWaAD/DM8Dw0ze3B7bu8JaA7S70+XQJXcvcvwE8C4qGcxSdCyqjsrFzqxKubECDwzg=="],
"@cloudflare/vite-plugin/wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"@cloudflare/vite-plugin/wrangler/workerd": ["workerd@1.20260401.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260401.1", "@cloudflare/workerd-darwin-arm64": "1.20260401.1", "@cloudflare/workerd-linux-64": "1.20260401.1", "@cloudflare/workerd-linux-arm64": "1.20260401.1", "@cloudflare/workerd-windows-64": "1.20260401.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-mUYCd+ohaWJWF5nhDzxugWaAD/DM8Dw0ze3B7bu8JaA7S70+XQJXcvcvwE8C4qGcxSdCyqjsrFzqxKubECDwzg=="],
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
@@ -1196,5 +1218,77 @@
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260401.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZSmceM70jH6k+/62VkEcmMNzrpr4kSctkX5Lsgqv38KktfhPY/hsh75y1lRoPWS3H3kgMa4p2pUSlidZR1u2hw=="],
"@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260401.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7UKWF+IUZ3NXMVPsDg8Cjg0r58b+uYlfvs5Yt8bvtU+geCtW4P2MxRHmRSEo8SryckXOJjb/b8tcncgCykFu8g=="],
"@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260401.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MDWUH/0bvL/l9aauN8zEddyYOXId1OueqrUCXXENNJ95R/lSmF6OgGVuXaYhoIhxQkNiEJ/0NOlnVYj9mJq4dw=="],
"@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260401.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-UgkzpMzVWM/bwbo3vjCTg2aoKfGcUhiEoQoDdo6RGWvbHRJyLVZ4VQCG9ZcISiztkiS2ICCoYOtPy6M/lV6Gcw=="],
"@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260401.1", "", { "os": "win32", "cpu": "x64" }, "sha512-HBLzcQF5iF4Qv20tQ++pG7xs3OsCnaIbc+GAi6fmhUKZhvmzvml/jwrQzLJ+MPm0cQo41K5OO/U3T4S8tvJetQ=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260401.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZSmceM70jH6k+/62VkEcmMNzrpr4kSctkX5Lsgqv38KktfhPY/hsh75y1lRoPWS3H3kgMa4p2pUSlidZR1u2hw=="],
"@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260401.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7UKWF+IUZ3NXMVPsDg8Cjg0r58b+uYlfvs5Yt8bvtU+geCtW4P2MxRHmRSEo8SryckXOJjb/b8tcncgCykFu8g=="],
"@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260401.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MDWUH/0bvL/l9aauN8zEddyYOXId1OueqrUCXXENNJ95R/lSmF6OgGVuXaYhoIhxQkNiEJ/0NOlnVYj9mJq4dw=="],
"@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260401.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-UgkzpMzVWM/bwbo3vjCTg2aoKfGcUhiEoQoDdo6RGWvbHRJyLVZ4VQCG9ZcISiztkiS2ICCoYOtPy6M/lV6Gcw=="],
"@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260401.1", "", { "os": "win32", "cpu": "x64" }, "sha512-HBLzcQF5iF4Qv20tQ++pG7xs3OsCnaIbc+GAi6fmhUKZhvmzvml/jwrQzLJ+MPm0cQo41K5OO/U3T4S8tvJetQ=="],
}
}

View File

@@ -1,14 +1,31 @@
import alchemy from "alchemy";
import { Vite } from "alchemy/cloudflare";
import { D1Database, R2Bucket, Vite } from "alchemy/cloudflare";
const app = await alchemy("zias");
const app = await alchemy("zias", {
password: process.env.ALCHEMY_PASSWORD,
});
const db = await D1Database("vriendenboek-db", {
migrationsDir: "../../apps/web/src/worker/migrations",
});
const photos = await R2Bucket("vriendenboek-photos");
export const web = await Vite("web", {
cwd: "../../apps/web",
build: "bun run build",
assets: "dist",
assets: {
directory: "dist",
run_worker_first: ["/api/*"],
},
spa: true,
domains: ["zias.be", "www.zias.be"],
entrypoint: "src/worker/index.ts",
bindings: {
DB: db,
PHOTOS: photos,
ADMIN_SECRET: alchemy.secret(process.env.ADMIN_SECRET),
},
});
console.log(`Web -> ${web.url}`);