This commit is contained in:
2026-06-09 06:33:34 +02:00
parent 4f13aadc1a
commit dc284db9b6
172 changed files with 437 additions and 15151 deletions

Binary file not shown.

View File

@@ -1,68 +0,0 @@
/**
* 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 { useChat } from "ai/react";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, status } =
useChat();
return (
<main className="max-w-2xl mx-auto p-6 flex flex-col h-screen">
<h1 className="text-2xl font-bold mb-4">
Polderfest 2027 vraag de AI
</h1>
<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.content}</div>
</div>
))}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
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>
);
}

Binary file not shown.

View File

@@ -1,61 +0,0 @@
/**
* 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 { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
export async function POST(req: Request) {
const { messages } = await req.json();
// 1. Haal alle bands op uit Supabase
const { data: bands, error } = await supabase.from("bands").select("*");
if (error) throw error;
// 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})`,
)
.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.`;
// 4. Stream naar OpenAI
const result = streamText({
model: openai("gpt-4o-mini"),
system,
messages,
});
return result.toDataStreamResponse();
}

View File

@@ -1,42 +0,0 @@
-- Polderfest 2027 — Supabase schema
-- Run dit in Supabase SQL Editor voor je het seed script gebruikt.
create table if not exists bands (
id bigserial primary key,
name text not null,
genre text not null,
sub_genre text,
stage text not null,
day text not null check (day in ('Vrijdag','Zaterdag','Zondag')),
start_time text not null, -- "21:30"
duration_min int not null default 60,
origin_city text,
members text[],
bio text,
tier text check (tier in ('headliner','mid','opener')),
popularity int check (popularity between 1 and 100),
ticket_impact numeric(6,2), -- bijdrage aan ticketprijs als extra
created_at timestamp default now()
);
-- Maak indexen voor de vragen die we vaak gaan stellen
create index if not exists idx_bands_day on bands(day);
create index if not exists idx_bands_stage on bands(stage);
create index if not exists idx_bands_genre on bands(genre);
create index if not exists idx_bands_tier on bands(tier);
-- RLS (we lezen public, geen edits voor anon)
alter table bands enable row level security;
create policy "Bands zijn publiek leesbaar"
on bands for select
using (true);
-- Optioneel: tabel voor straks (tool calling demo in Les 12)
create table if not exists user_favorites (
id bigserial primary key,
user_email text not null,
band_id bigint not null references bands(id) on delete cascade,
created_at timestamp default now(),
unique(user_email, band_id)
);

View File

@@ -1,272 +0,0 @@
/**
* 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);
});