add les 12

This commit is contained in:
2026-05-21 08:52:47 +02:00
parent 634789e615
commit eb1ba2e28d
42 changed files with 11012 additions and 8 deletions

View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -0,0 +1,57 @@
# Polderfest demo — Les 12 startpunt
Kopie van de werkende Les 11 demo. **Startpunt** voor Les 12 — Tool Calling.
## Wat zit hier al in?
- Next.js 16 + TypeScript + Tailwind
- Supabase client (`lib/supabase.ts` voor client, `lib/supabase-admin.ts` voor service-role)
- Chat-route met **context-all** aanpak (`app/api/chat/route.ts`):
- Haalt alle 500 bands op
- Stuurt mee als tekst in system prompt
- `streamText``toUIMessageStreamResponse()`
- Chat-pagina met `useChat` van `@ai-sdk/react` (`app/page.tsx`)
- Seed script (`scripts/seed-polderfest.ts`)
- `AGENTS.md` met project context
## Wat doen we in Les 12?
We refactoren `app/api/chat/route.ts` van **context-all** naar **Tool Calling**:
- Weg met de hele context-string
- Tools definiëren — `searchBands`, `getStats`, `getBandByName`, ...
- `stopWhen: stepCountIs(5)` voor multi-step
- System prompt aanpassen: "gebruik tools, verzin niet"
- UI uitbreiden om tool-invocations te tonen
## Setup
```bash
# 1. Dependencies installeren
npm install
# 2. .env.local maken
cp .env.local.example .env.local
# Vul je eigen Supabase + OpenAI keys in
# 3. Supabase schema (als nog niet gedaan)
# Open Supabase → SQL Editor → run schema.sql uit Les 11
# 4. Seed (als nog niet gedaan)
npx tsx scripts/seed-polderfest.ts
# 5. Dev server
npm run dev
```
Open `http://localhost:3000` voor de chat.
## Stack-versies in dit project
- `ai` v6 — gebruikt nieuwere API:
- `UIMessage` type
- `convertToModelMessages()`
- `toUIMessageStreamResponse()`
- `@ai-sdk/react` v3 — `useChat` returnt `{ messages, sendMessage, status }`
- `@ai-sdk/openai` v3
- Tool API (v6): gebruikt `inputSchema` (was `parameters`) en `stopWhen` (was `maxSteps`)

View File

