feat:vriendenboek
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
148
apps/web/src/components/garden/garden-drawings.tsx
Normal file
148
apps/web/src/components/garden/garden-drawings.tsx
Normal 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, // 30–50vh above ground
|
||||
rotation: moves ? 0 : (rng() - 0.5) * 8,
|
||||
animDelay: rng() * 15,
|
||||
moves,
|
||||
sky,
|
||||
moveDuration: 35 + rng() * 30, // 35–65s 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
111
apps/web/src/components/vriendenboek/form/drawing-canvas.tsx
Normal file
111
apps/web/src/components/vriendenboek/form/drawing-canvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/web/src/components/vriendenboek/form/form-field.tsx
Normal file
49
apps/web/src/components/vriendenboek/form/form-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/components/vriendenboek/form/photo-upload.tsx
Normal file
40
apps/web/src/components/vriendenboek/form/photo-upload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
apps/web/src/components/vriendenboek/form/pixel-canvas.tsx
Normal file
174
apps/web/src/components/vriendenboek/form/pixel-canvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/vriendenboek/form/vriendenboek-form.tsx
Normal file
114
apps/web/src/components/vriendenboek/form/vriendenboek-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
7
apps/web/src/components/vriendenboek/index.ts
Normal file
7
apps/web/src/components/vriendenboek/index.ts
Normal 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";
|
||||
94
apps/web/src/components/vriendenboek/questions.ts
Normal file
94
apps/web/src/components/vriendenboek/questions.ts
Normal 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, ""]));
|
||||
}
|
||||
97
apps/web/src/components/vriendenboek/result/export-card.tsx
Normal file
97
apps/web/src/components/vriendenboek/result/export-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/components/vriendenboek/result/polaroid.tsx
Normal file
18
apps/web/src/components/vriendenboek/result/polaroid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/components/vriendenboek/result/result-card.tsx
Normal file
101
apps/web/src/components/vriendenboek/result/result-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/components/vriendenboek/result/result-page.tsx
Normal file
93
apps/web/src/components/vriendenboek/result/result-page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/components/vriendenboek/types.ts
Normal file
30
apps/web/src/components/vriendenboek/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
245
apps/web/src/routes/admin/index.tsx
Normal file
245
apps/web/src/routes/admin/index.tsx
Normal 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} />;
|
||||
}
|
||||
169
apps/web/src/routes/vriendenboek/index.tsx
Normal file
169
apps/web/src/routes/vriendenboek/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/worker/api/admin.ts
Normal file
72
apps/web/src/worker/api/admin.ts
Normal 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 });
|
||||
}
|
||||
175
apps/web/src/worker/api/vriendenboek.ts
Normal file
175
apps/web/src/worker/api/vriendenboek.ts
Normal 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);
|
||||
}
|
||||
|
||||
13
apps/web/src/worker/env.ts
Normal file
13
apps/web/src/worker/env.ts
Normal 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;
|
||||
}
|
||||
49
apps/web/src/worker/index.ts
Normal file
49
apps/web/src/worker/index.ts
Normal 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>;
|
||||
6
apps/web/src/worker/migrations/0001_init.sql
Normal file
6
apps/web/src/worker/migrations/0001_init.sql
Normal 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
|
||||
);
|
||||
1
apps/web/src/worker/migrations/0002_drawing.sql
Normal file
1
apps/web/src/worker/migrations/0002_drawing.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE submissions ADD COLUMN drawing_key TEXT;
|
||||
1
apps/web/src/worker/migrations/0003_drawing_data.sql
Normal file
1
apps/web/src/worker/migrations/0003_drawing_data.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE submissions ADD COLUMN drawing_data TEXT;
|
||||
@@ -14,5 +14,6 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"exclude": ["src/worker"]
|
||||
}
|
||||
|
||||
9
apps/web/tsconfig.worker.json
Normal file
9
apps/web/tsconfig.worker.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src/worker/**/*.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
@@ -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
100
bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user