@@ -0,0 +1,97 @@
/**
* Polderfest 2027 — chat API route
* --------------------------------------------------
* Les 11 — Vercel AI SDK + Supabase context.
* Plaats dit bestand op: app/api/chat/route.ts
*
* Werking:
* 1. Haal alle bands op uit Supabase
* 2. Formatteer als tekst-context
* 3. Stuur naar OpenAI via streamText + system prompt
* 4. Return een stream voor useChat
*
* Vereist:
* - NEXT_PUBLIC_SUPABASE_URL en NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local
* - OPENAI_API_KEY in .env.local
* - npm i ai @ai-sdk/openai @supabase/supabase-js
*/
import { convertToModelMessages, streamText, type UIMessage } from "ai";
import { openai } from "@ai-sdk/openai";
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey =
process.env.SUPABASE_SERVICE_ROLE_KEY ??
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl) {
throw new Error("Missing env: NEXT_PUBLIC_SUPABASE_URL");
}
if (!supabaseKey) {
throw new Error(
"Missing env: SUPABASE_SERVICE_ROLE_KEY (preferred) or NEXT_PUBLIC_SUPABASE_ANON_KEY",
);
}
const supabase = createClient(supabaseUrl, supabaseKey);
export async function POST(req: Request) {
try {
const { messages }: { messages: UIMessage[] } = await req.json();
// 1. Haal alle bands op uit Supabase
const { data: bands, error } = await supabase.from("bands").select("*");
if (error) {
return Response.json(
{ error: "Supabase query failed", details: error.message },
{ status: 500 },
);
}
// 2. Format bands als context-string
const context = (bands ?? [])
.map(
(b) =>
`- ${b.name} (${b.genre}, ${b.tier}, ${b.day} ${b.start_time} ` +
`op ${b.stage}, uit ${b.origin_city}), populariteit: ${b.popularity}`,
)
.join("\n");
// 3. System prompt met context
const system = `Je bent een festival-assistent voor Polderfest 2027.
Hier zijn alle bands die op het festival spelen:
${context}
Beantwoord vragen van bezoekers over de line-up. Verzin niets — gebruik
alleen bovenstaande data. Antwoord in het Nederlands. Wees beknopt. Geef puur antwoord op de gestelde vraag en voeg geen extra informatie toe.
Zodra iemand iets vraagt over het schema. Geef dan altijd een volledig schema terug met band, stage en start en eind tijd. Sorteer op start tijd.
Wanneer een band speelt op bijvoorbeeld vrijdagavond, geef hem dan niet weer als iemand vraagt om de bands van zaterdag.
Regels:
- Zaterdag start om 16:00
`;
// 4. Stream naar OpenAI
const result = streamText({
model: openai('gpt-5.2'),
system,
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse({
onError(error: unknown) {
if (error == null) return "unknown error";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
return JSON.stringify(error);
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return Response.json({ error: "Chat route failed", details: message }, { status: 500 });
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,89 @@
/**
* Polderfest 2027 — chat pagina
* --------------------------------------------------
* Les 11 — useChat hook + Tailwind chat UI.
* Plaats dit bestand op: app/chat/page.tsx
*
* Werking:
* - useChat() regelt messages, input, submit-handler, streaming
* - Praat met /api/chat (de route.ts)
* - Disabled tijdens streaming
*
* Vereist:
* - app/api/chat/route.ts (zie route.ts)
* - npm i ai
* - Tailwind aanwezig in project (standaard in create-next-app)
*/
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
export default function ChatPage() {
const { messages, sendMessage, status } = useChat();
const [input, setInput] = useState("");
return (
<main className="max-w-2xl mx-auto p-6 flex flex-col h-screen">
<div className="flex items-center justify-between gap-3 mb-4">
<h1 className="text-2xl font-bold">Polderfest 2027 vraag de AI</h1>
<a
href="/supabase"
className="text-sm px-3 py-2 rounded-lg border hover:bg-gray-50"
>
Supabase test
</a>
</div>
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((m) => (
<div
key={m.id}
className={
m.role === "user"
? "bg-blue-50 p-3 rounded-lg ml-12"
: "bg-gray-50 p-3 rounded-lg mr-12"
}
>
<div className="font-medium text-sm text-gray-500 mb-1">
{m.role === "user" ? "Jij" : "Festival AI"}
</div>
<div className="whitespace-pre-wrap">
{m.parts
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("")}
</div>
</div>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const text = input.trim();
if (!text || status !== "ready") return;
setInput("");
void sendMessage({ text });
}}
className="flex gap-2"
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Stel een vraag over de line-up..."
className="flex-1 p-3 border rounded-lg"
disabled={status !== "ready"}
/>
<button
type="submit"
disabled={status !== "ready"}
className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
Stuur
</button>
</form>
</main>
);
}

View File

@@ -0,0 +1,61 @@
import { supabaseAdmin } from "@/lib/supabase-admin";
type Band = {
id: number;
name: string;
genre: string;
stage: string;
day: string;
start_time: string;
};
export default async function SupabasePage() {
const { data, error } = await supabaseAdmin
.from("bands")
.select("id,name,genre,stage,day,start_time")
.order("popularity", { ascending: false })
.limit(10);
return (
<main className="max-w-3xl mx-auto p-6 space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-bold">Supabase verbinding</h1>
<p className="text-sm text-gray-600">
Dit is een Server Component die met de service role key de top 10 bands
uit de <code className="px-1 py-0.5 bg-gray-100 rounded">bands</code>{" "}
tabel ophaalt.
</p>
</header>
{error ? (
<div className="p-4 border border-red-200 bg-red-50 rounded-lg">
<div className="font-medium text-red-800">
Query faalde: {error.message}
</div>
<div className="text-sm text-red-700 mt-2">
Check of de <code className="px-1 py-0.5 bg-white/70 rounded">bands</code>{" "}
tabel bestaat en of je <code className="px-1 py-0.5 bg-white/70 rounded">SUPABASE_SERVICE_ROLE_KEY</code>{" "}
klopt.
</div>
</div>
) : (
<ul className="divide-y border rounded-lg overflow-hidden">
{(data as Band[] | null)?.map((b) => (
<li key={b.id} className="p-4 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="font-semibold truncate">{b.name}</div>
<div className="text-sm text-gray-600">
{b.genre} {b.stage}
</div>
</div>
<div className="shrink-0 text-sm text-gray-700">
{b.day} {b.start_time}
</div>
</li>
))}
</ul>
)}
</main>
);
}

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,19 @@
import "server-only";
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl) {
throw new Error("Missing env var: NEXT_PUBLIC_SUPABASE_URL");
}
if (!supabaseServiceRoleKey) {
throw new Error("Missing env var: SUPABASE_SERVICE_ROLE_KEY");
}
export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: { persistSession: false },
});

View File

@@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
export const supabase = createClient(supabaseUrl, supabaseAnonKey)

View File

@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}

View File

@@ -0,0 +1,23 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"name": "polderfest-demo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.64",
"@ai-sdk/react": "^3.0.187",
"@supabase/supabase-js": "^2.106.0",
"ai": "^6.0.185",
"dotenv": "^17.4.2",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"tsx": "^4.22.3",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,272 @@
/**
* Polderfest 2027 — seed script
* ----------------------------------------------------------
* Genereert 500 fictieve bands en zet ze in je Supabase `bands` tabel.
* Run:
* 1. Zorg dat `bands` tabel bestaat (zie schema.sql)
* 2. Vul .env.local met:
* SUPABASE_URL=https://<project>.supabase.co
* SUPABASE_SERVICE_ROLE_KEY=<service role key>
* 3. npm i @supabase/supabase-js dotenv tsx --save-dev
* 4. npx tsx seed-polderfest.ts
*
* Service role key is bewust nodig — alleen voor lokaal seeden.
* NIET committen, NIET in client gebruiken.
*/
import { createClient } from "@supabase/supabase-js";
import dotenv from "dotenv";
// Laad .env.local (i.p.v. default .env)
dotenv.config({ path: ".env.local" });
const SUPABASE_URL =
process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL;
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
console.error(
"Ontbrekende env vars. Check .env.local:\n" +
" NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co\n" +
" SUPABASE_SERVICE_ROLE_KEY=<service role key>"
);
process.exit(1);
}
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
// ────────────────────────────────────────────────────────────
// Deterministische random (zodat seed reproduceerbaar is)
// ────────────────────────────────────────────────────────────
let seed = 42;
function rand() {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
}
function pick<T>(arr: readonly T[]): T {
return arr[Math.floor(rand() * arr.length)];
}
function pickN<T>(arr: readonly T[], n: number): T[] {
const copy = [...arr];
const out: T[] = [];
for (let i = 0; i < n && copy.length; i++) {
out.push(copy.splice(Math.floor(rand() * copy.length), 1)[0]);
}
return out;
}
function range(min: number, max: number): number {
return Math.floor(rand() * (max - min + 1)) + min;
}
// ────────────────────────────────────────────────────────────
// Bouwstenen voor band-namen
// ────────────────────────────────────────────────────────────
const adjectives = [
"Lost", "Velvet", "Iron", "Neon", "Silent", "Wild", "Glass", "Paper", "Sleeping",
"Honest", "Crooked", "Bitter", "Sweet", "Drowsy", "Drowning", "Restless", "Sober",
"Midnight", "Morning", "Yellow", "Crimson", "Hollow", "Heavy", "Floating", "Slow",
"Burning", "Frozen", "Cardboard", "Plastic", "Analog", "Digital", "Forgotten",
] as const;
const nouns = [
"Tigers", "Wolves", "Horses", "Rabbits", "Mirrors", "Clouds", "Echoes", "Ghosts",
"Lights", "Roots", "Stones", "Foxes", "Riders", "Ships", "Tides", "Anchors",
"Maps", "Letters", "Postcards", "Radios", "Telegrams", "Diaries", "Highways",
"Cassettes", "Polaroids", "Cathedrals", "Stations", "Lanterns", "Compasses",
"Saturdays", "Tuesdays", "Mondays",
] as const;
const dutchPrefixes = [
"De", "Het", "Van der", "Polder", "Noord", "Zuid",
] as const;
const soloNamesFirst = [
"Sanne", "Joost", "Yara", "Lex", "Mila", "Tess", "Bram", "Lotte", "Ravi", "Imani",
"Marit", "Stijn", "Liva", "Noor", "Casper", "Anouk", "Mees", "Pien", "Daan", "Olivia",
"Niels", "Fenna", "Tygo", "Saar", "Cas", "Maud", "Roos", "Vince", "Lieke", "Floris",
] as const;
const soloNamesLast = [
"Van Dijk", "De Boer", "Visser", "Jansen", "Bakker", "Hendriks", "Mulder", "Smit",
"Peters", "De Vries", "Kuipers", "Brouwer", "Postma", "Hofman", "Van Loon",
] as const;
// ────────────────────────────────────────────────────────────
// Fest-velden
// ────────────────────────────────────────────────────────────
const genres = [
"Indie Rock", "Electronic", "Hip-Hop", "Jazz Fusion", "Folk", "Punk", "Soul",
"Ambient", "Disco-House", "Experimental", "Singer-Songwriter", "Synth-Pop",
"Garage Rock", "Neo-Soul", "Drum & Bass", "Afrobeat", "Dream Pop", "Post-Rock",
] as const;
const subGenresByGenre: Record<string, string[]> = {
"Indie Rock": ["Shoegaze", "Lo-Fi", "Math Rock", "Slowcore"],
"Electronic": ["Techno", "House", "IDM", "Glitch", "Trance"],
"Hip-Hop": ["Boom Bap", "Trap", "Lo-Fi", "Conscious"],
"Jazz Fusion": ["Funk Jazz", "Cosmic Jazz", "Nu-Jazz"],
"Folk": ["Anti-Folk", "Sea Shanty", "Modern Folk"],
"Punk": ["Post-Punk", "Hardcore", "Surf Punk"],
"Soul": ["Neo-Soul", "Northern Soul", "Funk"],
"Ambient": ["Drone", "New Age", "Field Recording"],
"Disco-House": ["Italo Disco", "Nu-Disco", "French House"],
"Experimental": ["Noise", "Sound Art", "Avantgarde"],
"Singer-Songwriter": ["Confessional", "Storytelling"],
"Synth-Pop": ["Vaporwave", "Italo", "Darkwave"],
"Garage Rock": ["Surf", "Power Pop"],
"Neo-Soul": ["Alt R&B", "Jazz-influenced"],
"Drum & Bass": ["Liquid", "Jungle", "Neurofunk"],
"Afrobeat": ["Afro-Fusion", "Highlife"],
"Dream Pop": ["Bedroom Pop", "Ethereal"],
"Post-Rock": ["Cinematic", "Math-influenced"],
};
const stages = [
"Main Stage", "Tent Stage", "Beach Stage", "Acoustic Bar", "Late Night Tent",
] as const;
const days = ["Vrijdag", "Zaterdag", "Zondag"] as const;
const timeSlots = [
"14:00", "15:30", "17:00", "18:30", "20:00", "21:30", "23:00", "00:30",
] as const;
const cities = [
"Amsterdam", "Rotterdam", "Utrecht", "Groningen", "Eindhoven", "Den Haag",
"Tilburg", "Maastricht", "Nijmegen", "Leeuwarden", "Arnhem", "Breda", "Haarlem",
"Zwolle", "Enschede", "Delft", "Den Bosch", "Apeldoorn",
] as const;
const tiers = ["headliner", "mid", "opener"] as const;
// ────────────────────────────────────────────────────────────
// Bio-fragmenten — combinatorisch zodat 500 bios uniek voelen
// ────────────────────────────────────────────────────────────
const bioOpenings = [
"Begonnen in een garage in",
"Ontstaan tijdens een blackout in",
"Een vriendengroep uit",
"Doorgebroken op het kleine podium van",
"Geboren uit een jam-sessie in",
"Een collectief van producers uit",
];
const bioMiddle = [
"experimenteert met analoge synths en gefluisterde lyrics",
"balanceert tussen melancholie en dansvloer-euforie",
"mixt traditionele samples met breakbeats",
"gebruikt veldopnames als ritmesectie",
"schrijft songs in Nederlands en Engels door elkaar",
"speelt instrumenten die ze grotendeels zelf hebben gebouwd",
"draait alleen optredens op locaties zonder Wi-Fi",
];
const bioEnding = [
"Debuut-EP verschijnt eind 2027.",
"Hun laatste album werd genomineerd voor de fictieve Edison Polder Award.",
"Polderfest is hun grootste festival tot nu toe.",
"Vorig jaar speelden ze nog in cafés, dit jaar op Stage B.",
"Spelen voor het eerst op een buitenpodium.",
"Beruchte live-show met 12 backing vocalists.",
];
// ────────────────────────────────────────────────────────────
// Namen genereren
// ────────────────────────────────────────────────────────────
function generateBandName(seedIdx: number): string {
const pattern = seedIdx % 4;
if (pattern === 0) {
return `${pick(adjectives)} ${pick(nouns)}`;
}
if (pattern === 1) {
return `${pick(dutchPrefixes)} ${pick(nouns)}`;
}
if (pattern === 2) {
return `${pick(soloNamesFirst)} ${pick(soloNamesLast)}`;
}
return `${pick(soloNamesFirst)} & The ${pick(nouns)}`;
}
function generateMembers(): string[] {
const count = range(1, 5);
const out: string[] = [];
for (let i = 0; i < count; i++) {
out.push(`${pick(soloNamesFirst)} ${pick(soloNamesLast)}`);
}
return out;
}
function generateBio(name: string): string {
return `${pick(bioOpenings)} ${pick(cities)}, ${name} ${pick(bioMiddle)}. ${pick(bioEnding)}`;
}
// ────────────────────────────────────────────────────────────
// Hoofdfunctie
// ────────────────────────────────────────────────────────────
async function runSeed() {
console.log("Genereren van 500 Polderfest bands...");
// Wipe bestaande data (optioneel)
await supabase.from("bands").delete().neq("id", 0);
const bands = [];
const usedNames = new Set<string>();
for (let i = 0; i < 500; i++) {
let name = generateBandName(i);
let attempts = 0;
while (usedNames.has(name) && attempts < 10) {
name = generateBandName(i + attempts * 7);
attempts++;
}
usedNames.add(name);
const genre = pick(genres);
const sub_genre = pick(subGenresByGenre[genre]);
const tier = pick(tiers);
const popularity = tier === "headliner" ? range(80, 100)
: tier === "mid" ? range(40, 79)
: range(10, 39);
const ticket_impact = tier === "headliner" ? range(25, 60)
: tier === "mid" ? range(5, 25)
: 0;
bands.push({
name,
genre,
sub_genre,
stage: pick(stages),
day: pick(days),
start_time: pick(timeSlots),
duration_min: tier === "headliner" ? range(75, 120)
: tier === "mid" ? range(45, 75)
: range(30, 45),
origin_city: pick(cities),
members: generateMembers(),
bio: generateBio(name),
tier,
popularity,
ticket_impact,
});
}
console.log("Schrijven naar Supabase in batches van 100...");
// Supabase insert in batches (single call van 500 kan timeouten)
for (let i = 0; i < bands.length; i += 100) {
const batch = bands.slice(i, i + 100);
const { error } = await supabase.from("bands").insert(batch);
if (error) {
console.error("Insert error op batch", i / 100, ":", error.message);
process.exit(1);
}
console.log(`${i + batch.length}/${bands.length}`);
}
console.log("Klaar! 500 Polderfest bands staan in Supabase.");
}
runSeed().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}