diff --git a/Les01-Introductie-AI/[AI] Les 1 .key b/Les01-Introductie-AI/[AI] Les 1 .key new file mode 100755 index 0000000..57c4813 Binary files /dev/null and b/Les01-Introductie-AI/[AI] Les 1 .key differ diff --git a/Les10-Supabase-Auth/.env.local b/Les10-Supabase-Auth/.env.local new file mode 100644 index 0000000..1c7e1a1 --- /dev/null +++ b/Les10-Supabase-Auth/.env.local @@ -0,0 +1,2 @@ +NEXT_PUBLIC_SUPABASE_URL=https://ooozbbewsglfvysikbsf.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_QNSXIe0FBRAP-Wgd_Rb_uA_GB1WcEHJ \ No newline at end of file diff --git a/Les10-Supabase-Auth/Les10-Docenttekst.md b/Les10-Supabase-Auth/Les10-Docenttekst.md new file mode 100644 index 0000000..a56206b --- /dev/null +++ b/Les10-Supabase-Auth/Les10-Docenttekst.md @@ -0,0 +1,1651 @@ +# Les 10 — Supabase Authenticatie & RLS: Docenttekst + +**Docent:** Tim +**Duur:** 3 uur (180 min) — 09:00 tot 12:00 +**Cursus:** AI Developer — NOVI Hogeschool Utrecht +**Datum:** Lesweek 10 + +--- + +## Overzicht & Tijdsindeling + +| Tijd | Duur | Onderdeel | Slide(s) | +|---------------|--------|-------------------------------------------------|----------| +| 09:00 – 09:10 | 10 min | Welkom + Terugblik Les 8-9 | 1, 2, 3 | +| 09:10 – 09:25 | 15 min | Eindexamenopdracht Introductie | 4 | +| 09:25 – 09:45 | 20 min | Theorie: Authenticatie & Supabase Auth | 5, 6, 7 | +| 09:45 – 10:00 | 15 min | Theorie: Auth in Next.js + RLS | 8, 9 | +| 10:00 – 10:15 | 15 min | **Pauze** | 10 | +| 10:15 – 10:30 | 15 min | Hands-on: Auth Opzetten in Supabase | 11 | +| 10:30 – 10:55 | 25 min | Hands-on: Login & Registratie Bouwen | 12 | +| 10:55 – 11:10 | 15 min | Hands-on: Sessie & Beschermde Routes | 13 | +| 11:10 – 11:25 | 15 min | Hands-on: Basis RLS | 14 | +| 11:25 – 11:45 | 20 min | Doorwerken + Tim loopt rond | 11-14 | +| 11:45 – 12:00 | 15 min | Samenvatting + Huiswerk | 15 | + +--- + +## Benodigdheden + +- Slides Les 10 (15 slides) +- Werkende Poll App uit Les 8-9 (Next.js 16 + TypeScript + Tailwind CSS + Supabase) +- Supabase dashboard open in browser (LIVE demo) +- VS Code met project geopend +- Terminal klaar voor npm commando's + +--- + +## BLOK 1 — Klassikaal (09:00 – 10:00) + +--- + +### 09:00 – 09:10 | Welkom + Terugblik Les 8-9 (10 min) + +--- + +📊 **Slide 1 — Les 10: Supabase Auth & Row Level Security** + +> **Tim zegt:** +> "Goedemorgen allemaal! Welkom bij Les 10. Vandaag gaan we iets heel belangrijks toevoegen aan onze Poll App: gebruikers. Op dit moment kan iedereen alles doen in onze app — polls aanmaken, stemmen, noem maar op. Dat gaat vandaag veranderen." + +--- + +📊 **Slide 2 — Planning Vandaag** + +> **Tim zegt:** +> "Laat me even laten zien wat we vandaag gaan doen. We beginnen met een korte terugblik op wat we in Les 8 en 9 hebben gebouwd. Dan heb ik een belangrijke aankondiging over de eindexamenopdracht. Daarna duiken we in de theorie van authenticatie en hoe Supabase dat regelt. Na de pauze gaan we hands-on aan de slag: we bouwen login en registratie, beschermen onze routes, en zetten Row Level Security op. Best een volle les, maar we doen het stap voor stap." + +--- + +📊 **Slide 3 — Terugblik Les 8-9: Supabase Setup & Database** + +> **Tim zegt:** +> "Even een snelle terugblik. In Les 8 en 9 hebben we Supabase opgezet als onze backend. We hebben een project aangemaakt, twee tabellen gebouwd — `polls` en `options` — en onze Next.js app verbonden met Supabase. Jullie kunnen polls aanmaken en erop stemmen." + +> **Tim zegt:** +> "Maar er mist iets heel belangrijks. Wie kan me vertellen wat er mist?" + +*Wacht op antwoorden. Stuur richting: er is geen login, iedereen kan alles doen, er is geen beveiliging.* + +> **Tim zegt:** +> "Precies. Er is geen authenticatie. Iedereen die de URL kent kan alles doen met onze database. In de echte wereld wil je weten WIE iets doet, en wil je bepalen WAT die persoon mag doen. Dat is precies wat we vandaag gaan bouwen." + +--- + +### 09:10 – 09:25 | Eindexamenopdracht Introductie (15 min) + +--- + +📊 **Slide 4 — Eindexamenopdracht: Vrije Keuze App** + +> **Tim zegt:** +> "Voordat we met de theorie beginnen, wil ik jullie alvast vertellen over de eindexamenopdracht. Dit is belangrijk, dus luister goed." + +> **Tim zegt:** +> "De eindexamenopdracht is een vrije keuze app. Dat betekent: jullie mogen zelf kiezen wat voor applicatie je gaat bouwen. Het kan een to-do app zijn, een recepten-app, een fitness tracker, een blog platform — het maakt niet uit, zolang je de technieken gebruikt die we in deze cursus leren." + +> **Tim zegt:** +> "Wat zijn die technieken? Laat me de requirements even langslopen:" + +Benoem de volgende requirements: +- **Next.js** als framework (met TypeScript) +- **Tailwind CSS** voor styling +- **Supabase** als backend (database + authenticatie) +- **AI-assisted development** — je gebruikt tools zoals ChatGPT, Copilot, of Claude om je te helpen bij het bouwen +- **Authenticatie** — gebruikers moeten kunnen inloggen (wat we vandaag leren!) +- **CRUD operaties** — je app moet data kunnen aanmaken, lezen, updaten en verwijderen +- **Row Level Security** — je data moet beveiligd zijn (ook vandaag!) + +> **Tim zegt:** +> "Jullie zien: alles wat we tot nu toe geleerd hebben komt samen in die eindopdracht. En vandaag leren we de laatste twee grote onderdelen: authenticatie en beveiliging. Na vandaag hebben jullie alle bouwstenen om je eigen app te gaan bouwen." + +> **Tim zegt:** +> "Begin alvast na te denken over wat je wilt bouwen. Volgende les gaan we er meer over praten en kunnen jullie vragen stellen. Maar ik wilde het alvast noemen zodat jullie weten waar we naartoe werken." + +*Geef ruimte voor korte vragen. Houd het kort — max 2-3 vragen.* + +--- + +### 09:25 – 09:45 | Theorie: Authenticatie & Supabase Auth (20 min) + +--- + +📊 **Slide 5 — Wat is Authenticatie?** + +> **Tim zegt:** +> "Oké, laten we beginnen met de basis. Wat is authenticatie eigenlijk? En wat is het verschil met autorisatie? Dit zijn twee termen die vaak door elkaar worden gehaald, maar ze betekenen iets heel anders." + +> **Tim zegt:** +> "**Authenticatie** is: WIE ben je? Het is het proces van bewijzen dat je bent wie je zegt dat je bent. Denk aan inloggen met je email en wachtwoord. Je bewijst: ik ben Tim, want ik ken het wachtwoord van Tim's account." + +> **Tim zegt:** +> "**Autorisatie** is: WAT mag je doen? Nadat we weten wie je bent, bepalen we wat je mag. Mag je alleen je eigen polls zien? Mag je polls van anderen verwijderen? Dat is autorisatie." + +> **Tim zegt:** +> "Een voorbeeld uit het dagelijks leven: als je naar een festival gaat, dan is je ID-bewijs de authenticatie — je bewijst wie je bent. Je ticket is de autorisatie — het bepaalt of je naar binnen mag en welke gebieden je in mag." + +> **Tim zegt:** +> "Vandaag doen we allebei. Authenticatie met Supabase Auth, en autorisatie met Row Level Security." + +--- + +📊 **Slide 6 — Supabase Auth: 3 Methodes** + +*Open nu het Supabase dashboard in de browser. Navigeer naar Authentication > Providers.* + +> **Tim zegt:** +> "Supabase heeft een ingebouwd authenticatiesysteem. Je hoeft geen eigen login-systeem te bouwen — Supabase regelt alles voor je: wachtwoorden hashen, sessies beheren, tokens genereren. Laat me dit even laten zien in het dashboard." + +**LIVE DEMO in Supabase Dashboard:** + +1. Open het Supabase project in de browser +2. Klik op **Authentication** in de linkerzijbalk +3. Laat de **Users** tab zien (nu nog leeg) +4. Klik op **Providers** onder Configuration + +> **Tim zegt:** +> "Kijk, hier zie je alle providers die Supabase ondersteunt. Er zijn er heel veel, maar wij focussen op drie methodes:" + +> **Tim zegt:** +> "**Methode 1: Email + Wachtwoord.** De klassieke manier. Gebruiker vult email en wachtwoord in, Supabase slaat het veilig op. Het wachtwoord wordt gehasht — dat betekent dat zelfs Supabase je wachtwoord niet kan zien. Dit is standaard ingeschakeld." + +*Wijs in het dashboard naar de Email provider — laat zien dat deze standaard aan staat.* + +> **Tim zegt:** +> "**Methode 2: Magic Link.** Dit is een coole methode. De gebruiker vult alleen zijn email in, en krijgt een link per mail. Als je op die link klikt, ben je ingelogd. Geen wachtwoord nodig. Heel veilig, want alleen de eigenaar van dat emailadres kan inloggen. Dit werkt ook via de Email provider." + +> **Tim zegt:** +> "**Methode 3: Social Login (OAuth).** Inloggen via Google, GitHub, Discord, enzovoort. De gebruiker klikt op 'Login met Google', wordt doorgestuurd naar Google, logt daar in, en komt terug in jouw app. Heel handig, maar iets complexer om op te zetten. Dit doen we vandaag niet, maar het is goed om te weten dat het bestaat." + +*Scroll even door de lijst met providers zodat studenten zien hoeveel opties er zijn (Google, GitHub, Apple, Discord, etc.).* + +> **Tim zegt:** +> "Wij gaan vandaag methode 1 en 2 gebruiken: email met wachtwoord, en magic link. Dat is voor 90% van de apps meer dan genoeg." + +--- + +📊 **Slide 7 — Hoe Werkt een Sessie? (JWT & Cookies)** + +> **Tim zegt:** +> "Oké, maar hoe werkt dat technisch? Als je inlogt, wat gebeurt er dan achter de schermen? Hier komen twee belangrijke termen: JWT en cookies." + +> **Tim zegt:** +> "Als je inlogt bij Supabase, krijg je een **JWT** terug — een JSON Web Token. Dat is eigenlijk een lange string, een soort pasje, dat bewijst dat jij ingelogd bent. In die token staat wie je bent, wanneer je bent ingelogd, en wanneer het verloopt." + +> **Tim zegt:** +> "Die token moet ergens bewaard worden zodat je niet bij elke pagina opnieuw hoeft in te loggen. Dat doen we met **cookies**. Een cookie is een klein stukje data dat je browser automatisch meestuurt bij elk verzoek naar de server." + +> **Tim zegt:** +> "Dus het werkt zo:" + +Leg het volgende stappenplan uit: + +1. Gebruiker logt in met email + wachtwoord +2. Supabase controleert de gegevens +3. Supabase stuurt een JWT token terug +4. De token wordt opgeslagen als cookie in de browser +5. Bij elk volgend verzoek stuurt de browser de cookie mee +6. De server leest de cookie, checkt de token, en weet wie je bent + +> **Tim zegt:** +> "Het mooie van Supabase is dat dit allemaal automatisch gaat. Wij hoeven alleen de juiste packages te installeren en een paar bestanden aan te maken. Supabase regelt het hashen van wachtwoorden, het aanmaken van tokens, en het vernieuwen van verlopen tokens." + +**LIVE DEMO in Supabase Dashboard:** + +1. Ga naar **Authentication > Users** +2. Klik op **Add user > Create new user** +3. Vul een test email en wachtwoord in (bijv. `test@voorbeeld.nl` / `test1234`) +4. Klik op **Create user** + +> **Tim zegt:** +> "Kijk, ik heb nu handmatig een gebruiker aangemaakt in het dashboard. Dit is handig voor testen. Straks gaan we dit vanuit de app doen, maar het is goed om te weten dat je ook handmatig gebruikers kunt beheren." + +*Laat de aangemaakte user zien in de lijst. Wijs op de kolommen: email, created at, last sign in, etc.* + +--- + +### 09:45 – 10:00 | Theorie: Auth in Next.js + RLS (15 min) + +--- + +📊 **Slide 8 — Auth in Next.js (@supabase/ssr)** + +> **Tim zegt:** +> "Nu we weten hoe authenticatie werkt, moeten we het koppelen aan onze Next.js app. Daarvoor gebruiken we een package die `@supabase/ssr` heet. SSR staat voor Server-Side Rendering." + +> **Tim zegt:** +> "Waarom een speciale package? Omdat Next.js zowel op de server als in de browser draait. In de browser heb je gewoon toegang tot cookies. Maar op de server — als een pagina wordt gerenderd op de server — moet je cookies op een andere manier lezen en schrijven. `@supabase/ssr` regelt dat voor je." + +> **Tim zegt:** +> "We gaan straks drie belangrijke bestanden aanmaken:" + +Benoem de drie bestanden: + +1. **`src/lib/supabase/client.ts`** — De browser client. Wordt gebruikt in componenten die in de browser draaien (Client Components). +2. **`src/lib/supabase/server.ts`** — De server client. Wordt gebruikt in Server Components en Server Actions. +3. **`src/middleware.ts`** — Middleware die bij ELK verzoek draait. Controleert of de gebruiker is ingelogd en vernieuwt de sessie. + +> **Tim zegt:** +> "De middleware is het belangrijkste stuk. Die draait bij elk verzoek — elke keer als je een pagina opent. De middleware checkt: is er een geldige sessie? Zo niet, stuur de gebruiker naar de login pagina. Zo ja, laat het verzoek door." + +> **Tim zegt:** +> "Denk aan de middleware als een beveiliger bij de deur. Elke bezoeker wordt gecheckt voordat ze naar binnen mogen." + +> **Tim zegt:** +> "En dan is er nog de **auth callback route**. Als een gebruiker een magic link aanklikt, of terugkomt van een OAuth provider, komt die terecht op een speciale URL in je app. Die route wisselt de code om voor een sessie. Dat is een technisch detail, maar het is een bestand dat je nodig hebt." + +--- + +📊 **Slide 9 — Row Level Security (RLS)** + +> **Tim zegt:** +> "Het laatste theorieblok: Row Level Security, oftewel RLS. Dit is de autorisatie-kant van het verhaal." + +> **Tim zegt:** +> "RLS is een feature van de database zelf — niet van Next.js, niet van Supabase Auth, maar van PostgreSQL. Het betekent dat je regels kunt instellen op de database die bepalen wie welke rijen mag lezen, aanmaken, updaten of verwijderen." + +> **Tim zegt:** +> "Zonder RLS kan iedereen die de URL van je Supabase project kent alles doen met je data. Dat is een groot beveiligingsrisico. Met RLS zeg je: 'alleen ingelogde gebruikers mogen polls aanmaken' of 'alleen de eigenaar mag een poll verwijderen'." + +**LIVE DEMO in Supabase Dashboard:** + +1. Ga naar **Table Editor** en klik op de tabel `polls` +2. Wijs op het gele waarschuwingsicoontje naast de tabel (RLS is uitgeschakeld) +3. Klik op **RLS disabled** (of ga via **Authentication > Policies**) + +> **Tim zegt:** +> "Kijk hier — Supabase waarschuwt ons: RLS staat uit op deze tabel. Dat betekent dat iedereen alles kan doen. Dat gaan we straks fixen." + +> **Tim zegt:** +> "Een RLS policy is een regel die je schrijft in SQL. Het ziet er zo uit:" + +Schrijf op het whiteboard of laat op de slide zien: + +```sql +CREATE POLICY "Iedereen kan polls lezen" +ON polls FOR SELECT +USING (true); +``` + +> **Tim zegt:** +> "Dit zegt: voor de tabel `polls`, bij een `SELECT` query — dus bij het lezen — mag iedereen alles zien. `USING (true)` betekent: altijd waar, geen beperking." + +> **Tim zegt:** +> "Maar we kunnen ook zeggen: alleen ingelogde gebruikers mogen polls aanmaken:" + +```sql +CREATE POLICY "Ingelogde gebruikers maken polls" +ON polls FOR INSERT +TO authenticated +WITH CHECK (true); +``` + +> **Tim zegt:** +> "`TO authenticated` is het belangrijke deel. Dat is een speciale Supabase rol die alleen geldt voor ingelogde gebruikers. Als je niet bent ingelogd, krijg je de `anon` rol, en die heeft geen toestemming voor INSERT." + +> **Tim zegt:** +> "Er is ook een speciale functie: `auth.uid()`. Die geeft het ID van de ingelogde gebruiker. Daarmee kun je zeggen: je mag alleen je EIGEN data lezen of bewerken. Dat gaan we vandaag niet gebruiken in onze Poll App, maar het is goed om te weten voor je eindopdracht." + +> **Tim zegt:** +> "Oké, dat was veel theorie! Laten we even pauze houden en dan gaan we het allemaal bouwen." + +--- + +### 10:00 – 10:15 | Pauze (15 min) + +--- + +📊 **Slide 10 — Pauze** + +> **Tim zegt:** +> "We houden een kwartier pauze. Om kwart over 10 beginnen we met de hands-on. Zorg dat je laptop klaar staat met je project geopend in VS Code." + +--- + +## BLOK 2 — Hands-on (10:15 – 11:45) + +--- + +### 10:15 – 10:30 | Hands-on: Auth Opzetten in Supabase (15 min) + +--- + +📊 **Slide 11 — Hands-on: Auth Opzetten in Supabase** + +*Deze slide blijft zichtbaar terwijl studenten werken. Alle stappen staan erop.* + +> **Tim zegt:** +> "Oké, we gaan beginnen! Ik doe het voor op het scherm en jullie doen mee. We beginnen met het installeren van de packages en het aanmaken van de bestanden." + +> **Tim zegt:** +> "Open je terminal in VS Code — je weet hoe dat moet: Ctrl+backtick of via het menu. Zorg dat je in de root van je project staat." + +--- + +#### Stap 1: Installeer de packages + +> **Tim zegt:** +> "Allereerst installeren we twee packages. Type het volgende commando:" + +```bash +npm install @supabase/ssr @supabase/supabase-js +``` + +> **Tim zegt:** +> "Even wachten tot het klaar is... `@supabase/ssr` is de package die we nodig hebben voor authenticatie in Next.js. `@supabase/supabase-js` hebben jullie misschien al — dat is de standaard Supabase client. Als die al geinstalleerd is, wordt hij geüpdatet." + +*Wacht tot iedereen klaar is. Loop rond en check of er errors zijn.* + +--- + +#### Stap 2: Controleer je `.env.local` + +> **Tim zegt:** +> "Controleer even of je `.env.local` bestand de juiste variabelen bevat. Dit zou er al moeten staan van Les 8:" + +``` +NEXT_PUBLIC_SUPABASE_URL=https://jouw-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=jouw-anon-key +``` + +> **Tim zegt:** +> "Als je deze niet hebt, ga naar je Supabase dashboard, klik op Settings, dan API, en kopieer de URL en de anon key." + +--- + +#### Stap 3: Maak de mappenstructuur aan + +> **Tim zegt:** +> "Nu gaan we de bestanden aanmaken. Maak eerst de map aan als die nog niet bestaat:" + +```bash +mkdir -p src/lib/supabase +``` + +--- + +#### Stap 4: Browser Client (`src/lib/supabase/client.ts`) + +> **Tim zegt:** +> "Het eerste bestand is de browser client. Maak een nieuw bestand aan: `src/lib/supabase/client.ts`. Dit is de simpelste van de drie." + +```typescript +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} +``` + +> **Tim zegt:** +> "Dit is een simpele functie die een Supabase client aanmaakt voor de browser. De `createBrowserClient` functie uit `@supabase/ssr` regelt automatisch dat cookies goed worden gelezen en geschreven in de browser. Die uitroeptekens achter `process.env` zijn TypeScript — ze zeggen: ik weet zeker dat deze waarde bestaat." + +--- + +#### Stap 5: Server Client (`src/lib/supabase/server.ts`) + +> **Tim zegt:** +> "Nu het server bestand. Maak `src/lib/supabase/server.ts` aan. Dit is iets complexer, maar jullie hoeven het niet uit je hoofd te kennen — je kunt het altijd kopiëren." + +```typescript +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) + ) + }, + }, + } + ) +} +``` + +> **Tim zegt:** +> "Dit bestand is voor de server-kant van Next.js. Het gebruikt `cookies()` van `next/headers` — dat is een Next.js functie die je toegang geeft tot de cookies op de server. Merk op dat de functie `async` is — `cookies()` is asynchroon in Next.js 16." + +> **Tim zegt:** +> "Het `cookies` object dat we meegeven aan `createServerClient` vertelt Supabase hoe het cookies moet lezen met `getAll()` en schrijven met `setAll()`. Supabase gebruikt dit om de sessie bij te houden." + +--- + +#### Stap 6: Middleware (`src/middleware.ts`) + +> **Tim zegt:** +> "Nu het belangrijkste bestand: de middleware. Maak `src/middleware.ts` aan — let op, dit bestand staat in de `src` map, NIET in `src/lib/supabase`. Het is een speciaal Next.js bestand." + +```typescript +import { createServerClient } from '@supabase/ssr' +import { NextResponse, type NextRequest } from 'next/server' + +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ) + supabaseResponse = NextResponse.next({ + request, + }) + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ) + }, + }, + } + ) + + // Belangrijk: haal de user op om de sessie te vernieuwen + const { + data: { user }, + } = await supabase.auth.getUser() + + // Als er geen user is en we zijn niet op de login pagina, redirect naar login + if ( + !user && + !request.nextUrl.pathname.startsWith('/login') && + !request.nextUrl.pathname.startsWith('/auth') + ) { + const url = request.nextUrl.clone() + url.pathname = '/login' + return NextResponse.redirect(url) + } + + return supabaseResponse +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|login|auth).*)', + ], +} +``` + +> **Tim zegt:** +> "Dit is het langste bestand, maar het is heel logisch als je het stap voor stap bekijkt." + +> **Tim zegt:** +> "Bovenaan maken we een Supabase client aan, net als bij de server client, maar dan met de cookies van het request object. Dan halen we de user op met `getUser()`. Dit doet twee dingen: het controleert of er een geldige sessie is, en het vernieuwt de sessie als die bijna verlopen is." + +> **Tim zegt:** +> "Dan de if-statement: als er geen user is EN we zijn niet al op de login pagina, dan redirecten we naar `/login`. Simpel." + +> **Tim zegt:** +> "Onderaan staat de `matcher` config. Die bepaalt voor welke URLs de middleware draait. We sluiten statische bestanden uit (`_next/static`, `_next/image`, `favicon.ico`) en de login en auth pagina's zelf — anders zou je nooit op de login pagina kunnen komen!" + +*Loop rond en help studenten die vastlopen. Veel voorkomende problemen:* +- *Bestand op de verkeerde plek (middleware.ts moet in `src/`, niet in `src/app/`)* +- *Typfouten in de imports* +- *Missende `.env.local` variabelen* + +> **Tim zegt:** +> "Is iedereen zover? Vier bestanden aangemaakt? Als je ergens vastloopt, steek je hand op. We gaan zo door met de login pagina." + +--- + +### 10:30 – 10:55 | Hands-on: Login & Registratie Bouwen (25 min) + +--- + +📊 **Slide 12 — Hands-on: Login & Registratie Bouwen** + +*Deze slide blijft zichtbaar met alle stappen.* + +> **Tim zegt:** +> "Nu gaan we de login pagina bouwen. We maken een pagina waar gebruikers kunnen inloggen met email en wachtwoord, of zich kunnen registreren." + +--- + +#### Stap 1: Auth Callback Route aanmaken + +> **Tim zegt:** +> "Eerst hebben we de auth callback route nodig. Die is nodig voor magic links. Maak de volgende map en bestand aan:" + +```bash +mkdir -p src/app/auth/callback +``` + +Maak `src/app/auth/callback/route.ts` aan: + +```typescript +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get('code') + + if (code) { + const supabase = await createClient() + await supabase.auth.exchangeCodeForSession(code) + } + + return NextResponse.redirect(origin) +} +``` + +> **Tim zegt:** +> "Dit is een API route — een route die geen pagina toont, maar een verzoek afhandelt. Als een gebruiker op een magic link klikt, stuurt Supabase ze naar `/auth/callback?code=abc123`. Deze route pakt die code, wisselt het om voor een sessie, en redirect de gebruiker naar de homepage." + +--- + +#### Stap 2: Login pagina aanmaken + +> **Tim zegt:** +> "Nu de login pagina zelf. Maak de map en het bestand aan:" + +```bash +mkdir -p src/app/login +``` + +Maak `src/app/login/page.tsx` aan: + +```typescript +'use client' + +import { createClient } from '@/lib/supabase/client' +import { useRouter } from 'next/navigation' +import { useState } from 'react' + +export default function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState('') + const [isSignUp, setIsSignUp] = useState(false) + const router = useRouter() + const supabase = createClient() + + const handleEmailLogin = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setMessage('') + + if (isSignUp) { + const { error } = await supabase.auth.signUp({ + email, + password, + }) + if (error) { + setMessage(error.message) + } else { + setMessage('Check je email voor een bevestigingslink!') + } + } else { + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + if (error) { + setMessage(error.message) + } else { + router.push('/') + router.refresh() + } + } + + setLoading(false) + } + + const handleMagicLink = async () => { + if (!email) { + setMessage('Vul eerst je email in') + return + } + setLoading(true) + setMessage('') + + const { error } = await supabase.auth.signInWithOtp({ + email, + }) + + if (error) { + setMessage(error.message) + } else { + setMessage('Check je email voor een magic link!') + } + + setLoading(false) + } + + return ( +
+
+
+

+ {isSignUp ? 'Account aanmaken' : 'Inloggen'} +

+

+ Poll App +

+
+ +
+
+
+ + setEmail(e.target.value)} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + placeholder="jouw@email.nl" + /> +
+ +
+ + setPassword(e.target.value)} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + placeholder="Minimaal 6 tekens" + /> +
+
+ + {message && ( +
+ {message} +
+ )} + +
+ + + +
+
+ +
+ +
+
+
+ ) +} +``` + +> **Tim zegt:** +> "Dit is een best groot bestand, maar het is eigenlijk vrij simpel. Laten we het doorlopen:" + +> **Tim zegt:** +> "Bovenaan staan onze state variabelen: email, password, loading, message, en of we in sign-up of login modus zitten." + +> **Tim zegt:** +> "De `handleEmailLogin` functie doet twee dingen: als `isSignUp` true is, roepen we `supabase.auth.signUp()` aan. Anders roepen we `supabase.auth.signInWithPassword()` aan. Bij succes redirecten we naar de homepage." + +> **Tim zegt:** +> "De `handleMagicLink` functie roept `supabase.auth.signInWithOtp()` aan — die stuurt een magic link naar het email adres." + +> **Tim zegt:** +> "De rest is gewoon een formulier met Tailwind styling. Twee input velden, twee buttons, en een toggle om te wisselen tussen inloggen en registreren." + +*Loop rond terwijl studenten het overtikken of kopiëren.* + +--- + +#### Stap 3: Email bevestiging uitschakelen (voor ontwikkeling) + +> **Tim zegt:** +> "Een belangrijk ding! Standaard stuurt Supabase een bevestigingsmail als je je registreert. Dat is goed voor productie, maar vervelend voor development. We gaan dat even uitschakelen." + +**LIVE DEMO in Supabase Dashboard:** + +1. Ga naar **Authentication** > **Providers** > **Email** +2. Schakel **Confirm email** uit (toggle) +3. Klik op **Save** + +> **Tim zegt:** +> "Nu kunnen jullie je registreren zonder een email te hoeven bevestigen. In een echte app laat je dit aan staan!" + +--- + +#### Stap 4: Testen + +> **Tim zegt:** +> "Start je dev server als die nog niet draait:" + +```bash +npm run dev +``` + +> **Tim zegt:** +> "Ga naar `http://localhost:3000`. Je zou nu automatisch naar `/login` gestuurd moeten worden — dat is de middleware die je redirect! Registreer een account met je eigen email en een wachtwoord. Gebruik minimaal 6 tekens voor het wachtwoord." + +*Doe de registratie zelf LIVE voor op het scherm.* + +> **Tim zegt:** +> "Als het goed is, ben je nu ingelogd en zie je je Poll App. Ga terug naar het Supabase dashboard, naar Authentication > Users, en je ziet je nieuwe gebruiker staan!" + +*Laat in het dashboard de nieuwe user zien.* + +*Voorkomende problemen:* +- *"Invalid login credentials" — wachtwoord te kort (min 6 tekens)* +- *"User already registered" — gebruik een ander emailadres* +- *Redirect loop — middleware config klopt niet, check de matcher* +- *404 op /login — bestand op verkeerde plek* + +--- + +### 10:55 – 11:10 | Hands-on: Sessie & Beschermde Routes (15 min) + +--- + +📊 **Slide 13 — Hands-on: Sessie & Beschermde Routes** + +*Slide blijft zichtbaar met alle stappen.* + +> **Tim zegt:** +> "We zijn ingelogd! Maar de gebruiker weet dat niet — er is niets in de UI dat laat zien dat je bent ingelogd. Laten we dat toevoegen: een welkomstbericht met het emailadres en een uitlog-knop." + +--- + +#### Stap 1: Navbar component aanmaken + +> **Tim zegt:** +> "We gaan een simpele navbar maken die de gebruikersinformatie laat zien. Maak een nieuw bestand aan:" + +```bash +mkdir -p src/components +``` + +Maak `src/components/Navbar.tsx` aan: + +```typescript +'use client' + +import { createClient } from '@/lib/supabase/client' +import { useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import type { User } from '@supabase/supabase-js' + +export default function Navbar() { + const [user, setUser] = useState(null) + const router = useRouter() + const supabase = createClient() + + useEffect(() => { + const getUser = async () => { + const { data: { user } } = await supabase.auth.getUser() + setUser(user) + } + getUser() + }, []) + + const handleSignOut = async () => { + await supabase.auth.signOut() + router.push('/login') + router.refresh() + } + + return ( + + ) +} +``` + +> **Tim zegt:** +> "Dit is een Client Component — het draait in de browser. We gebruiken `useEffect` om de ingelogde user op te halen als de component laadt. Dan tonen we het emailadres en een uitlog-knop." + +> **Tim zegt:** +> "De `handleSignOut` functie roept `supabase.auth.signOut()` aan en stuurt de gebruiker terug naar de login pagina." + +--- + +#### Stap 2: Navbar toevoegen aan de layout + +> **Tim zegt:** +> "Nu moeten we de Navbar toevoegen aan onze app. Open `src/app/layout.tsx` en importeer de Navbar component." + +Pas `src/app/layout.tsx` aan: + +```typescript +import Navbar from '@/components/Navbar' +``` + +En voeg de Navbar toe aan de body, boven `{children}`: + +```tsx + + +
{children}
+ +``` + +> **Tim zegt:** +> "Let op: als je al een layout hebt met wat Tailwind classes, pas dan alleen de Navbar import en het component toe. Verwijder niet je bestaande styling." + +> **Tim zegt:** +> "We willen de Navbar niet tonen op de login pagina. Er zijn meerdere manieren om dat op te lossen, maar de simpelste is: de Navbar checkt zelf of er een user is. Als er geen user is, toont hij niets. En dat doen we al — we tonen de user info alleen als `user` niet null is." + +--- + +#### Stap 3: Testen + +> **Tim zegt:** +> "Ga naar je browser en refresh de pagina. Je zou nu bovenaan je emailadres moeten zien en een uitlog-knop." + +*Doe het LIVE voor:* +1. Laat de pagina zien met Navbar +2. Klik op "Uitloggen" +3. Laat zien dat je naar de login pagina wordt gestuurd +4. Probeer naar `http://localhost:3000` te gaan — je wordt terug gestuurd naar login +5. Log opnieuw in + +> **Tim zegt:** +> "Kijk, de middleware beschermt alles! Als je niet bent ingelogd, kun je niet bij de app. Dat is beschermde routes. Probeer het zelf: log uit en probeer direct naar localhost:3000 te navigeren. Je wordt automatisch naar de login gestuurd." + +*Loop rond en help studenten. Voorkomende problemen:* +- *Navbar verschijnt niet — check de import en de layout.tsx* +- *Witte pagina — check de browser console voor errors* +- *User email toont niet — `getUser()` kan even duren, daardoor kort `null`* + +--- + +### 11:10 – 11:25 | Hands-on: Basis RLS (15 min) + +--- + +📊 **Slide 14 — Hands-on: Basis RLS** + +*Slide blijft zichtbaar met alle stappen.* + +> **Tim zegt:** +> "De laatste stap vandaag: Row Level Security. We gaan ervoor zorgen dat onze database beveiligd is. Op dit moment kan iedereen met de anon key alles doen met onze data. Dat gaat nu veranderen." + +--- + +#### Stap 1: RLS inschakelen + +> **Tim zegt:** +> "We gaan dit doen in het Supabase dashboard. Ga naar de SQL Editor — dat is het icoon met het database-symbool in de linkerzijbalk." + +**LIVE DEMO in Supabase Dashboard:** + +1. Ga naar **SQL Editor** +2. Klik op **New query** + +> **Tim zegt:** +> "We gaan eerst RLS inschakelen op beide tabellen. Type het volgende SQL:" + +```sql +-- Stap 1: RLS inschakelen op polls tabel +ALTER TABLE polls ENABLE ROW LEVEL SECURITY; + +-- Stap 2: RLS inschakelen op options tabel +ALTER TABLE options ENABLE ROW LEVEL SECURITY; +``` + +> **Tim zegt:** +> "Klik op Run. Als het goed is, zie je 'Success' zonder errors." + +*Voer de query LIVE uit.* + +> **Tim zegt:** +> "Nu is er iets belangrijks: als je nu naar je app gaat en probeert polls te laden, zul je NIETS zien. Dat komt omdat RLS standaard alles blokkeert. Als er geen policies zijn die iets toestaan, mag niemand iets. Probeer het maar even — refresh je app." + +*Laat zien dat de polls verdwenen zijn.* + +> **Tim zegt:** +> "Zie je? Geen polls meer! Dat is RLS in actie. We moeten nu policies aanmaken die bepalen wie wat mag." + +--- + +#### Stap 2: Policies aanmaken voor polls + +> **Tim zegt:** +> "Maak een nieuwe query aan en type het volgende:" + +```sql +-- Iedereen kan polls lezen (ook niet-ingelogde gebruikers) +CREATE POLICY "Polls are viewable by everyone" +ON polls FOR SELECT +USING (true); + +-- Alleen ingelogde gebruikers kunnen polls aanmaken +CREATE POLICY "Authenticated users can create polls" +ON polls FOR INSERT +TO authenticated +WITH CHECK (true); +``` + +> **Tim zegt:** +> "De eerste policy zegt: voor SELECT queries op de polls tabel, mag iedereen alles zien. `USING (true)` betekent: geen beperking." + +> **Tim zegt:** +> "De tweede policy zegt: voor INSERT queries, alleen de `authenticated` rol mag dat. `TO authenticated` is de sleutel — dat is de rol die je krijgt als je bent ingelogd via Supabase Auth. Als je niet bent ingelogd, ben je `anon`, en dan mag je geen polls aanmaken." + +*Voer de query LIVE uit.* + +--- + +#### Stap 3: Policies aanmaken voor options + +> **Tim zegt:** +> "Nu hetzelfde voor de options tabel:" + +```sql +-- Iedereen kan options lezen +CREATE POLICY "Options are viewable by everyone" +ON options FOR SELECT +USING (true); + +-- Ingelogde gebruikers kunnen stemmen (update) +CREATE POLICY "Authenticated users can vote" +ON options FOR UPDATE +TO authenticated +USING (true); +``` + +*Voer de query LIVE uit.* + +> **Tim zegt:** +> "Nu kan iedereen polls en opties zien, maar alleen ingelogde gebruikers kunnen nieuwe polls aanmaken en stemmen." + +--- + +#### Stap 4: Optioneel — INSERT policy voor options + +> **Tim zegt:** +> "We hebben ook een INSERT policy nodig voor options, anders kunnen we geen nieuwe opties aanmaken bij een poll:" + +```sql +-- Ingelogde gebruikers kunnen opties aanmaken +CREATE POLICY "Authenticated users can create options" +ON options FOR INSERT +TO authenticated +WITH CHECK (true); +``` + +*Voer de query LIVE uit.* + +--- + +#### Stap 5: Testen + +> **Tim zegt:** +> "Ga terug naar je app en refresh. Je polls zouden er nu weer moeten zijn!" + +*Doe het LIVE voor:* +1. Refresh de app — polls verschijnen weer +2. Maak een nieuwe poll aan — werkt! +3. Stem op een optie — werkt! + +> **Tim zegt:** +> "Mooi! Alles werkt weer, maar nu met beveiliging. Laten we even testen wat er gebeurt als je niet bent ingelogd." + +**LIVE DEMO — RLS testen:** + +1. Ga naar het Supabase dashboard +2. Ga naar **Table Editor** > **polls** +3. Wijs op het groene vinkje bij de tabel (RLS is nu ingeschakeld) +4. Ga naar **Authentication > Policies** — laat alle policies zien + +> **Tim zegt:** +> "Hier in het dashboard kun je al je policies zien en beheren. Je kunt ze hier ook bewerken of verwijderen als dat nodig is." + +> **Tim zegt:** +> "Een belangrijk punt: de policies die we nu hebben zijn vrij simpel. In je eindopdracht kun je veel specifiekere policies schrijven. Bijvoorbeeld: een gebruiker mag alleen zijn eigen data bewerken. Dan gebruik je `auth.uid()` in je policy. Dat ziet er dan zo uit:" + +```sql +-- Voorbeeld voor de eindopdracht (niet nodig voor poll app): +CREATE POLICY "Users can only edit own posts" +ON posts FOR UPDATE +TO authenticated +USING (auth.uid() = user_id) +WITH CHECK (auth.uid() = user_id); +``` + +> **Tim zegt:** +> "Hier zegt `auth.uid() = user_id` dat de ingelogde gebruiker alleen rijen mag updaten waar het `user_id` veld gelijk is aan zijn eigen gebruikers-ID. Super krachtig!" + +--- + +#### Stap 6: Policies bekijken in het dashboard + +> **Tim zegt:** +> "Laat me even laten zien hoe je alle policies kunt bekijken." + +**LIVE DEMO:** + +1. Ga naar **Authentication** > **Policies** +2. Laat de lijst van policies zien per tabel +3. Klik op een policy om de details te zien + +> **Tim zegt:** +> "Hier zie je per tabel welke policies er zijn. Je kunt ze ook via dit dashboard aanmaken door op 'New Policy' te klikken. Er zijn templates beschikbaar die het makkelijker maken. Maar voor nu is de SQL Editor prima." + +--- + +### 11:25 – 11:45 | Doorwerken (20 min) + +--- + +> **Tim zegt:** +> "Oké, de rest van de tijd is om alles af te maken en te testen. Veel van jullie zullen nog bezig zijn met het overnemen van de code. Neem de tijd en zorg dat alles werkt. Ik loop rond om te helpen." + +*Laat slides 11-14 op het scherm staan zodat studenten kunnen terugkijken.* + +**Checklist voor studenten (noem op):** + +1. Zijn alle bestanden aangemaakt? + - `src/lib/supabase/client.ts` + - `src/lib/supabase/server.ts` + - `src/middleware.ts` + - `src/app/auth/callback/route.ts` + - `src/app/login/page.tsx` + - `src/components/Navbar.tsx` +2. Kun je registreren met email + wachtwoord? +3. Kun je inloggen? +4. Zie je je emailadres in de Navbar? +5. Werkt uitloggen? +6. Word je geredirect naar login als je niet bent ingelogd? +7. Kun je nog steeds polls zien en aanmaken? +8. Is RLS ingeschakeld op beide tabellen? + +> **Tim zegt:** +> "Als je alles werkend hebt en nog tijd over hebt, probeer dan het volgende: log uit en open een incognito venster. Ga naar je app. Kun je polls ZIEN? Kun je polls AANMAKEN? Wat verwacht je dat er gebeurt?" + +*Het verwachte resultaat: je wordt geredirect naar de login pagina door de middleware, dus je kunt niets doen. Als je de middleware even uitzet en direct de API probeert, zou SELECT werken maar INSERT niet dankzij RLS.* + +*Loop rond en help actief. Veel voorkomende problemen:* +- *Studenten die de middleware op de verkeerde plek hebben gezet* +- *Typfouten in de import paden (bijv. `@/lib/supabase/client` vs `@/lib/supabase/server`)* +- *Vergeten om email bevestiging uit te schakelen in het dashboard* +- *Wachtwoord te kort bij registratie* +- *RLS ingeschakeld maar geen policies — dan werkt niets* + +--- + +## BLOK 3 — Afsluiting (11:45 – 12:00) + +--- + +### 11:45 – 12:00 | Samenvatting + Huiswerk (15 min) + +--- + +📊 **Slide 15 — Samenvatting + Huiswerk** + +> **Tim zegt:** +> "Laten we even samenvatten wat we vandaag geleerd hebben." + +> **Tim zegt:** +> "We hebben het gehad over het verschil tussen **authenticatie** — wie ben je — en **autorisatie** — wat mag je. We hebben geleerd dat Supabase een ingebouwd authenticatiesysteem heeft met drie methodes: email + wachtwoord, magic links, en social login." + +> **Tim zegt:** +> "We hebben `@supabase/ssr` geinstalleerd en drie belangrijke bestanden aangemaakt: de browser client, de server client, en de middleware. De middleware is de bewaker die bij elk verzoek checkt of je bent ingelogd." + +> **Tim zegt:** +> "We hebben een login pagina gebouwd met registratie, inloggen, en magic link functionaliteit. We hebben een Navbar toegevoegd die het emailadres toont en een uitlog-knop." + +> **Tim zegt:** +> "En als laatste hebben we Row Level Security ingeschakeld. We hebben policies geschreven die bepalen: iedereen mag lezen, maar alleen ingelogde gebruikers mogen aanmaken en bewerken." + +> **Tim zegt:** +> "Dit zijn de bouwstenen die je nodig hebt voor je eindopdracht. Elke echte app heeft gebruikers en beveiliging nodig." + +--- + +#### Huiswerk + +> **Tim zegt:** +> "Voor het huiswerk:" + +Benoem de volgende punten: + +1. **Zorg dat je Poll App volledig werkt** met authenticatie en RLS. Alles wat we vandaag gedaan hebben moet werken: registreren, inloggen, uitloggen, beschermde routes, en RLS. + +2. **Voeg een `user_id` kolom toe aan de `polls` tabel.** Ga naar de Table Editor in Supabase, voeg een kolom toe van type `uuid`, en koppel die aan `auth.users(id)`. Als je dan een poll aanmaakt, sla je op wie de poll heeft gemaakt. Pas daarna je INSERT code aan om `user_id` mee te sturen. + +3. **Begin na te denken over je eindopdracht.** Wat voor app wil je bouwen? Welke tabellen heb je nodig? Welke pagina's? Schrijf een korte beschrijving van je idee — maximaal een half A4. + +> **Tim zegt:** +> "Punt 2 is een uitdaging, maar het is een goede oefening. Je moet de tabel aanpassen in Supabase, en je code aanpassen in Next.js. Tip: gebruik `auth.uid()` in je RLS policy om ervoor te zorgen dat gebruikers alleen hun eigen polls kunnen bewerken." + +> **Tim zegt:** +> "En punt 3: begin echt na te denken over je eindopdracht. Volgende les gaan we het er uitgebreider over hebben en kun je je idee pitchen." + +--- + +#### Vragen + +> **Tim zegt:** +> "Zijn er nog vragen over vandaag?" + +*Neem de tijd voor vragen. Veel voorkomende vragen:* + +**V: Moet ik magic link ook werkend hebben voor de eindopdracht?** +> **Tim zegt:** +> "Nee, email + wachtwoord is voldoende. Magic link is een extra optie die handig is, maar niet verplicht." + +**V: Hoe werkt `auth.uid()` precies?** +> **Tim zegt:** +> "`auth.uid()` is een functie in PostgreSQL die Supabase toevoegt. Het geeft het UUID terug van de ingelogde gebruiker. Als niemand is ingelogd, geeft het NULL terug. Je kunt het gebruiken in RLS policies om te checken of een rij van de ingelogde gebruiker is." + +**V: Kan ik Google login gebruiken voor mijn eindopdracht?** +> **Tim zegt:** +> "Ja, dat kan! Maar het is iets complexer om op te zetten. Je moet een project aanmaken in de Google Cloud Console en de credentials toevoegen in Supabase. Als je dat wilt doen, help ik je er graag mee buiten de les." + +**V: Wat als ik vergeet RLS in te schakelen?** +> **Tim zegt:** +> "Dan is je data niet beveiligd. Iedereen met je Supabase URL en anon key kan al je data lezen, aanmaken, verwijderen... alles. In je eindopdracht is RLS verplicht." + +--- + +> **Tim zegt:** +> "Goed werk vandaag allemaal! Jullie hebben in drie uur een compleet authenticatiesysteem gebouwd. Dat is niet niks. Volgende les gaan we verder met de eindopdracht-planning. Zorg dat je huiswerk af is en neem je eindopdracht-idee mee. Fijne dag!" + +--- + +## Referentie: Alle Code in Overzicht + +Onderstaande code is een compleet overzicht van alle bestanden die in deze les worden aangemaakt. Gebruik dit als naslagwerk of als studenten achterop raken. + +--- + +### Bestand 1: `src/lib/supabase/client.ts` + +```typescript +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} +``` + +--- + +### Bestand 2: `src/lib/supabase/server.ts` + +```typescript +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) + ) + }, + }, + } + ) +} +``` + +--- + +### Bestand 3: `src/middleware.ts` + +```typescript +import { createServerClient } from '@supabase/ssr' +import { NextResponse, type NextRequest } from 'next/server' + +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ) + supabaseResponse = NextResponse.next({ + request, + }) + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ) + }, + }, + } + ) + + const { + data: { user }, + } = await supabase.auth.getUser() + + if ( + !user && + !request.nextUrl.pathname.startsWith('/login') && + !request.nextUrl.pathname.startsWith('/auth') + ) { + const url = request.nextUrl.clone() + url.pathname = '/login' + return NextResponse.redirect(url) + } + + return supabaseResponse +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|login|auth).*)', + ], +} +``` + +--- + +### Bestand 4: `src/app/auth/callback/route.ts` + +```typescript +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get('code') + + if (code) { + const supabase = await createClient() + await supabase.auth.exchangeCodeForSession(code) + } + + return NextResponse.redirect(origin) +} +``` + +--- + +### Bestand 5: `src/app/login/page.tsx` + +```typescript +'use client' + +import { createClient } from '@/lib/supabase/client' +import { useRouter } from 'next/navigation' +import { useState } from 'react' + +export default function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState('') + const [isSignUp, setIsSignUp] = useState(false) + const router = useRouter() + const supabase = createClient() + + const handleEmailLogin = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setMessage('') + + if (isSignUp) { + const { error } = await supabase.auth.signUp({ + email, + password, + }) + if (error) { + setMessage(error.message) + } else { + setMessage('Check je email voor een bevestigingslink!') + } + } else { + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + if (error) { + setMessage(error.message) + } else { + router.push('/') + router.refresh() + } + } + + setLoading(false) + } + + const handleMagicLink = async () => { + if (!email) { + setMessage('Vul eerst je email in') + return + } + setLoading(true) + setMessage('') + + const { error } = await supabase.auth.signInWithOtp({ + email, + }) + + if (error) { + setMessage(error.message) + } else { + setMessage('Check je email voor een magic link!') + } + + setLoading(false) + } + + return ( +
+
+
+

+ {isSignUp ? 'Account aanmaken' : 'Inloggen'} +

+

+ Poll App +

+
+ +
+
+
+ + setEmail(e.target.value)} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + placeholder="jouw@email.nl" + /> +
+ +
+ + setPassword(e.target.value)} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + placeholder="Minimaal 6 tekens" + /> +
+
+ + {message && ( +
+ {message} +
+ )} + +
+ + + +
+
+ +
+ +
+
+
+ ) +} +``` + +--- + +### Bestand 6: `src/components/Navbar.tsx` + +```typescript +'use client' + +import { createClient } from '@/lib/supabase/client' +import { useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import type { User } from '@supabase/supabase-js' + +export default function Navbar() { + const [user, setUser] = useState(null) + const router = useRouter() + const supabase = createClient() + + useEffect(() => { + const getUser = async () => { + const { data: { user } } = await supabase.auth.getUser() + setUser(user) + } + getUser() + }, []) + + const handleSignOut = async () => { + await supabase.auth.signOut() + router.push('/login') + router.refresh() + } + + return ( + + ) +} +``` + +--- + +### SQL: RLS Policies + +```sql +-- RLS inschakelen +ALTER TABLE polls ENABLE ROW LEVEL SECURITY; +ALTER TABLE options ENABLE ROW LEVEL SECURITY; + +-- Polls policies +CREATE POLICY "Polls are viewable by everyone" +ON polls FOR SELECT +USING (true); + +CREATE POLICY "Authenticated users can create polls" +ON polls FOR INSERT +TO authenticated +WITH CHECK (true); + +-- Options policies +CREATE POLICY "Options are viewable by everyone" +ON options FOR SELECT +USING (true); + +CREATE POLICY "Authenticated users can create options" +ON options FOR INSERT +TO authenticated +WITH CHECK (true); + +CREATE POLICY "Authenticated users can vote" +ON options FOR UPDATE +TO authenticated +USING (true); +``` + +--- + +## Notities voor de Docent + +### Voorbereiding voor de les + +- Test of het Supabase project bereikbaar is +- Zorg dat je eigen Poll App werkend is met auth (als demo) +- Zet een fallback account klaar in het dashboard voor als live registratie niet lukt +- Check of je de Email provider aan hebt staan in het dashboard +- Schakel email bevestiging alvast uit voor de demo + +### Veel voorkomende problemen + +| Probleem | Oorzaak | Oplossing | +|----------|---------|-----------| +| Redirect loop op /login | Middleware matched ook de login route | Check de `matcher` config in middleware.ts | +| "Invalid login credentials" | Wachtwoord te kort of verkeerd | Minimaal 6 tekens, probeer opnieuw | +| "User already registered" | Email al in gebruik | Gebruik een ander emailadres of log in | +| Polls verdwenen na RLS | RLS aan, maar geen policies | Voeg de SELECT policies toe | +| 404 op /login | Bestand op verkeerde plek | Moet in `src/app/login/page.tsx` | +| Middleware werkt niet | Bestand op verkeerde plek | Moet in `src/middleware.ts` (niet in `src/app/`) | +| Cookie errors in terminal | `cookies()` niet ge-awaited | Zorg dat `createClient()` in server.ts `async` is | +| Magic link komt niet aan | Supabase rate limit of email config | Gebruik email+wachtwoord als fallback | + +### Tips voor het rondlopen + +- Begin bij studenten die er stil bij zitten — zij zitten vaak vast +- Check altijd eerst of de bestanden op de juiste plek staan +- Veel errors komen door typfouten in import paden +- Als een student helemaal vast zit: laat ze de code uit de referentie-sectie kopiëren +- Moedig het gebruik van AI-tools aan (ChatGPT, Copilot) om errors op te lossen + +### Verbinding met de eindopdracht + +Benoem regelmatig de verbinding met de eindopdracht: +- "Dit heb je ook nodig in je eindopdracht" +- "In je eigen app kun je dit uitbreiden met..." +- "RLS is verplicht voor je eindopdracht" +- "Denk alvast na over welke tabellen JIJ nodig hebt en welke policies daarbij horen" diff --git a/Les10-Supabase-Auth/Les10-Hands-on.pdf b/Les10-Supabase-Auth/Les10-Hands-on.pdf new file mode 100644 index 0000000..e77ea10 --- /dev/null +++ b/Les10-Supabase-Auth/Les10-Hands-on.pdf @@ -0,0 +1,296 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 6 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font +>> +endobj +7 0 obj +<< +/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +9 0 obj +<< +/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +10 0 obj +<< +/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +11 0 obj +<< +/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +12 0 obj +<< +/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +13 0 obj +<< +/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +14 0 obj +<< +/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +15 0 obj +<< +/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +16 0 obj +<< +/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +17 0 obj +<< +/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +18 0 obj +<< +/PageMode /UseNone /Pages 20 0 R /Type /Catalog +>> +endobj +19 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260422152406+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260422152406+02'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +20 0 obj +<< +/Count 12 /Kids [ 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R + 16 0 R 17 0 R ] /Type /Pages +>> +endobj +21 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 941 +>> +stream +GauI5;/_pp&BE],.K3,.dqcBC].Do?S'5Z#A.od,uR-7.@0KF"!-8:4=]M+IK81)GM87X$^*Bl0i#L:(/'Pn_SU/nWHu0DpTSuZ5J\/90qXDaNg]@mH'aSY%=iXpkr\V3#IX/Q>4mF?c)0,nX/g_HgJWb$1gcT>oN,_I?]fhl9j&0G]!e3m7gB/9Un_$5!5?Fbjk1aDiN^V^ZE1Rr4=s]!hR4`([X?=02a9Xren&1Y"Qi7&H#[t'rll*N_Y9Y(M;M1lMK2k$W&TTMe=R6j4JV,[,:4(kjNpam_7ggX6IXn)eeR/!(p1@B-';!WJB1&2VYa@&u~>endstream +endobj +22 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1770 +>> +stream +Gb!SlD/\/u%0#[%_0m0c(@t[Q]$j<&%Wg(*9p_\oisZk<+HY\XX@/@28TqNXrU8e2c*Ao]C)Rf=*$i[t8;]q.Gt=?TL-INllRi/I-STpBJMNTt"6G3Rj:mV%]t\k]f5++?5]gF:L(;/M"g^5-2n]3V/SYqgR/t/?KUK[W`e\&le:^r@onslr$[kqeQGr@9W%dSG4t!e4)*k/rFA#`O#ntfh'"L3HKk:AE*%ff.KSCFFknd*!b%I1\L!-u!Mpn(4Ymc8*?l%/#_,4_DrNl75T>94N7OIOc%"5WVA*M9;\7Eg:dE,d\,[QYV)8)+_l%dYQ#3g2rgqr-kQ@3k7E'nHp5EuOO%Gj.^//a+,DKkI&G_#suB'9=d\+8#s4>d9iG\*6W'ms!BJc]?bIS:l=MBOV_:TGa]p#+eEL;?s-?s,&WPm<(nGR*j@7a#l`n"WZ69kCQf8[C)*W-i:k=?HCFSl$$)Tp_2QXN54a8c>>q;QU_-h'gY:lm`cr0t5afoZpG`_RO-=BFcum!mPW#9E`OXL;pN?#Uem60mW)1Oij1>1A(?!p>Lr%CZOf8J#';Et.BRq>E?/OPUmQ:?[m8Xn-FoD*4L\C2[UG(2Ra-@1u"YOQeg;^S0QE*UDReb=!4g+7oP!R?j`=hKeV;ktF`F5EA'in)r3E,0d_32%i-c=AN%Y@7r(q.g[A?[8,kW8\QL8GTA+II`?A/IW]`aPg+&AYD!R@s_[<286,.3Yem!N+/&s-R&N0+LcRNipY\`hUH!q=3"Sa4$CH1l[ZNiUAa1h@GLJO^o=i;^[^G%4gKs=rm:"Db(=b9WCq2$SZs[0j^t[4SPV,-a(Q.^QGf6nm)?!OEl;q^jMd.Y3L/Y;u;n[BUfL'/)DF!)pu5iLPXe(X,dqasRMiNYBb0r\KliS/3&u=&(NCL3O6G@"pi<*2aH'\4PUk(l_<^"/(PDi`KktC>^e6i&DRZS7F=e[)al>A?cUVXuY-=LnRk/N3_PhV^%L/mk,^4qDeCNd`jI;iqf\MB5j>f"/fZ7YL,YEm_gV5a_#.a"Bqi:$_YXEZN(ZHPWhD+nIF)n(l.hl@b^Q=eb68%/tBO+9qQ4`IW*!0kpu$J48F'k:L\P3Hdeu'L:OoA9t#h.%W.eQH\["g6m)!REY8pT0<[f&l)ORJuk7E)R9Sh^[t_bXX;;U-=JeC6+C=,S`h^_nVs24U^Y-cT6r2A8Q*^QgI>D)R\o22a.ck6kbbl@Y9+\+(dslP&`j7hO%EQ8`fB=ifl$U&Hg08;4%ehQ[0ijPK9l>?Vk?`Kghs:uhHUA&!Ci]iF,2I+]Z>?jR)Zs#f_'SMN[45mEK,V)FtF6Oq\OM!$6K~>endstream +endobj +23 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1290 +>> +stream +Gat=*D/\E'&H9tYR!u!Z3@0T-]jBGa=-]TB6H]u/+B9p872o@E$!q[Z6`uF>h`Jh9fKcHPD8BuMI:o.>2$Fq+])A6h!1+pD?;mL*7Pg^-E#A8@1MV;S&B>9.RfdY8iaEP5[5671t++0p9U+g,)VJE8rM/D2XuKo*10MA/i\_V@.eGfn%qd]=(\aoj!EKWM4,tn(c1;?]6hp`-8_]j_CoZ!QLQG&3^&,C,E1J$fZ2>E([ckQSh`QJ\<2)P/n1C+*QWN),rcW,T/!si8-NuRQTO+\LJZ,2<[CC&V4QTd*3Ma$R-P>#3+mot8A3+=H/@nijaEFKU9(_<=4kqY1AoANO+TjFiZs[9Sp_^pft^Kaa4lom[cU'D%RSBZcn->WUP=3Ml%99]2aji/bPenrpOuS&;5F_=<5"MH;`[(#q"BAF]paO.OQL\4-9:H;8V^aD_nZ@]F[T/IP00Om>>#3QXo:NY?SW9iB)8,<`!#jH9/R/_5(j#EPjKf7fR`RC$:&krtXqQ6C87S]0).8`J'4hSLH:.P"5=YC"]?=OTl@*t:BOA3'Sbp#P]cYQ"RnC;BgV^g*`'jmjf3j\m4Nt&r/51mLQl^*YAm#pO(-V+&?c3[,\DV92uA_5"H9sc*ddb*aM>FR_MKQO3&RlaUNh]okUD$Jd:X+Mj?Z3Nq9"(]i#+sB/iMD"K'>i=aocD%N8!CK:>?`90&1p7dWHT*`H[c"G*>+aJ/55Uh1fk403dcNL"eg[`S`)'N2"V\;,nSPs-=g.b@ipCXjY_K,^J]J3gnAaNAh04h!4rnZ(p_6K$9D'hJ>#OWV4t1n(Z\O[oY142JniY>5S,W\o:JsBm!b(Sk9T#0!'Hgn>Y%endstream +endobj +24 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1652 +>> +stream +GauHKD/\/e&H;*)EVZim#om-EW4)c_k-jNC/PX^6faHpc?tJ%mUDI!pP86mlqi5uhC^4YYbXP:iFSG:`4\,'06,PA#Ibb"^`<8Z6.8U543D`I19MXPkdehfE]&qW`#GN07D82%qCgM/F7jE5E%K.$$K^j+CqGi>`FL+GPUTWo=E7[c6WbZ_37X>--#tLi&TF"1*j09o^h,%*m`.IKFG^KhU95'_u^9`mS.i0P/I[n]B`L8\?=61O(3aU`Z]?)5d9bq#ln6%R*c8gb_(eV_S:Q_&F!3P/A]lER2V@$O-#(d!NuS`RH/fDK$V`AeRcs6WI+V.CDie6$FlCB3:*0^c<,9a&)j8T9Jq?Hk_DW,tZlF'3'aYaY2l`RB3,:O[D[SY%1u;]Tu+u';B?.A.qob)3FcZ!K#lQke_]f\uCIb"Q!tWb?GS%koe9FNgdK,IJ?i_!UsY---q__4E#3aMSXD]MukE#LCN;BFMGd*^r>d]*aq"62''&3=)mdqjd\TPC8Q$.uaWFiHH1-gl#g`K6B).;RMJ^+n_i98o1U<$h>\b/+oqP?$Cp.tk4q&oDFq1]l_mV$PQ89'?99^M1>+#&T//HXh)QG"8rl$=W0*Hdfh?%CK=,Al"]9Mgtr7rRM+0!unC%!Od!)er*mTohM"_&YDk2OBJ5_;4Efcola6Ds_A@(P66()^pu.P\hKZMh>+'teO)/6Cl?p]f,.=WD`/IC2HU`<=:TQEAMFlN#'7(&a'b2>LWk$YF7Hfs1$Ent5]meqHHi[B1DBo4g)IUX`XCg%(ZMS#)jI@)e9.@5b_:t`JriYlE2,04#DYf&$fLo2IUk>7BU.o6F]7B#6T<0A>7jKUPA_CN9[*jH2imF!?Mp)T-c+9&J9Qu8OaX_'eEr82jL[]@jfY45hK_\I"4idWp_ZZS/9_!LkoLM=YIqcR)_m+bmT=UXl9#JGTfSfm$0~>endstream +endobj +25 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1596 +>> +stream +Gb!#ZD/\/e&H;*)E@A'1/K.fgW4)aGQG00>OEG9sp-[dlN(hu`-./RQ,YaF$^7ussZL#*S0OV4!1Eu9^GL&E7$4_N!q&t#n+]/I9AIl%JJ71na@]G.*$:[^#j5kE!oc5M6*5SVFD,0ELgPpr]cgYGSU]B1f#ia_2R<%#c#2i:us6\1!NWa>(>i2_nBk9]kIt%'O+7U1594,M`#Sp)#4MG+2$Nijg+S_5@=ML`'74-D/`%k5f\V3dDG*_)Waa_7`55<9;=QXfaI5dFbb1VPl#SGS"%S5FlRM;*#?phtpYldDI_<2^lk_YuX>."!?etp8&!A]W)'VLhGQ9PAHr"JaX.c.?&YF-C@8IJ;I;E*;h]cPH.!S>_jn-a?_!BNRS36jK+V2a?+GdMhT:]fdQ$nfi9KOjB\`3MJmbKP@Yf)"S;Z]m;US,o'90hjp3T>In%cCHfGSY7L.LO+bbqh2?R+P&sQ.qC?YC.aQ5"$\i=I6OekfCcaPIDC+N5nD2(`\)>+O,ah_/Uek.8ClJ&(/4=bcZ%&$C8.h]1Nbu&s8!iSOg3^bODN6&VU-M"-`ND<65^6FFkQ*6^de/p`FBk`kohl$P&Eh$ODT][d^/HZMfaSm4[Y'Yitpd++eer+`87*9A!j(s]cqj>63H/u=CS;hB`-.ij`@H_4sh<;;)(OSiEDJKW5!=8kr0L2LM[q"LQ&!2T;GXtnaM!('24;R^2L_'Ni$bpjgbI=U<@>e9J_$NJlo;i6rOoXh@4JJQ17:cEC/JlU7-A8>2_SUB1'=lD\uG]\6#e,B.O?$9fKd>![gAd[emQ;kAa=HtdfpLeqctU,E\3,b1QMURo61:F/`:q,+DfPaQUUIH7B5e&a0/5$?Q-2"Yc8>PbCP"^h&a;D,qD!#Ru&DEKiU.S(pb_\IP6hWo,[)_L?l@1KhK<=?=(U_K&o0i]b?J8Tj]8W>cn@sN@8C?9'A#-$8p[BD6&/F`.RuP`53.+K?!b0LVe?8MT$6>bG97A#^fJdl<"m*T3GD)bC);T>QY9Vr!U6UND^\7laqSg_m@rET-QH"Bh^aC:T)+4\41nAVUH5.F;NRke_jc,Rt\$C\>'(<&O26N1Eqkbh*I#7OEEf'sf7]sl-0F^d:6"oH,J9`-t7TMCtJ'Bf0`pRIXcKJT3@1C\iMJ;c<82Gn49%=p6&Y(:3d/8i$:mjZ::\b.g;YLD>\$"+#bkn>o;`Rl*J/*NWRrj,;dg^nYQ]Rsi&9=&M75`75MDIZg'A1t1UA_@r%5RoMM"qfr'fmCl`F=q_p]_S-,c8*;P1J#Z,5#,RLYcB@Is00~>endstream +endobj +26 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1174 +>> +stream +Gb!;bgN)%,&:N/3C`YX#1GqSicH`rK1pI^HAI)r(2cs.YEKH=c4TU4P]<&@eHH]pI@n)YLW"1\(JQZPr;,CD-+kbZ>fN8-8E#aF4PhJgfQI7u/l,hGR7P;`n:oil[+JtQ=!o]:KDE]NInYj3^=Vm>itA9omo[;gVaXqf]jg:QBAg6=I7/7J9RYn\T'JX,#ptVt`L/'Fd_L&/k[&&q!\4?([>AbZhPjg]S6.]6i-1ct,lJc0=6KGJ%le@a/LKNJihR+<1+=:PYr&D[H>s?I/_Di#f*417mLN_VQ:a>obd`m>KL*>UmZ.[AaI?_2J(bgrA\C&UUo76RgFK-=m$[5T8&KLWZu$??2`P(l2cL`1&BEj=GmoT;rrK:1YiGjIqYXZH3J"LY%n$NXA1P/RHrEVmaU25,/ga2T]RO0"GXu"JOK:.&*H@XAdBBR18)Q]J\r3hD#F=_E+KSN]+tNGaMoGfl_tCI-VWs[6Fn2^L1slQ64"S`\Oh,gaNh1crp>9gDI_F_1(V&L*ic&74eXYfAM^h8_TS@6M-M`uEj*lN2oeYQekOUJ//Q&"n_#7'>XlbLW9=[?/VgZu3'DuP\>[)W7f3Sio8"`*(?2O>'^;n"#G_TtAk&P,B%8SVch#g%coZu\U`I8C9U?S?sn!>W9.RcO+fRG[.s0U*9W.FIZBX"DtIRB]BULTeWR2.hu3&@Z]%$'GaDO9M5k84'ONJQu^$_M8sfB]mjI$Akh*d"^dUQ/ZXE+=p0p47@jD7'dV-3K8lRQ$3)#%CI:"T~>endstream +endobj +27 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1300 +>> +stream +Gb!;bgN)%,&:N/3m!,D8SJa`o#i7=V>.7'AGML]IQ1dX"O9e^*^l(e2ZH(FDn+%Z+2nJNPB*s\'!SC\J4aT^O",_T0q4&f45>>P`JH=db=TB+'^uQ-b:I_f<@ai,j!f66R^2!i@D2/>tMJ/6KZd/]i)0`n'*5XkZ"jVtjH-Cn-_;a"$"J*:t*T-$>F6:!KPJ85k93krL-FWC<+:*0n:g:=+!Z0>k+p:@@J2e6%=RG!sp1qkQEBD7ObI[%&8po6%!k;$Bi*e7tRAdHe,YkJ<'$K='jIic\>`(60-7]GQpk%Ta9Tn^7rNU1pMQ/gf#gTO=BUPSCFF6a=4W#c6Z2_s(I9'g[6uiJA5sPJd(2`FV$s\qV5(`ig'dY!EIZ(hE>Wh:f'o0t2TB$5WNg''%M&TVd24d0#'<)[G!*e+FW@5bEqfZqcf]@;D*Z6#O^D+7%'SXb,"3MB\G5q4bjgi+&%UsAKt=DG64M$Pi(iQ0SNSmn6hCJHltYEfVI96(lrniY?GB%##YquFMb6WU<=+-Et4S9Tb;T!Wu6oPY?ei)>endstream +endobj +28 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2056 +>> +stream +Gau`T>>s99'RnB331H=hZUUVql5pG1gb;B_R2s`E!rl?a@*KV+gpd*ncS6"<17]b_D%4-]"dp,PTJ.P((*7Fqc;3SL^U0>l^TKAf$L-)oYWV6;+K.u9BZo7c:lo%Bdbqr++J74lb+cN-q'`!F7(:.HH=d-n)WL:O=eYnLHa47]Z9eU]sZ\4.!Bk_)0e^6A8W7-4oOGR,/`E]/*+*bd1BPM#/V9(ZfeY].Q6;/bO/?1mZ_^]F5?+i4O&N$-7=QDIQ"?_#8(S_]S/HLAh5J.%Nid(Npa5Ub\`E,(F@jmrj>!Z\ilcN!PPR2hUl2'moFkZT-$#Y(EZ0Ys"qs*S"bVkE-aZVAm)&pJrI8GAr8;VMU2bPH&9]JqZ_%1TNNJ)e')l;=#PMgcup=;gej&ls_0T3kN\1DYIC1@@c.LNm.)bFKZNMphn!*\nd55^DpT+H&o5,YE[h#MkUd:+Cl:pXjuMU=EWWC%>IBHAeQQ3I5ior^)\>:;^ViBA!7["p>ga4,[_G)=s2a-sNEObH(-:q9()HO'2KV(.AERUS'$kc#.Bjm<8*=M2H!&L@1!Y("]!9=-oYl"rF%mI5j9B>f`-*2I;>k=^B6PI+cl@0>9U-?PU7i8Q5(fo6,h\B.%egG@CoG!dFi7,euPRDhrcANSjB07+ZaW"qYT%C-,dh`r/s(ZrtGm40L:1RmD+)P>D4oT?DdT/c9"ngj4Eq42B'^h9)>Z3e(0`+uh*Np%FWanQOf]D$b?&p9FPLaRrP5@^(!XmhW_8@U0CYk$40i8QkXYhU&iQl`\iHF=-eroC>1=R"cmn1Wc(,9@[X>+Iih@Id458AR0Q3>`]EC]9-Spcn8g-eUkhY4ms't<>!Um.qKGVqN=;Kpu7cn*-G@-P#V5cIOgon%tgRYI6Z'-0tMYM\Vb(O&7$LG&)OXQV<<3ggf`a]S/]85?b83"q'0'2Y315Sni8+SR(#FJ?[l=utQf9g8_-&T%u5*o^N5>]]7$Y#FBsOO8t`*%#+<0j%Y)jVK,_fCA;NeC8q.e7K6)_`!_'Xn\1tO;*L6_!WZ@+\m`K-f;ea*KssmkSeXm^jgfon4J,DJNbffTbY01+_ml=qr#Q?Ho0aRG_F>gbT#;#!/g&[AdH>E5?,[@^5g6&`8%$#eR>$3L.WOZ^CNthe[jLS(^jI=5(rcqWE`Mhh_=UOA^ai+MBQ7%6EN&p\)&O5-6=B*cb=F0FYgerKC[&:&rZ>fPPIXeIOuIF;olrX%3>c684P$Y7*EbnEG%6'?ej:@Y%V"!YKl8g^e7_Y9W1"h=WH_K8RR)q/[k4i;3\Z%'^u.E_9KEg~>endstream +endobj +29 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1546 +>> +stream +GasaoD,]1K&H88.Z.#*kTn?q"Ol%!`9!@@1RpJ_q]e<)E.#sH-r0"Bs#fH2iDe2"jPLshdL_=Frfk6p_aeE2%+"]^p"Q8)nQkpI8%G*D/N%hd@$m2VWrsZui$@Ia4)*+kgNe$mL`srsIo0J8Z4q3Tlee>m@l>,Y>\$tG5?'8PKn?RHHZ8u+30uC`8ke3W\.`kj9LPljiVEb]hsB8G28WU2#QM,l5V;cPe#>10[)0N;o-i]3[",la\d_TIB'LNRN]'oAQWp^G(g.d60(UnQC_U&#:(WsV'Lrr]%u@8tf(FT&6fD=@h88@mXp!l6i&/0rabr9LMD_*dpT.1XDXm%$BJg41%Ho-m(TNOF=Q\.($Wmk,1uL?=[e_CEB"Wp7b_e7,Pj2Qs&''IpfRs=mrDY9AM4F,]O@&2N^YlIBGuDZ^(XaLfcc/=8N">[\o1G%lNAN]CT%TPh)R^%#[]Sp9\St8A5!@Y0]`t!.KFs9gLGX[%gU6]!9Y'0'X5]4P+W'1tJJi+q-9LG"_7c3DD73/bLMe_DOlLe.\-6l(rK!G.9DO8hiq1c3DVQlhCid/Fe*PqQ#u%XP.WT%il>/uAPA.DcS+)\_Dg8<"P*LU?A[#DW$tOC;UV#Pk54Ml2q0U3[fX"\]i22ScRQVK$U$dV*@:@MX=9-PfO[b.D.e4)ADL=f(ngM1/XA0;u=jXU8_W/PLA7o&FN(Fd\];a^e>0_3g?j%6s?Y49dkKDtD\Y,MK9I/5tn6#+/f<9CVpoJ.G.:CGf#4"hkrT03?HRWD3C_C]h5'7cq6mOmPTDE#a3_1oV8YHiiHWc5]9R(p\\@'$H0VOhS/uG(XY91P(]+b![A*dE#endstream +endobj +30 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2265 +>> +stream +Gb!#\D/\/e&H88.E@W`e/J)'\W4/iVQ=b%#c#?S8m4bX=5hh7"7M]9d:6lCemlOFQU;W4iAneLaNPbFFG7PePOarGkM"s2O>W:,$tWTEq>nKAWdrp7E-Mt+OK"p)[r-*A'@N+Z2TfcC^a=AIcY^b"c6;"r&O1G%/Z=H)2I+]tn,'h"*95R/3KBR'blX=odN4-b]V;S`T1I5pX:*bSW?.5?UM`MJCPH6j3s'IFo1o'F["N4>0Jr&aK\6jCSa2a!L@LGI!74l_GKiE0HTkq/@5K^':KI?.P+VI6\!05LKoa[#9+JpIE3uqSHAIeLRZUhVQr)4hn]AJ4X'Lr2A.B/;lLSgH&f/E6W:c1bD*ESc6mD@KekdA,h9?"G8='QmLcZmSCd%A8HNF-UPo1_>A\hV"c`WW.Y_q6g"eJA$_[9`0&ku0OkgI>N_>.U`BK,4a$I+^p%)Wk(JAiST1,nM@V(GM-GlP;n[6Il8l-^s4G!R\88Q8HLr2^b$2l8eQCS)_K=*$'Rm"l6c!.T\`)?Mlrj7e&SUaZn+`VP*6&U=&6Z,\WME6A(UQ:n?'K=2?hDRK]77+X_6Hc4>LDgdhe@Bc+fZ@]U387X^e.qj<\=];7UJbNY!?Jhi1>m.'KK0$dXC,Hc8p)R_7I./]"3qX/`5E45]p4a*`;oX,X$rF4f5DT14gEIBo#Y3uZ8==!XaOj!YmPJS!$W)Or];GDp>).V7U&W.+K.Op:TVe8<3kDJi)OiI-si"9.'^)+NUJ("lK[?g#h)M:@WjT-Ich.o;kt6LYZ+n.sb&cf2bLOc&6%NW,`l^!PpoiECH1.;D-S(4KO.T;?S.).Gi_Q!,8P&_`2a9:;b(a0oh]#'Ws_i_$(h"UgHMH%^bEQYn5H]&k*U"^4-I`9'Z*_gp=4R),%Y0,4C$/P*a+G$`aN?na+jKq`c!S&Qq'cG4$7rm6ZE:%2BluO`]?D\1'bT:(MGHZGEI-em0^4R)Er9t)K=-Zu%!k9>(nLX1KAQL^!62^9GGG/upE+/UagI":dUq,6iaH?N8gnJtX^^9^chaTMm*HS"3Adg3ZHKbFia3GKKV=0iY`4R!c^$>n&f`_(8AR%N*Y9>&0K,$YReV-K9dmZ2e>:+<.tL?r,DXSHoJughI8&RD?p$&>Xs1M6(1N/aI">A=r&a[B!+2qg1QMRE3k5u)?u??h5MKB8;G%VSk%EBfqtjrf@S"-*=c#u/?7#t)'p[:FD_@3Hh^FV80h7BO-Wb[m==H_B@n>+3(hRIT*b(uo0Q*A0GZkaUACN]4o\f(Xcp.=HSt)4"*&i$YM(:cL0j20?r(jLNO*jh83uo)P]K.(,1(JUXPHsT3\3GA#bGoY/5!1FYd^11Ej#go/@M,id7%1qal=\Ci;@GckW:WXpb2\Ib@T"/i\:,4=a3X6ocVf>skA>=58S$"TGbg-2:)rhl7L_U&m5Nk?j57gf?caKe?=0W^ij;:;9rE[dm7r(1K*&l3&D$NlUds7GgOp$W8Jp_T(X3endstream +endobj +31 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1602 +>> +stream +Gau0CD/\Dn&H9tYQsJr%\e8oI3,^t9LE9)mG.WG[oM2=MI5&Udcm34e:t5L@)^cfACd[He"VitV4aH1XRE#sJ#(C:c4,s=?*5_4`"j144!Qk_(i?`T#N?8U=)P1_`+Jr_^[L'l0/`q%DVMuEO]@8'a1)hdj,0[<.@#W`u6QJ]C9Kk=i=718@bB4\_N*<[!hTq$k)`CN-'%=6ni.P7_djp=S*%q^:#j5m;"(E--61t3i[&Yp.`%E4n/1nPV%-#u!jhP,1Egu*P#b"i.^UeBXVD91L.2B]oQH8T%#.KOOE8#0"VJ2-(dYH0>6&'+qaZ)A6i=(I5G3j9b&VE,+p+Ktae:7o=qe8^BL#'J1#-gEH2_9AIH7Pq;$.V>g3&1n8blZ1k"[=om(r5h9BhlV:)(VIb+$aKL1,6fY-TNT,34VIt[.3/VZ$!j>L!Wn00FlI9#Gi(&-)c))W&TK)N+BH[eFTV5]?W"O[($F.`AZKqPWC]m$p.se2:Ua'3?ub`\X'rZP/YMEY#>G?T_"ROft;V^hJi.OId8A%U68&X)">716j)\IVN]K92&8[5>Z9D(C`f;>+p"Z//H(rJWs)enb='Ji+u-3U$K[J1=%"q#f:CB!#Pu-WDYkONfIIaEo]%*V9/-c"N#i!U0R6X&r)dh&>u_ZdHIjk7>^"i=lL)oiHZ]h-bq*#=gSS4K/QTrLQ8]c)!qf1:Z[&=Wm))h3'e(XTn[[(j?+%kQE?M\W]Qk!%M34aO4\%k=WXqPO91D7o4#9an%Rs+5a,7TF"]j7R["'5:UGH*g=gI2TmE^L_6<0Xdj#$m5Ge(k5Z)48do\gW$4nYfLgd_7>Q1Wrhrl-MQ2K"&SR_ATWJ!K8Pcnbr,0Zn4`;)N5-4nD2Z\fA^NXibjbh?k(LJ2LjpYt9Q@mld`'GfI\'bCb8PdsO!JPPs[_K9Mqn;VJQMhIp!r!]IT8b`=0W*/)rEDA\=02^[)+a+4F0oU3)/!DC1;O"+K-I_Y@\&V(0^8hdBW$TME@bSVd"Z'M?_<`3Y61uS1P>ZLo:iGh.)tB&6o)jG7Zfh-V^?3qrFK_XiZ/@lcg]@@RFMSmJeOIP\XMF#o6'l]GL8'"+`oBVLko-NBW`-&T+J9b?>]uP7+&&e)/"V<;^Y9T+lS_fACo4WmAF='U!fd3\#rVDo~>endstream +endobj +32 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1671 +>> +stream +Gau0D>?BiC&:Vs/R&;!K]-rgng%$Ash5,n4]+Fg:qOfel0l_$]G(B;en]"g=G4calM)0kp9+FZK1UJ`P$:X5d%GN/ks?0VK$%WR_4+:+=X[lpb]!Xhs.&HXQZ5U=;UB#iMrQ,W)'[t]jLXS0r77(Fo`^_LjB/$R1uNip.qE=$!9WQBcqP!j7jA1!ZYE6l'"-^Vk$Gol+%RD?@Y1/X2e9O)sqd3FT6^9K>Y,ahT(YVdZZhi1?be88emdXeC&=&:AM'(2WD57R:/J]_$:?R3U,1EIubqAGC9:`rk=)RdKsedAb4qM^!F%ftX6RnJ=0SR:%)0K5(d2c99rS@%V@%lK^j/:pJUG,H_%$$]e!d55Y'4(#P2@1ALAt'fE2l'UN-V_$KF,TiS+Z*JHj7$%NMoi%\a(0bV7RGlh\eJh]99p_3>F1I^O'r7u1'c:5Ybl1K.7(R4^0?dS`YR$N<27*jkf]GcdUX3nqpTI3_1M0:\=L02)C>Rhs!;+i1jB97\7r!l]*QpGa9&-'LcSsb"=I&W0^;jS%D%hU4.mDTT\tW_T1G2Seh";YD1/A#h9?[:W%iasc-8I7!-Jdpi_>^#9STL;9st^Zm',8N`gCV?n%_:$9@7$I]FU+!%SP/_/P.:ddO9AI%.W-qAS)Qe-7C"f3eQ]#h&tH5aPMKcm-5@IP4JT>cV66I,_\(3U>50k,V?kY?8$1WPk)&s8rG,,6=dGr-HIZdJ_Q*`MmLeY^t6cqgP["uAM8#srVU2JYnOt5^tCD9X7-7OUfsdZf]C\8LKq7pJ&)i\dp%T`Jh-73/endstream +endobj +xref +0 33 +0000000000 65535 f +0000000061 00000 n +0000000122 00000 n +0000000229 00000 n +0000000341 00000 n +0000000424 00000 n +0000000629 00000 n +0000000734 00000 n +0000000939 00000 n +0000001144 00000 n +0000001349 00000 n +0000001555 00000 n +0000001761 00000 n +0000001967 00000 n +0000002173 00000 n +0000002379 00000 n +0000002585 00000 n +0000002791 00000 n +0000002997 00000 n +0000003067 00000 n +0000003348 00000 n +0000003486 00000 n +0000004518 00000 n +0000006380 00000 n +0000007762 00000 n +0000009506 00000 n +0000011194 00000 n +0000012460 00000 n +0000013852 00000 n +0000016000 00000 n +0000017638 00000 n +0000019995 00000 n +0000021689 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 19 0 R +/Root 18 0 R +/Size 33 +>> +startxref +23452 +%%EOF diff --git a/Les10-Supabase-Auth/Les10-Huiswerk.md b/Les10-Supabase-Auth/Les10-Huiswerk.md new file mode 100644 index 0000000..462fa52 --- /dev/null +++ b/Les10-Supabase-Auth/Les10-Huiswerk.md @@ -0,0 +1,475 @@ +# Les 10 Huiswerk: Supabase Auth Uitbreiden & Eindopdracht Brainstorm + +**Vak:** AI-Assisted Development +**Opleiding:** NOVI Hogeschool Utrecht +**Deadline:** Voor de volgende les + +--- + +## Overzicht + +In les 10 hebben we Supabase Auth toegevoegd aan onze Poll App: email/password login, magic links en basis Row Level Security (RLS). In dit huiswerk ga je de authenticatie uitbreiden met Google OAuth, polls koppelen aan gebruikers, en nadenken over je eindexamenopdracht. + +**Wat je al hebt na de les:** +- Supabase project met Auth ingeschakeld +- Email/password registratie en login +- Magic link login +- Basis RLS policies op de `polls` tabel +- Login- en registratiepagina in je app + +--- + +## Opdrachten + +### Opdracht 1: Google OAuth toevoegen + +Voeg Google OAuth toe als derde inlogmethode naast email/password en magic link. + +**Stappen:** + +1. Maak een Google Cloud project aan (zie Appendix A) +2. Configureer OAuth credentials in Google Cloud Console +3. Voeg de credentials toe in je Supabase dashboard +4. Bouw een "Inloggen met Google" knop in je app + +**Checklist:** + +- [ ] Google Cloud project aangemaakt +- [ ] OAuth 2.0 Client ID en Client Secret gegenereerd +- [ ] Redirect URL correct ingesteld in Google Cloud Console +- [ ] Google provider ingeschakeld in Supabase Auth settings +- [ ] Google login knop toegevoegd aan je login pagina +- [ ] Google OAuth werkend in je app (je kunt inloggen met je Google account) + +**Code voorbeeld - Google login knop:** + +```javascript +async function signInWithGoogle() { + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: window.location.origin + } + }) + + if (error) { + console.error('Google login error:', error.message) + } +} +``` + +```html + +``` + +**Styling tip:** Gebruik de officiële Google kleuren en logo voor je knop. Google heeft richtlijnen voor hoe hun login knop eruit moet zien: wit of blauw met het Google "G" logo. + +--- + +### Opdracht 2: User-gekoppelde polls + +Koppel elke poll aan de gebruiker die hem heeft aangemaakt. Zo kun je laten zien wie een poll heeft gemaakt en ervoor zorgen dat alleen de eigenaar zijn eigen polls kan bewerken of verwijderen. + +**Stappen:** + +1. Voeg een `user_id` kolom toe aan de `polls` tabel +2. Werk de RLS policies bij zodat gebruikers alleen hun eigen polls kunnen bewerken/verwijderen +3. Update je app zodat bij het aanmaken van een poll automatisch de `user_id` wordt meegegeven +4. Toon in de app wie elke poll heeft aangemaakt + +**Checklist:** + +- [ ] `user_id` kolom toegevoegd aan `polls` tabel (uuid, references auth.users) +- [ ] Bestaande polls een `user_id` gegeven (mag je eigen user id zijn) +- [ ] RLS policy: iedereen kan polls lezen (SELECT) +- [ ] RLS policy: ingelogde gebruikers kunnen polls aanmaken (INSERT) +- [ ] RLS policy: alleen de eigenaar kan zijn poll bewerken (UPDATE) +- [ ] RLS policy: alleen de eigenaar kan zijn poll verwijderen (DELETE) +- [ ] App stuurt `user_id` mee bij het aanmaken van een poll +- [ ] App toont wie elke poll heeft aangemaakt + +**SQL voor de kolom en policies:** zie Appendix B. + +**Code voorbeeld - Poll aanmaken met user_id:** + +```javascript +async function createPoll(question, options) { + const { data: { user } } = await supabase.auth.getUser() + + const { data, error } = await supabase + .from('polls') + .insert({ + question: question, + options: options, + user_id: user.id + }) + .select() + + if (error) { + console.error('Error creating poll:', error.message) + return null + } + + return data +} +``` + +**Code voorbeeld - Polls ophalen met user info:** + +```javascript +async function getPolls() { + const { data, error } = await supabase + .from('polls') + .select('*') + .order('created_at', { ascending: false }) + + if (error) { + console.error('Error fetching polls:', error.message) + return [] + } + + return data +} +``` + +**UI tip:** Toon de email van de maker onder elke poll. Je kunt `user_id` gebruiken om de gebruikersinfo op te halen, of je slaat de email direct op bij de poll. + +--- + +### Opdracht 3: Eindexamenopdracht brainstorm + +De eindexamenopdracht is een zelfgekozen app die je bouwt met Supabase en AI-assisted development. Begin nu met nadenken over wat je wilt bouwen. + +**Schrijf het volgende op (geen code nodig):** + +1. **App naam** - Hoe heet je app? +2. **Beschrijving** - Wat doet je app? (2-3 zinnen) +3. **3 hoofdfuncties** - Wat zijn de drie belangrijkste features? +4. **Supabase features** - Welke Supabase onderdelen ga je gebruiken? + - Database (welke tabellen?) + - Auth (welke methoden?) + - Storage (voor wat?) + - RLS (welke regels?) + - Realtime (waarvoor?) + +**Checklist:** + +- [ ] App naam gekozen +- [ ] Beschrijving geschreven (2-3 zinnen) +- [ ] 3 hoofdfuncties beschreven +- [ ] Supabase features opgelijst +- [ ] Eindexamenopdracht idee uitgeschreven + +**Voorbeeld format:** + +``` +App naam: StudyBuddy +Beschrijving: Een app waar studenten samen kunnen leren door + flashcards te maken en te delen. Gebruikers kunnen elkaars + kaarten beoordelen en favorieten opslaan. + +Hoofdfuncties: +1. Flashcards aanmaken met vraag/antwoord +2. Kaarten delen met andere gebruikers +3. Quiz-modus om jezelf te testen + +Supabase features: +- Database: tabellen voor users, flashcards, favorites, scores +- Auth: email/password + Google OAuth +- Storage: afbeeldingen bij flashcards +- RLS: eigen kaarten bewerken, gedeelde kaarten alleen lezen +- Realtime: live quiz-sessies met meerdere spelers +``` + +--- + +## Wat lever je in? + +1. **GitHub repository link** (public, of nodig Tim uit als collaborator) +2. **Korte reflectie** (150 woorden): wat ging goed, wat was lastig, wat wil je bouwen voor de eindopdracht? +3. **Screenshots van:** + - Google login werkend in je app + - Polls met user info zichtbaar + - RLS test (bijv. probeer een poll van iemand anders te verwijderen) +4. **Eindexamenopdracht brainstorm** (mag in een apart bestand of in je README) + +--- + +## Tips + +- **Google OAuth testen:** Je kunt je eigen Gmail account gebruiken om te testen. Je hoeft geen "productie" goedkeuring van Google te hebben voor development. +- **RLS testen:** Open twee browsers (bijv. Chrome en Firefox) met verschillende accounts om te testen of je echt alleen je eigen polls kunt bewerken. +- **Supabase Dashboard:** Gebruik de SQL Editor in het Supabase dashboard om je policies te testen voordat je ze in je app gebruikt. +- **AI gebruiken:** Gebruik Claude of ChatGPT om je te helpen met SQL queries en RLS policies. Geef de AI je huidige tabelstructuur en vraag om hulp. +- **Eindopdracht:** Kies iets dat je zelf leuk vindt om te bouwen. Motivatie is de belangrijkste factor voor een goed eindproject. +- **Foutmeldingen:** Als je RLS errors krijgt zoals "new row violates row-level security policy", controleer dan of je policies correct zijn ingesteld (zie Appendix C). + +--- + +## Appendix A: Google OAuth Setup stap-voor-stap + +### Stap 1: Google Cloud Project aanmaken + +1. Ga naar [Google Cloud Console](https://console.cloud.google.com/) +2. Klik op het project dropdown menu bovenaan +3. Klik op **"New Project"** +4. Geef je project een naam (bijv. "Poll App NOVI") +5. Klik op **"Create"** + +### Stap 2: OAuth Consent Screen configureren + +1. Ga naar **APIs & Services > OAuth consent screen** +2. Kies **"External"** als user type +3. Vul in: + - **App name:** Poll App + - **User support email:** jouw email + - **Developer contact:** jouw email +4. Klik op **"Save and Continue"** +5. Bij Scopes: klik op **"Save and Continue"** (standaard scopes zijn voldoende) +6. Bij Test users: voeg je eigen Gmail adres toe +7. Klik op **"Save and Continue"** + +### Stap 3: OAuth Credentials aanmaken + +1. Ga naar **APIs & Services > Credentials** +2. Klik op **"+ Create Credentials" > "OAuth client ID"** +3. Application type: **"Web application"** +4. Naam: "Poll App Supabase" +5. Bij **Authorized redirect URIs**, voeg toe: + ``` + https://.supabase.co/auth/v1/callback + ``` + (Vervang `` met je Supabase project ID) +6. Klik op **"Create"** +7. Kopieer de **Client ID** en **Client Secret** + +### Stap 4: Supabase configureren + +1. Ga naar je Supabase dashboard +2. Ga naar **Authentication > Providers** +3. Zoek **Google** in de lijst en klik erop +4. Zet de toggle op **Enabled** +5. Plak je **Client ID** en **Client Secret** +6. Klik op **"Save"** + +### Stap 5: Code toevoegen + +```javascript +// Google login functie +async function signInWithGoogle() { + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: window.location.origin + } + }) + + if (error) { + console.error('Google login error:', error.message) + alert('Er ging iets mis met Google login. Probeer het opnieuw.') + } +} +``` + +```html + +
of
+ + +``` + +```css +.btn-google { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + background: white; + border: 1px solid #dadce0; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + width: 100%; + justify-content: center; +} + +.btn-google:hover { + background: #f7f8f8; +} + +.divider { + text-align: center; + margin: 20px 0; + color: #666; +} +``` + +--- + +## Appendix B: SQL voor user_id kolom en updated policies + +### Stap 1: user_id kolom toevoegen + +```sql +-- Voeg user_id kolom toe aan polls tabel +ALTER TABLE polls +ADD COLUMN user_id UUID REFERENCES auth.users(id); + +-- Optioneel: geef bestaande polls jouw user_id +-- Vervang 'jouw-user-id-hier' met je echte user ID uit Supabase Auth +UPDATE polls +SET user_id = 'jouw-user-id-hier' +WHERE user_id IS NULL; + +-- Maak user_id verplicht voor nieuwe polls +ALTER TABLE polls +ALTER COLUMN user_id SET NOT NULL; +``` + +**Je user ID vinden:** Ga naar Supabase Dashboard > Authentication > Users en kopieer je ID. + +### Stap 2: Bestaande policies verwijderen + +```sql +-- Verwijder oude policies (pas de namen aan naar jouw policy namen) +DROP POLICY IF EXISTS "Allow all select" ON polls; +DROP POLICY IF EXISTS "Allow all insert" ON polls; +DROP POLICY IF EXISTS "Allow all update" ON polls; +DROP POLICY IF EXISTS "Allow all delete" ON polls; +``` + +### Stap 3: Nieuwe RLS policies aanmaken + +```sql +-- Iedereen (ook niet-ingelogd) kan polls lezen +CREATE POLICY "Polls zijn zichtbaar voor iedereen" +ON polls FOR SELECT +USING (true); + +-- Alleen ingelogde gebruikers kunnen polls aanmaken +-- user_id moet overeenkomen met de ingelogde gebruiker +CREATE POLICY "Ingelogde gebruikers kunnen polls aanmaken" +ON polls FOR INSERT +WITH CHECK (auth.uid() = user_id); + +-- Alleen de eigenaar kan zijn poll updaten +CREATE POLICY "Eigenaar kan eigen poll updaten" +ON polls FOR UPDATE +USING (auth.uid() = user_id) +WITH CHECK (auth.uid() = user_id); + +-- Alleen de eigenaar kan zijn poll verwijderen +CREATE POLICY "Eigenaar kan eigen poll verwijderen" +ON polls FOR DELETE +USING (auth.uid() = user_id); +``` + +### Stap 4: Verifieer je policies + +```sql +-- Bekijk alle policies op de polls tabel +SELECT policyname, cmd, qual, with_check +FROM pg_policies +WHERE tablename = 'polls'; +``` + +--- + +## Appendix C: Troubleshooting + +### Veelvoorkomende fouten en oplossingen + +#### 1. "new row violates row-level security policy" + +**Oorzaak:** De `user_id` die je meestuurt komt niet overeen met de ingelogde gebruiker, of je bent niet ingelogd. + +**Oplossing:** +```javascript +// Controleer of de gebruiker is ingelogd +const { data: { user } } = await supabase.auth.getUser() +console.log('Ingelogde user:', user) + +// Zorg dat user_id overeenkomt +const { data, error } = await supabase + .from('polls') + .insert({ + question: 'Test vraag?', + user_id: user.id // Moet het ID van de ingelogde user zijn + }) + +if (error) console.error('Insert error:', error) +``` + +#### 2. "Invalid redirect URL" bij Google OAuth + +**Oorzaak:** De redirect URL in Google Cloud Console komt niet overeen met die van Supabase. + +**Oplossing:** +- De redirect URL moet exact zijn: `https://.supabase.co/auth/v1/callback` +- Controleer op typefouten en extra spaties +- Wacht een paar minuten na het toevoegen (Google kan traag zijn) + +#### 3. "Access blocked: This app's request is invalid" bij Google login + +**Oorzaak:** OAuth consent screen is niet correct geconfigureerd. + +**Oplossing:** +- Controleer of je je eigen email als test user hebt toegevoegd +- Controleer of de OAuth consent screen status "Testing" is +- Zorg dat de juiste scopes zijn ingesteld (email en profile) + +#### 4. Polls worden niet zichtbaar na toevoegen van RLS + +**Oorzaak:** RLS is ingeschakeld maar er is geen SELECT policy. + +**Oplossing:** +```sql +-- Controleer of RLS is ingeschakeld +SELECT tablename, rowsecurity +FROM pg_tables +WHERE tablename = 'polls'; + +-- Controleer of er een SELECT policy is +SELECT * FROM pg_policies +WHERE tablename = 'polls' AND cmd = 'SELECT'; +``` + +#### 5. Gebruiker kan andermans poll toch bewerken + +**Oorzaak:** De UPDATE/DELETE policy is niet correct, of RLS staat niet aan. + +**Oplossing:** +```sql +-- Zorg dat RLS is ingeschakeld +ALTER TABLE polls ENABLE ROW LEVEL SECURITY; + +-- Test de policy met een specifieke user +-- In de SQL Editor kun je een andere user simuleren: +SET request.jwt.claims = '{"sub": "andere-user-id"}'; +SELECT * FROM polls; -- zou alleen polls van die user moeten tonen voor UPDATE +``` + +#### 6. Google login knop doet niets + +**Oorzaak:** JavaScript error of Supabase client niet correct geinitialiseerd. + +**Oplossing:** +- Open de browser console (F12) en check op errors +- Controleer of `supabase` correct is geinitialiseerd +- Controleer of de functie correct is gekoppeld aan de knop + +```javascript +// Debug: test of Supabase client werkt +console.log('Supabase client:', supabase) +console.log('Auth beschikbaar:', supabase.auth) +``` + +--- + +**Veel succes! Denk goed na over je eindexamenopdracht - het wordt het leukste deel van de cursus.** diff --git a/Les10-Supabase-Auth/Les10-Huiswerk.pdf b/Les10-Supabase-Auth/Les10-Huiswerk.pdf new file mode 100644 index 0000000..cf300ba --- /dev/null +++ b/Les10-Supabase-Auth/Les10-Huiswerk.pdf @@ -0,0 +1,200 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 6 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/BaseFont /Courier-Bold /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +6 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font +>> +endobj +7 0 obj +<< +/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +9 0 obj +<< +/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +10 0 obj +<< +/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +11 0 obj +<< +/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +12 0 obj +<< +/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +13 0 obj +<< +/PageMode /UseNone /Pages 15 0 R /Type /Catalog +>> +endobj +14 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260422141611+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260422141611+02'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +15 0 obj +<< +/Count 7 /Kids [ 4 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R ] /Type /Pages +>> +endobj +16 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1725 +>> +stream +Gatm;?$"IS'Re<2\AJ_:Br=r_=gI/'[8jX+'55LU^)%*=g"&T(H81dM4=kO:OE%/s\=:N%to3+:.Rm#E08@pSe\6L*+GV'$Su)Nf]F_P>U9BbOqA.T&TjJ/od_L^^1Z2YVYS;&sp32i'-i1TO*-BYsSZk$5I^C=nAu)]&WVfAi*2W8E)a[V@m4.&T3`4#>a.:$p\I`7-Ca5+4G*Q.kTAR+/,OBW0l_\Qru55DFl:_*/nR-dfDC"o_`1X]=#To49JoAS3Dpt!Kp21RM_%!,m(6Vq[f_W%6)b'F`fc*'KLq\6gc/D,/mKU1lf'$i%Kp,X8\uA*^j1H8$6QM-S;[4iILi^C'a$GQpHAE0Bbi^PZ`o=Q^^.Xr6eZgddqB-:.mgTt(P#8#:ohX92QiIL"R]4jB8sP?KjcCK\f`s=2Ta#,/KJIG(pHUqDUfl!oW/X6bPlY`gb(g6?7EXjbJ,H8XV[M^Jl?CH=6mD&:OLpq#XNbP':JqGE/NQHoT=Bj%X`eG+poKmfR0_N29h!R20J%qV"d>Xoa*kPl[)q+(nTb;AY$J`6gVQg2-%OE(,ei"a_;`K!n2Z^p9X+anjrG/tH`a"f:A&ZNmu>F4ddUq5+f\j9U5Kcm*VZd\`5jrB8+mq@2+=.Q7q7;+R()bPm\Y36-LB@#F'fOrna-rDpGnB/H[K%gXqX(g4MgOjDGCL5QT!CK);e`QrrkK6hKWaG]W=$JgIIf!`N?Fh-!cA=PDf+4m[VO%6>C,Ef;Y$m27/Je%+&?nHCZK5N?7@Tr.mV;'MSA!iJU8?\B(ssck@-AB:16,RE6[E%/U"G,)oNV30`/sB@*4-3?J;0G[(6g_#SHUG@h%<[e6>l_eC0Ys2%,>`UIh7!22i$$)>IV@Ar+^[N\cY$[j3/<>fS>2VtqLldYHH\KS6e;&:oE=f)j#@'^a95sC:K3\O5ZW)V_6A,Ket&K=H>,kZ)0[^6SmBu(D<#572XTnmP`oNd^tMFoEg-";P=^X$+,EJ&\%:^Obanj%VQ.??#=5OH!r.pY^M*RqZr)XAY`!KQP!jk%=SXm7^^qaaTHPu*Rn8tIVrTe@H,g#%eggVR>r*$3+2h01e@gCA3?A+Q26.]VnH]IrHE"Z4Oi4L?&3Q;buU$>irEhZLMkg)e>@(>/-U`Y$&4PC_-CKKlp5`C5`WYITY#9l$3/"j]#E]cp%l53Cft,'Es:e4#/cdPq'pN)b17$G2:%*.*CKZ1>&nF?AVAA["ZCp0)(D(U(Sok:/tfN=G(l.h^)kQ2/[&fNJHp8)TmcQ(ra8Kio-g>;KmVV?UW$B]JJf5GWendstream +endobj +17 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2332 +>> +stream +Gb"/)lYkN;'*"Q<@^*YBDR!WgVl0UiU"?tAp580RPAYq=J:f%%[UND38ofO>UAt*#8NlE"Z'.(*"A$nj&h2-'2kqNr$5*92r\p;Kr::a8?;mL*NmE4_,2i5Zr9JUrO$1@T6q7:aQV&b<7!$H_!S(_a6t/k(_C/>*'1l:W'ei*V*7G`S"(s:\8CGr,OjoiYPu4NDJR4F+71(I)KPRPQ&12,NTO`6.NieV9EVuTC\`4081#Kb>I-s;rgi+T=%;EH*:U08Lm5]Mnj+p@1eP%94Ze7j^A[.RBOKe&moe*)#dU]5`:r>iPY'2rOBCCBpe<$n(mE5G]c0Al03XR!N:bSYde(T+Ep\LF$2E-r/90:_5@rdn!(4MJ]4]r?QPn915OQ_p2T^;$P/]M*mY0_u5'eSD0g9k+bIFCgCCG(HHo&i#*@f6&ko&5M`2CY"'Q]4pnPC9gun[_R[;\4Wu#!l^4u#&T&@4Y7'D,&8;Onc]5'i0lZ&8[MlgiLg1N8L/TJ"pi@*:J%Xq*l\8eQ%pB&Yqjoc)/_?upG3]XlNbC`Y\6`Wfb6@f(N-6Y+"i\b.>''sIKAQeO/3hA+lb;9BEs[u[J:jt^KgjMVhAgMo]j)3-g5#.n/?QVdOQV,I,A$f5u53EluEMmb+:\]VJ/%Fo/*!q)aM-;5bnM"O&CWYR<8cTY@*3A+A?s?70JO5>R`_"4Hj>bh=_YF:"pd[-c]c01aHk2XIr-aS?)uHUDN-DL,e6gn7[Y;6oc8d7O,Ys^'OlBV.&=g3X`hqf@+.F`F4I`4S(?J'07=(ER#(4Cn:"/$Xq@5KG@Z4%Z<6lX8cK2s\H!q_^Au/cuA$J8/^n7lI`SnIV\FBCMgETs%#(_f:3R"a"a2sW$bqb3<:S.$0IWr^T@ht*Nd"L^$#h:U'mP>tI2K3)AF_]^Dae)"=AG)6U+[mZCJccg:7Ah*2:\j>j>S.a.fOpi7$'>YPN@G8Njr%$bZXKq?`cM6%g1faM;)>Nj0Nk"(gP=B"iT$r+]<@QcNsU,JU2O3aBAlq3;?qn%6sfZO3I&A).@%4j1Ka]ckLB3#filE:H+OJ7$`_oI-d2`T@cSW]2;V\94Ad;1aN-Y6F-O/QcB6P@4=M&r9/M;Q(7fde_9Fo:iJS:`f4fX'5a9`>1XMb;*jR>OH*'kEGIBq)NAj!QldbPNVFHX0j^o-YkNnZS]9oquYoc8Uge?ET[n()^B99A%@"],Pur<,Rq>XbTr)':^rF>2M\l2XrMHp=!'YN89\*]5SUd%sPrRde>#jC:CRjp(/kJEbmC#@C+H46#;fr);M)Z,48$^YM5l:ke6LRpmp*g(B1,S0KP:XY5N:XT]*n5S3lDEF#ANJn)@mS/>P1)ejYknNTBgsYZV7Be"-@O=NqhKP@M>o+\dXP(Y12tS6E]d7Zr&9:R$ggbaCI<002USHtt+tk=jjDB7Sg3et>NB+,,q$aJh[:a,tQCFW;WFpofgT"lnAADB=D=nh#pO0/-XuZ_Op/P*A?*-*KIMK0XdOEh0+h(]bJupq+oI=K=Lt\14]upgo:^U5sMRJV06'mkub`cEd4?>P.<[E<;aJBS#E.*eHQ#109dgs7=`+o`>6CjTOKW^s92$Xg>"u8Jeg.>E+El3&ej*<4pkf$hjQ(n$Q6m~>endstream +endobj +18 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2220 +>> +stream +Gb"/)=`<%S&:XAWR$XObdBL80"k\SOkr(uW/+2h`JV>;sNac.3,rn7qoY9jVOt7upiiQF,OgKXM.\)./mi)a.1SFN8\^+pjn@T!%?SPq.H:))'8EcV[;6TT%6NdjG.Y.l%I4^!E6Ld<-53eplm'D4]`\j\[&ah(WS.k:8b_+]dn8X3d[Cf"pmrNksVKC2-9+kp+=1.?;VAI3`6Etb+2d-'2:B?(8'MBXZF1OthSdNmLf@aafZoIcg2fU?-n00-q$HT1ShQi\+Yf<_us1ASH.q`Z"=BaA06d0H2@\Ym%4e<>i!-p=)/4-@b9dUR7fH$u((:4TXY*?M0XNegLipAqp*A?(]Q/S75?A&i&31,G:70T?>tI.<1H8`[C+c0P>fYZ3;RY$Yo-rD7o57dWMh5.R'."kVai+)07(51>QpR0WU>uL*_^)K)I8JNG@@4[nDTe;[F+.`f$%bPBVtVj:oo=2^]W@0.mLapr&[24qLqbeBV8QS&pI?`me/prJ)9.>D]^`>W@kM.5;OW(e2KD=s#E4iaOQa,)m1JD;*mhTb[V$pXX0BY,a1P.:8R,E-:L06a/G`5P#q*4dD"OU>?bR\s6+`/Cu4e5&PW)(,4:B?KPa,U^cmrOZ#.igOuf.r9+mO[9sS>Qr,>u>&qpVJ4-G0a_UHG-N^Cdg7Uq^BX&G:psS#ZV*?8+cgT8K7-!RZI@at4H=nKj0b.82?fG[foOHW?!G-9]7L)O5<.DH@S7-7k.1F2.TtAdU']"L>"T(B$bmD<&Ecgsa%`ZrV)=p)=FW1-ELQ44X:is8U^mNUSEVDkXh29uFVGXm22!=bo]cW-VLrR;$h`^GaM9^uTd,s-,3U)D3XCH\s9jk1QQfIHV0%*c@QBk5"n[)Q/C^ZJXJ/+/\.klo!!MKK4fH)HQ_:D4N'bRuFW*XO);LgFTcc%?S/]?>#Pl$VTBd^k_=e5(uRI^"ep#JpkIJA*WUKSha)(gn#M^?'fN=/Ne>d5dK&s9+7f$Frqd9FQXFY3$#Obp$cC\"j+[s76!&l-Y;E-n\9`-2ruU*`)E86@A'=J;rHUbmKONR122GPRZ(jHGWdd?$FoX274ghGu`J4qFrWQ:jIk:~>endstream +endobj +19 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2086 +>> +stream +Gb"/(=`<%S&:XAW(gp4Vm5BoJl,ZM[DN>K9Cq!-IIE;k6dB[4ank*r8c*-"JF]e06Vs>l8,`!%hPfWZ4jr8&iUu:/Q[Ju"\JCbTVr/LhiE%qDDLk(DEnFiUNGE6\jn/VO_9@nAJ&>)T2#frcIMM$f5boNs,-#c]PrfnB.\OKB7>D!nVFC;GO_TI1V'')ONI`nNIHG!T6)jpn/Q*jaE1\=PtHr6[[L^![K'%j">s58;9/b6)QAeo3SPqDj1Zd>O+f_MK=.f66Ja^,TZ6E"sFJL[A@%Uga0ZkLb>ROs5VS[%rdh'\epI6/:kRY>S"k$>VbHXl%c%_U54_*L[[c?lQhroF]ptaM^u;^@75>%(Q>*JhTGaV=`%Oj"qlP+5Y15b]L]>8hORm@m,\C*(Pk)0kL\qK\N9FXsPF/6fcWqKPi7mC7)NkfFiVMB828#@ccklccQf/e\@.a>ni3%=7]3[K.*_P9>($dk>nXd\6s36pQb&W@=YFd>E6blZQ,0)Lbc`\>;BRY>a'q>=b6FL"Q4%Ku&U(b3(%VefE9qNQMqg-sj_7B`LZ:$X1MRJIZScSCLVBPUoGi2]"[F)b=;C_Q6g-Krk2f5V^G_u:*:&S=]&kQl@Mi?h;uSR+M;6P\l-?'b7a"NPa(**L*SM4JW?^qWBC;h8pP&_993r*G"Fh-hPd^]GhHG+6Xs%B#kQS1&KnW.&"$,Tjg`9^Mtn2k`]`k=i#_BXj0qrU`^JE]Aj7X>JT9m:JGJd+]X:J:,FoNqJIt)\%kq05AN[Be;(=Nefaj(_#T'Z!)M^Yhm0e,o_tM8XjiTK>m/Jup_b#H:"*>0Pm2.iq_i>O`0WVh-odg6d5->#hf!#chRu;KS1cs^M^ucjY:PnVkY_6EtcS-^^pl8KDM(b6,fLXB;`5,Pb'c@^L(7dX4%X"nEgCT]k<_cXQf\9>%J#J\N+EnWa]IV&%KDL+i7tY:rD?OWpk`m_/^<+WXTr&'8Mqc2&&'^e__#%o*TIs-.JhN:24PbjqH1=?r1LYCWp&fdP@0p%^FFR`8F/-@.li&geGf)QK(!VD<[,PmHqQ00hJ/>e&qSZH-IE:Y?*20XLK0`hgru+-NOR'ipu-jZ?RKs?7X&>Rpg:K?PVZCa@nUfr$c3+XCRV-KZkAn_;e@-R)`c[LGcXl7_'b"bCX,;[E:VHIGqqAX'`,d*b_*t0Zrsc.X1:h8M10*`udc3q`tcR2Md#oKK6oU6.@f+i-1[7TnH>\l[Kreaj@-AN=E@BCa.'1f)uq1IElZH=cRUjS3neafhHO&%0W``Rc[Xk"74+9mRV5;&G:>9S,3%rot#kC]K2*?MAk+E!j-OP3lMAG)^>'e+F5q`DdCtJ2+/5%#Emk:fh^OCI6M07Gb@;M3\@V;0G+MQ\]N_PdJ8Nal@K]A5i=u8$TC[W0cm>W/Bfa3<11'6r7N+@7&L1EfDEF1;+'+FNd1j2ViqEj$"`+GA#+ZF[+3/Lr^Q06b@>B1A@!IWC<&W)JOTBc@)4HM_4_-]f$/uZY?WL(587$FJTja:TKt(XQnuPD>cHphV<,S(?l'aM:t0"YI]jc!q:sn)B((4p:luR+TZBJ4OV@LPO)L&U=ZA(YTu3-RkH-'t8mR[nj$ESr]OQ`Vh-0N`PC)pugcK83:JTVb'endstream +endobj +20 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3051 +>> +stream +Gatm==``=W&q9SYkjm;@.4H2_5k&f2c'C;.*7#lLa4>'5ncD[m"IgSM*`=?"IN&8?aZ=go<(qoe\f6'Hh59,gs4$t*B)qrL_T<#iPeli)-mp!hTAY*AnDf&/:)#`/M!Ol1mfEf$O$t&Za[(f>6Q-P+R1EWb&JfUT0Lks_TE4*dDVlLsW'2:_?5PGF1@"[4GFk#>GC6(5-#Vq#^^9UGUqa.s6Tjd+J9:/RaI^/\3$97nO\"Dld.\soNtR5Y5(a_t%.E+B]lse<*7VG+od(jBf68J[ff:2ec;@'lj>m!eC'jD1XYnXLEIAIG>,'sa#64Ym^J"?h9k4i3ZRg[Bb$;s8'#$.;^tIi>=]),lP[eqk(Ga+p1RXPI@!Dpo\!hH#5%bQ_*j!ch,R^7TI&p;PPki"hHR1!g(#\`jI76f:P5<+<4mo&c*^Nd1BOT]qD'\WgP[]kJe88GWS>\I5cc8s?i5h6F4MXC;MhlVVB3]E$m!CR)kd#*n6ej7UMPm!,iL?J4Jbn=B4u#aigKR2ZIZs$kJLlK&^di/*ce;H^HHYdp(Y1klF)>\Dj26oI@FfaDUDIdZd-:\'R(@igk*>,0aW5?J`R##@+#nrMf7r_O(iIJI(DD'$icZSY103X-VD(nlE1;Ym)N/uJX;L0eW[Nh@ROHd[DGEJIc2V++][Usmm4L.WJr(I_nRT6XP!n:SOhYG<90hdTSq']aV4+*`'"*hjUJeje$]1R.hE@&Krfd.[U,e.B9P"-!DdV,76Pm5JkXV/iOA[[qjq#,mK:U,#jY"9\#m4p_"Z,C^n4f.G[Y;ra:4c+<]iBDrY2BoZESh[cgdWF,T]<1j,7kMii,KE]Snm6DRkP8_2:iGmDOT5D/R?TDBRuH\ot6<=?Jo8`9H(coiH-Ff6Sm:+R!#u$!R`VR:@4=geeegb>ikX*GGocV(BbpqPI&1ONi+0qZGkYio1s68H'=UEAYtjHIsV^.P3=`bT;n7Sp0EV97.Kg<&A:H.HZFR&YKo95feJD$K#>I>b@Y%s*VOHSj2`%h':W=M\The#fM,>oeh8m^ZEgc$ZSFM#?T.SQM95?Y3Z@nXe[+Eo=K]RHC+K9hXl!'B_J'E-Ua#H',jCWZ:Hp_'G#2CgGn^c(1MJfYItk!`N(+W6UHS'4H+$b7@Q_?J:!6b!V8?!n\Iq\[=,E@Fd(#fXpkS?6`r:2VrMAbFf[.Ac&D"u[,KAA("\PsJ5N+2*E6nS3=9\jlPd)lei?sH3kEM<)7=X"C[9e!],%-9f#Y+!X]r@R*^h:7q!iYHe+=4XRbJAHGOdju[79@A[,4A1SUBbp.p!.RPq.T6&*eoj+W>YI$b_@YJ:8gS@D-WkPE)=S_[GT.OfSN2TKI6sY;lmc%>8%NI3d1H\Z7/Z7u'iMeC/&HDRE4+p,iV5Tpj`iAsq?T-KBQQ5lgRY8#n(8?d>ZlC7u%;6grh2!#\>0@PSF3Be5VGt*?8e6@\Ub-iYD8^hY=RZp65.g1*]h>SRM5X61eBY\*-nh`X[;a.2M)L2k[$LY\-$(Xi^-Y2q@PtV;85e=0M]up^0Wc$I(/i2O9StSq\#QH5T3gR[X$!\UGLK:ce3YTe(=6eK\V3FPpTVH.-!3+1+I^+/XAfS$)ZWlOBi-pR:3(K$NtuDIFXpp<.-'@A=*-9L#2C%=GF3!edj5_uT$2[/pE$;2LLFG'Q[e`!Y[#eJS#7o3D(a[hsahVeoiJ*s+R%0)pB;tq\0NSKQVXjI^>%]n\.Rc[e6PtB/K?!j0]hjH\_)2KsubB\)REaj1rs0@\H*0-9niPG!V6&HT4@18\4PiZFA:LZ_N,8"j_cTYq*&?JVIJp)`rq7O0M7dYr>16(dl@0C/LV9JG%=kKJBK&H`[0KejUL'N0546+kS\hZD;qniO)_=3!Lqe]QjS[s\??!H*+l[5g=kH!EbM[3oV&:s_OpZ_!R)K=~>endstream +endobj +21 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2719 +>> +stream +Gb!SnHZ.Xu')t1g@SgGE\%>L/ML+C2!bpXPhX`%-ltl+j!s_8,*$qoOQ/q^`(]Eb\Un2a=a_fSdXsap:12#`._g-6,$3^B+p"Pr<]GncSpCo$.nI8T4[5Ml9c:Xb/1'+UIKR'ruHB'TYB35i/:&;b8hBT+\[%*-_^AXL9#Am,tX+;SqfC/8nkHA`WKc1&iR@6,,df,U!A5cLpN[UB9iQpDb',GV`1>^$2DAFrVdJ,*MK1LPo1/>Cgp8N@Ve,>:$6be[\c"*&)E!Z:=d>1ukG7l,3,23-[P%3"Dc&LA:t?VlRj$#"4U;Mo@,[NQ6BkKl:Ps(qb6d7bhBD%m.mQ,^\Q9jto+"\42tUEoAn<(u7=4GY+`bXIo5K^g(A?V/X&rgZhbgtL[N'F@S;Vb96WM>09pHf<9JcipbqkTc\K,GZY9O/B;RK^n)pXp'tSl(f3I%qB#utNt;FdoUD^lW%@T4Q4!f>G`-lC,2W@3V_kt)#fLQoEV#>qE_\:m_^+PcicK[WG[[j&;S5RN]r@Oqa2\hHmk4(E9H4U+*ICNSp!:kfM9Lbl#G-#At(!6b(%SU@dj0V"Z6SpS9.F^5/Nq/O+OVKJ(%!ceT*(6O!.qMHO3+@M,=rk@RP_Q`DZQNJ^iA;;u>CP10eZ`a=UaU:H,_18E)6=ktJ=ELB#$Af/_rso7e0I6]l;C/D)8;DHgL'7SQ0H(.?uQbDFq-Sabclne#jNb`)DPT$P+,rB2C:J$W9h7`D\gPWE_+6Ps:'kh9l:"Q)A$]=Yp*k;$$GWY-L[5HYpZ.@k:E:PLk:gK?[BH2_PVeg;CdM_.Pa[Nk\XVJb\u![j,G4j[VlgMVLadXX%`P+dA/9*<>Qq=L/$DuL=].NRhkn*Q$l3-g1Bp*89NoWY;koe(kg,BdG?fm)_F9jB]S45"\LpA'sMdg#t_<3.=H<6g7q8mNNPf('>1]O?.3@6oNE#9&4:SU0gft+Z-Q6"Ju_u8@R[CJg@G:&dOTVO)b4d,AU>=IV]/Val'M2GDO0Zsprj-AoI)LAVaEKJd^>#k$n'q+YSqh>LBRc`.K7hl3IZYS0%JORi`&%juWdDmCm7$ngWM2;3!:Z4JEU:KWY(K'+;^.>](7"pV_3jp&5H*bkUDXYi!_Re$#%kdE+rbR#]LJ=8t6qB2fj>/&SEg5o7$`qsXEKrNkP`VupY3-^#AnPFb"`$X+4e^GS&QE?!QTYo:QRT77F3#-LCXU1\+!'[Qb2>-:JU:O3Q(a2n(thuk_m(?7,o$aR7Za-G)SV+fVa"iJm0ajIU[0-9f,]0"mhfU&HqOR!!uH6=S&qs?.&SY@WWki.4II.HW=3;b[!m-LVC5FUIj6Ig@9gW=$]ci:Wk0&Q"aG#q*OT&khY>D&Z`KW*aNmW-eSk"n(K4MU+!ILPNq(K37-cXqUZeV*J5Yl5=mchFmIf!uB&VFHC'Cn2M"q+XJqTp+GlIL'T#=KVd3%S,'",TiQG6WqV1)deE2I8FB%n93R<4N/G$eHR>]`.RXRV6r;`@AGd]@3ceB0N&j?0EnMM,`8LtU^#^5k[:2MkeP`8%lt]n=lp]E0%%ErK$e6efoU9lW+1M&0nh8%`r/".qe_qYj0A1?79(+UGVThO(dj:W,fT8\s85!>;Er'E~>endstream +endobj +22 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2641 +>> +stream +Gb!SmD/\/g')q<+0jA%jDNQUu,uIQXG6)XI2!MPnlSp=/#Z)p)j!^4iX<]1Lr;%@1YAnA=c5CiAU93)\adIo?_r2$GJDqZJJ,MCC+(I8iB#$BbYCW+[K^f'=j3D!P@Sk[M-4n'-N\GuCL;dF3P%c;\aUW@uB3]uK>(kXr!7$G*D.Z-ZiVHi8\A`ib>)pM*jie?T4l7NfTE5rdLuDGGR"S\f2clj/e+;:4W7gN^Y`3/OOgol&hLK_$aWr_3T`SD2CPh=%1SOCiM7n)_4kj1Dj\ZF;O%L%.5N]#!:b$l<\]>,jn=Lh0\c!iBE<@C_RIKk6DE,:-$5$Xl;QYq_r.$"X8GmDKDmdRQk!:C,)fZ(6u%=u,N$rb6f[BM\5N]-L_aQ:tXb^"W/'<[si;Jm9[WiR^V?k`0b22!XPL$bm)TVal5SB(VrB9P*:1$-^$fUa#HFK6s"K"Lf&4aQQ@QdY7Io7YLO763!Pi&R+pb`W$a$`7QR-[&e%m+"h*0;-1U,7L]N!-AQOdB>NsD]F[k;FKiOkAD4I'DXPU+cd9Lg`\6oZ_#HLek&LtY:\N_MKbs0n6]g,OpMZilCQJ3O6@1g5IX2%Gl1\)2RlA?rPQ'bL)b_V?CAI)@^ZZ6ubB&14]!QS'jRSg>0M7_MQr:ie!S"(QW9sogpTZ;oN\gu7qb53G9uG=lhq:'dR//bo\!."k/0sd,`e!B5S*QfCgUk)8bV/_l7u&cniW\Tn`SR3Kp+rr4Vd+^$>gnqIb7Gq_4L*^=ud!@%h4FMo+:R:TL77/rt%I%kZ0r>PJJG4=l68>m+W\Gi9N5:$FQ>(s)%WZ_eQ#]s/;U2#VW``+W1L[tK1W+gASn50,S8m-RN")["Z*"dc9;S:\jd,eCT%"$2Q$_[V%.2olnKM0ZbG\'MgU1A.H5J#jT!pm]Ea]O2_8>Y`h!`)sX4:jpnE%'CEg&gDd:F8Lj`VLK'.^>H#%to3;1e&h#BAs=5odR'\Z\HOsk$1pkVg9cV&8V7/7-`Rh"m4MJhX6Le7j20nGun<%M,(FT1-7u;.bOmAqA:TFICg.G/$l9#cQ^3$7D6KQ[M/8(7e^5F65)?c<,elLSWB7U0h1'2Q`1oEAb+*CXK\f,OGg5LXG7>B:GFntr8Ag1a5Aj:KUXO)9+MOC1kb;#GuIhGqg71]b4NFITU-64-pK]$B*UV4LK@edVM.73fM8oV@nG#@AX^J9)#H,Bk#/$I0h1WKUr+7V'n!BYN&!qP]g&!PlV:qM!OY5B9SVkqeHXhAZAVg[iDtQ#HU.p?/2:4%+[[)mj6].$H.eq7ft2=e!,om0O'pjY-YTiU_m`"Th3E0\U(Yo^6QA5nkk\A\ZAuT_1C!$e(<+&#:tl4d^q@Zh4qKh0HB=3(qP:C)@(]fu`"ii8jT=VIRh[gL`[J'f@2*KSK9VrL"r9X\Ll>[t&:BRs;#hpnX.g>OM>[1qo&_-Vt-8MqU95UK'm!1N#N3%@m5d[NL:'%&+jP0RH?e9NK.$O8/2;"g:C@,rBNSGY"hI\1Dim>tC8eWlf$t.C/6U;Pr-$AD)[u8YaZ.UD3pOVa@aT3I!q<.@?$rS-UhU#@[pW^>7pY'NKIU>67Sqq0d!ELi-VMp;l48oJ$trS]q3,A]Ml)Y3Z`.GFAME)mCb/c]\!6UneV#5E9*$[njX3.f(XJ]?*ek>h&+PT^6GQ1"YTmq")!0P`$Dkj=ilK:Q+Ia0>apN09UF>Ou6=endstream +endobj +xref +0 23 +0000000000 65535 f +0000000061 00000 n +0000000122 00000 n +0000000229 00000 n +0000000341 00000 n +0000000546 00000 n +0000000656 00000 n +0000000761 00000 n +0000000966 00000 n +0000001171 00000 n +0000001376 00000 n +0000001582 00000 n +0000001788 00000 n +0000001994 00000 n +0000002064 00000 n +0000002345 00000 n +0000002444 00000 n +0000004261 00000 n +0000006685 00000 n +0000008997 00000 n +0000011175 00000 n +0000014318 00000 n +0000017129 00000 n +trailer +<< +/ID +[<4c88adf75e990bd3ea85826d6e0ee994><4c88adf75e990bd3ea85826d6e0ee994>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 14 0 R +/Root 13 0 R +/Size 23 +>> +startxref +19862 +%%EOF diff --git a/Les10-Supabase-Auth/Les10-Lesstof.pdf b/Les10-Supabase-Auth/Les10-Lesstof.pdf new file mode 100644 index 0000000..f6b5c68 --- /dev/null +++ b/Les10-Supabase-Auth/Les10-Lesstof.pdf @@ -0,0 +1,296 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 8 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/BaseFont /Courier-Bold /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font +>> +endobj +9 0 obj +<< +/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +10 0 obj +<< +/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +11 0 obj +<< +/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +12 0 obj +<< +/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +13 0 obj +<< +/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +14 0 obj +<< +/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +15 0 obj +<< +/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +16 0 obj +<< +/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +17 0 obj +<< +/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +18 0 obj +<< +/PageMode /UseNone /Pages 20 0 R /Type /Catalog +>> +endobj +19 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260422143453+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260422143453+02'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +20 0 obj +<< +/Count 12 /Kids [ 5 0 R 6 0 R 7 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R + 16 0 R 17 0 R ] /Type /Pages +>> +endobj +21 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1754 +>> +stream +Gatm:968fX&AI`dqN@),ZH)RfBMY,r@5drSgG=-u0\@WS<&&,Bh>L&4^)dIm^Wc_tF+Dje3.Ju!E>[L5P=3:O')idY^(6$mTqN*T:tfR``!F=L$,pG:TsA)MiJ_PKTcuTq)E9**#Zq(\L"hpr&iTRe,8$-]+?%d`OCAkBf^\W:B'WfYP5`*mW8Pk@B/i8>D.#qr5@IIJ/_a/?9islq>h40TuF@QcO.1meN;AfBJbL.O.X-m_#&LgbBTK=.=A*%)c%uojue/q(MBqo1+=_8L:?m5VYO\=Zg%^QmU5\8\aJ.;ggGrWQMjH\!i(9==lm-#fmfbgP"!04G!9E13hX%b)IOo)Tm4;^Tfn7p]%c_&nEe/h?)XKee7.e)$K[7guEorPU\QL""8*PYhA*W-^62&E7UJKKmBEGon)b.CP[*tc:@f1/>cn*]DM'#M$*aL6"KPUJg5d]k//hOYijpZrN:C@BGa+=GS!mlMGc6G(Gq@RJC?RZVM9:<7=]iF*oSo"%^b7M*m&k>r2:LT(Z-+5piJ!0=][AsR1g'k.T't,5CE0n_@NL*(3rHLAqD)WrgMKH52h2U4p+hJ3<`J7-B7q&F"5"=@t>9GKD111Vn=-6jbfcM8A*S4KRj8`*3G6#XCMXT"<[E1DM-G*+BiROi'8n3m=nnfFXFETqi-1_5sBQ[96,E)\g<>=oGm77%\7IH7noI5`J/F5&CP!t)N;3J'pm+F>8iFteX#G@EP@&L!;2(PN=2Qo'thgQ\(ponu+j3[5VM.CDE)X^$:QuDH,1Q7+T%?Jl3DL;Ze)6Au-(R^RsA5hDISgN55IHdZbS2%0)UT-2rd\."3^8/Yh:neEs*?`Ak?+boW+OkL5J(9Pjs4rJ>WC+([-$.N%@=dW#rEQ5SK2]&N\^CDI'B*Yn*[JII[:YY;(H:*uoF/^"cnW6ag,^W;>/FQ/U[c$>j5IbLegKfRkrF)7E=A'gnF#ZZ=79QO^qj\+&A2#c`8aZdNR,Gs9-n!bI$T>1mBI%Z2U,XCohEWOEX\ML8FX_Bmn>!V`@2^O?b`ffY]\5b#";29Sq7T4N)@okmh2PoU>Kj-"bh\Y>G!*XL]C]@CJ0[Js"H*HA$n_$,M1%3aVhYd\DCEo\h'VlX'$%S%2rJO]DcL]&EID!*EYcA8!e4@_aJScRr%q>mdJ)/8?0t'n?h[dN#fl?P]?2/a,;ZO?#HL4:*b4pWW?F.%]ts%S8^h#~>endstream +endobj +22 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2925 +>> +stream +Gb"/(>E@Ms(4Ml_EF&]+fo/O$e1JIF0gX5ug9fUHgs/Q!A;ar5/5&o5*i&>mpDd(4>JA+m53sK78/00e--t[/#DW8gr]=Be]H>$%5B>Z``Xfk]-"6]T5)uiG]!?,fpe69N40J&+@/75U!X\\h.`KXr%kPPN$VoV^_K/rn_K%f#H]Tb&G]RF"eUEQ>^+\t)\Dme\IVAsmFL3=!@m4!mU5[18@JGD;KXcS7Q>3q^Y2q_jdYXB-HkXALS(m7uU54_*7IWT+X!E$qWjI-_j$ss$+k@\1;YXp7H\;;6<<-Z^O.si>%,'OT=e(3h3cog@7l>nPBPQZ5i,PQ'9,AV-dnEOVS[>GcG-u!'c$Mq^5J3F5A-?I'8JF3q>/E+QT?uLKY#p9m>:p74EKG_<=c@?m#'Ngka)g1nU)g!Om@`.'FM?9ng6\r'c'Km-CBhlr1Y*5]DDj6)QJgY8IJ$uOV^]jKrXGZ)Y>cEWl$_J6[%c,)f?KdFa[^]n"a$7KN\GptQ*>NP)+Ze15Q$\IRj[NaTfk'6;q+Y0RKHR,=rW:"8^5]Adtd>MO:ra'nSM.?IaA`:a'A3eWH0d%FP]Z:r%*$[=dKAOutc@F\"Ks6g]?K+Jf(Mfk?<&n9!Le,!/>X4Nb3N-I#L,l?A4MnXsRYP;a]nIb\Htfr/9iS)-.`E^=.LVUoPsC^+.PE>tYMuM?K3+ZJU"O'Q*B-*]3/M8T_Pa@J<>*9khTF):Fg^\GP#Np1SlPFg.URNVlXN[ML`KFV^!ci?#jB2TD?!Gcm79lm,#bDa:E+Yf(g07C_*E`DdS1'Rt+nFgY3N[kFoje_K#]-$dRh<,K;Pp\>Q'L@]17.qI^k(Z^i!l7QZeOmk_Sjr2Vg*k&P(Du6MTb0VjXT(rGlKNh)&(6t1JW&=ph00P;#3(EZIoYIo2:jHQ=6L@-+uc>G"^4"+(O;ERGT$Lj!I?^^/Ub7''C]:OogqOh`!G84f]P58p%iZ;I73NpL0L?Q1)7tPoJ"Z[s@],7k@#"Lb!Zh[ejY2rYkL_^4ck#dU%GeAp2LAqhCNZfX%EZ>-pK53p`eQIc@UO-YgDkjLCm_6@UOPhMas*-p^DG$^9Vt._KFl%6]e=@4i42q'.=M"rVSB_P'l3T"R3YOJ.'0\@XTd$po2h`R&$J.('V!2Jfc_%3i8!>`9(TG+S#^>4:8Q$jm^2emfrFIj0X87"n070ZhAsOtGTIN3P4fAWW7p*2ISL@f>nq;-neFC?E\k=2MWr@=`LWJE"FroKClk9]8UqqWsY&(c[8e_-g5=/U))PW>;EA1d`6P58at-C,;-BCod3/H^'>7dO;%E;k&RLUEB6gLl^Wi^+@0=B;#NX+9tP:HF;)Vfe%RmX@P+AGR7cJZ7@`WU8uJ++1uAX20F"ECFNj'f!$_a_?$N3q[8TrlTSbLR`mSZ=L?2X8J:5%9EVn^@;Upe%Wp0pM$Fa314su?Tl'dg71hA&"TGII$!-^%D4V@jqp@l]'\t&l]V"-niQ6g&P[9>-p&Z5b!:lU;B;gQa=,Z^$VL13bJ.AD*)X5i=/G?a>$]Dp$MDV.+B)TR.tj9*Thn.LM=#T_]DLLZ(o5KLZ)mhcnu)F1Y\(Qlhqn.rZ;;pS"C#dM]hNg!dA8k_6e#umdEVn"fAkW_$Te=gkkkcK$KX-.!=1_XJ%&87/[s#r*>OZ/TlH4B=)nQX%h?)D+pI+=%f4Q5e\R#l'U8qaqa'5;TGfs?)R#-L%DO9;a46[U*(RdU?9sL^4e=HW3eg].?e&I/$Hc!6p8Kb/0?17Yh&?'U"OOF:;?*.89VM8K(JMFQ#b,6oc>7qNP7$6@C*R*)Meu:1&?ti[7G@a%#&%bScM_1eWA1An,1n8p_(PXaV0>\L%\]l>kqlV@AHrJ++AZifYhJ;,+[WeS?l>4X7=sX$/ca.W7Z]e#Xt(E'Snm9oofacP8r][?0FP:7;&7s9R;.j"qFN2FYM_F:$BBS_.X'"u_e#M;?c#r.KeIGW3:,lC+f9mPIU6$jT$i=90ud=C?f^b;9]qqc3!fot:\o%KM$6*knH.N:B$rL8eB&#\:"4pt:IMX4!n&WW#:fI"LL3NoqDphjSIPmb,Y8e"AY$qH_*@6-3;!k('))M3MMXN_,T=b&O%hLY<59tb;c[Z<-7*BQ.$2G>)DnH!p&)@n&Z3#Hcq(Zpd"p+MSKYTOZO7]9PZEE+21ZP!W0nKm`T1l:j_/t_*_M'cHRMUendstream +endobj +23 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2894 +>> +stream +Gau`V>Beg[&q9SYR!n1tP-nFt#a!hsmR`nFEDDsu)l8#*$VeGMD2M:aaP)hb^B8uhWLG2Zj5e-5#nf$2$td6L5aVVhJ(4JCjoE/TiMT`k."h%!cnG`6r"a`#TDa0RE0A-bKRaFJ9+F83"\V1\Ve7:_a#")>AJ.BT!Yu(KN!1toBEY_pS*+EMd`P'J`%f.[UI$<@Hl+,l8>)1P4;KZ)!>@:E4e-nYi]I7kj5^k$s.A2p!WafN&s4<&P(k)m5T#grjrbMdJL#k\"aiFJB,R11-@p!r(kajgM'3-lZk5e@"UpKjSR**>?(5QLML:!C$W1XK@@.0Aj;]*BK]og7KE<*di9iThM;u*aLQM1gLfp^[KhFrkPe)ajuC\"K\l)1\?9U2Td`GJTb<%*H]-;7[G`/Ls6[s1Sn<+(e!]]Ta:h11?%)R^HPk*FkJsEA)1sD3`pYIVa@0%\oIFK*8Biuoc_c0pnbPY,#S)Y'\!0n"!`L.YoTTKX-$CfZ-`r1LeFTXNTi\Unq.#S&L,9hq/qPriX0XlQU9VTSJ:JATTIPnuK39;NSGAQI%lL&*HGY3*_+>/;#O[46B'qZd)X*WbI_"6Y(B@`tEg#moj:^qBCnM^@LKVKj,qk.P^/pKiF[`P$RN5_d_SlVf/sdHOb4&CteqRU.[!gS5k,h0i1?j`D(A7&KKbYOZdp$P\N-+2TiC?SGPW(efSSP/QX56I&sj3^=T3+&)G2)]emB,`5H?&lMt+%htQK+U9kp@TSluMT'#;Co?,'BF`"o3LM5,E\Y*`,T.8ObN+f>Y>S;/eP;^BPCLqXCM'&[*0S.B9q-;lMJ3k`q6^(,pcSENm"ef-'A:HuRf+j?L*gG6[4F22BfStOJlsRtk%S/;5C4`?iXTOR6tEUT]GUBS:Z@\`ng`7t%r'K05'3mL2*g=GUhAbO1,gs@k0q;1166)7aSo;UWgP_'5HnA+.739RjhSS$PgT>f^H77f=:`=7lFO-!SpDk=5a0OgIdJ@UrP0b@:Aq>n$le?GsgTi_)N"SlsgoHR3f8oYD$;2'0X\NSdGUGnPji,Y#=kfdd&8U[N]GRn"M*k%U9mS7Dgei7Md5.jkg^t9!idA:s=$&md`lT.`9n#gW=/Bp*_>NmcCnO93o>fS9EF5:@_,YX#Bk*$198+8U>8_VCkX0ec1U\d0]q^^0aB;s1W&q5QGF`j#A?KV$,k!qu+cCtBj6qJ6!V2l[,+1qFTMgGIOj"7G7L=#dJe1f*H2m(CEsDl(A"Rh$/jVfCq]%P[KZ9n*PP08@ID0Q-h`&2i0mM7ul#4#Aq^3^2I&#=4N3&Gc^!PdqjM9'G2mp9.n968B%-DgHLJ4q-G")D2!4+%5barf^%:iA(q2?[To&g2nCX;a0QAF6.P\<&SYoH1.D43V1@A3Ql@7aO1pt)"90oRX)JlE8sC&$<[]3e75jS`QeJ)A;)C7Y`^@!cW=aI6UWr1#-?!0?TqR]>gY780_+hp"=KDs4$$Ml9.0b9l7\FVM[$r+)N*::@VV:n^$FO/Epm7SgH'bc[Qgk^F`kli:>ZA*'j&Rsl"\6t+,FQOj1VF*8i=3Sk;d&WYO)uBnb/?!V6JgYd+aendstream +endobj +24 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2717 +>> +stream +Gau`UBli&f')et?dIEMQA$6$oioMg4a`\k>?*YH0mG06l\MtRB`"A%:acR46o&$oBO8(Hpf%;,NpF:YLbB_h815fH)0Y^V5WIdV3.XH,kIA;fEBTA.:aihu]gICK^IK=%kP4,^EJ(F/,_8To?q_-j4$H3P*>[iSVSA2J70%#AoFFXqhgX6%%c!]hNtU>mR089Wb(\"^"c)+TB[EeQi,#;/V6:+A&@WN%Om%i%5bpf,kfKN#_)+LF^m^[s?A(t!7jAqL0KAn'jVX$KM(5:D4AXmV.a4UQe]V1(/+?cW"KpNI$@W[@Eb#8B637cEk;gu5dTL+#Vt;+GR3?XH1/o29/[Q''n.G\TVui'$Kb.?Ed+Qu,4D\i+l^GWo:@,f<1,A^U/@qM'^=cEQfI2JF%[elfd?+1*32H([l6qgH%D&iPl2#uN(ua)%i7Yu8]`h!oAq$L`Q.Fe4WC2?(b[!'ql]q@4\j5@S(dW1l[<1tS58G%41NT=/Hcr7P'WUALuWFrG[#?\bs,C]"Bn)fi^uFKG%]AA)?\FH7!RMOS4#GO4kJh)*G\No>Hb/>=;!ZS5MCB=]fK67k^#ar/SdNA>XaX(WO3*MKMdus9lgGE(RJdYpfR6+U.VgCDB.=Xo;2#$:"FjYXL1%=:[=qOb1q2!o-nlkNWC0*$!BGi$O+W"Q%]*WTM&i;kKm#gBIh4hN$M`+U-`@$Ds7CZYC;jk??WjKSaj3<^U+9\S?'s\BNJlV#b=_/N(:U`&]m%Y>IU+f\pS:B`lV56_ZCHj0@Q2rDA\k$#)LC-gE$H_29>[%'$d^3m0=t;49k87M2!<3hDu5>E#QkY#2njA(`BmZ'O\o2c;lN4gl1?ijs\CaZP.;>NuA7"XAROKaGJYKX[ppEt7)N6o"lo"EEsR-u7rV*heho\q!:6;ic]P?gt5"$-j<&n"a%:)X1R/RbO8:mFah3if#KT'"/GW[jIfGIIj-)Ql^C#lCbR=HhP^SBC6`?gnKWY\\kls#V8@4qP&Yp*/:'s%-,5e_FEu1)VR2%cL$.(m9-bnG6V+@ch;][fisNoh#1mTRdlap.G\6cSiO:Kiom#)3)AW'B\ZQa"O0aiL+jF$;-QiPc<+`*Te81g$%8d6D%6NMZEc2OcYA;+E2&I6m"HLjg/<$aKM$54KW1>T[C<_PAV"8,ZMe`Nd.K[Xn=bTGA&-j8#O]e_DgF18X\-mr9(N_QU\+#@k0&]cU7]GSZ-'VY\+\6M9S$d(U7SkrGe7qQV_P^eEhjKU%B-6"g\_tBRMRiUu3pF'O3$r'VZGH+<@1nQr"hH(Pqm$I0#i;-`S*RuRkD#am1tRkKeLUA90N:IZBAr"M&Tl"Wc/Ri!Te>m;pXVrr:W"AEJLm2XC6i+LY.-@t<%"AXo#qMVONgZsh)_NCO[OMlk%/If:U[Ka@&WWAgWV#"pM>K&lEs!ckhp:j04)))!/I*!o=]AUH]VKZB,A;-4>!A9E3!AU:B$M*LK/LQE-jWX%2[O:->HauW^6Tk5W,*=dklsYWhph"WG6l1j)h/]Z9I)RN3i$pfKrrgGI-nBCk5*X5$]jj+hk`.9X)fr/ni8O:J2S:5eEVp[eYl`;'WBRThG.C%E@Uq&4]kkqaioUlZ:r/+/r;MrLWc!I3pG[>g&$4rdq(F@ATDmfjsp9E1tTW5;$p!NuS(kTi&tQU"o5u^g)$D58Uf/5O&QcQtK82k?0SoAjXcJkHEgIc/X#?.nYRfOCtqCI-sAjmd"JW6VpR[h%jcg]]RkJIhNDnhik5!:kf3.(\mo5>_"f\Hs2q#=-/#:CTh^A[s-It-)ZO?YLk[fAO?[FsrRabIS!.KgTRk5'"[&F_gZrNSDV]bjj!"(:;A"-,ln~>endstream +endobj +25 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2259 +>> +stream +Gau`UqbQ!:&H;Y-MQ*U(QRFEALL3Sd9*d@g.3[Y@=pYKQHT;KuqeH!N-/sFCm.ti8T-6KqN[2>EG%SIZq.@9$TjZdhec5IZJ6*uJgnR+PN6Zt],%.bg];*3W"2YN!0a&&*>)pRQ38A&IJ93o0;:qQp@?ao&\gCjoB`V_^Z>F>D.cL=$J@MGgD4$@$nB%VC\W+6EY%:^#.4!mle5\"+GmD=!$`KtbG743W^CQ,,2lOYQLgsReJ4YU6\A_hW-Gqt`mbD%M7tYiKNnU:%XE'@iTK0mj+p;@C9;[7R<$=X1>'d3W_Cac7$INMg_h3.Thnkjn/2R;M8Kp.>;s@ZME5ItOgM]$ZZRj=@)8L$5/Yci;O`C@/7[GfTY"[1+(X,/]o-OA41';7k5-d:20@V%hj"U2BSC@'N"'#H28t\)K#^Xq=!9:9G9]9=pI>@ZuWI1?`DlB(F+OmiQ9imqN(l#4?A2:ia.JQbr)hFJ(%<<2ShJOP!,Odj.DVXBa2@*.mMgh"L=nM-Hb]]TJ3Dar-[q**iGh5=r"ER@Oa^T!arup1g(^5V,._RhsK\AD.5CR6[_aEX@l@7F_);".t=7fD$qc6;Vr@CEYZ1iA%kl3,lp/$dY"mM,Au^4,Y@>hp7RKVoW``V'[=H(!I4I%O)mc,\@&eKODb]-Cr%W1Ub);#+TZZedO.IWT>so3--#PC>bigoo(B>C1&l]L=YVLe&!t3[AnTk)RU(lnEDsOK-rDaYLB7lY9H)XpCFF;"1=!%q]/\!Xqenj^it&ncVnX-$V@T=Nr_RZQ5M0NZmD1Ka=oijKg[\8Z.b\a6$NSE<2H(M2s<<,3*HZYl"1Yo^MY+rnF!mUZ98Z=5-6WVoKnQIsTLn%ZaRR&Dd&r]7\QsGQ6oZ86sdk`/]eNO0't(L7ub\X\K;<6Dafl4h(Y*gn">q%.bZd;pk[qRlmkW/bV"7mD\YV6sSHZ,KQ7[%JrT]r[ls2T8(t@&Zc@lFEF+ZCZm_=tnS)@#,K>*S=8r')s$Bl:teYTVklUtqL&40#i,J1UO)#C*R"QPgi^8B&[/8"#A`!fo^ZU;"9T/7"!6/EO0PoZ6g*PtUjt\f8]kbN16rkJT7egVnL]dt'/=o\cqh?.8ZaKQEr^BaC7Pga7bA*Aubm3F=XFWj,0XH;I7)F*!V0k0(pX6Ud8%\=G:fCY@SQ)A(5f??7N3FcI,]h>6UZUWA*AlWl=@48AFCEleSOdGq8Delm\KFN-6Sj=N4ktN%<;L[Q>:rLXN*_1Z^YLCe^Nt#U;mE\1Y;Nf*?g;U7ELXqSrOX32*e:g<%YrU^@_8?NRPQ)7R;r<9S9+Is`/#Sm[sf@>[2o'WIHEl!DjXQM-32gH'P5hCMIlE#!!a`<1KQl1a_aLLc^o%!dQ2A3*l^"6A7IaFYg*]&J'B4&9]XPsM@,mGdOk!HBS]0N2^F/EY3'&%Gk)^hWPX2\h3-%bN5B^_kWAR9m9_+D=l/?OInh)44+<15e7g0C>Tf8EIhZr_5G4:dOL>+97&o!E=LNS,~>endstream +endobj +26 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1767 +>> +stream +Gau`UD/\/e&H;*)EP[aj#ngF;W4)h2QaZ,d(8Q4qed_3^0J[LGP9j;)P86ahqi6gZ43k]$aOnUbBaQ'QiUbhFO"pkD5IC9I-NGfHd!\941?]0uEl+NZ%su"@k8&'..!.E[fCMAe"VSBfM`;EGH0$Rk571ird=06\[1fSf]Q(Et\;/&!XZD%D<<4@31F#'D;,3LV"/V4SAKJ*e4*r76J)u8HO[#^/$b6PW6n[),.hU\-=1nRNRVDb+A$$r)po[JNA!KP?8%M,e-@L%[_bSgt(omQJ4ct/-L&+NO&kt7;RpqrqW\,Y/]$(BQ<^pcA0W_^Bj&_'!J@B[)p33F/Yu[\.8Z.`&h$2r1B2mMr]FhVmg!t7G>dkK8f-*CnVGEm6)!t[hTl^$()N-P0In8=!l!<*d=Te-H0Q\l_ZIa)==cE^OuH+&qtqOO?mkP6l5r=-7l0]Unc&g3GHjpj88&pE[k9=00Hc4q%7?2GQfYWkrRc^'N?)*TPee0$(36aGT(d,+0MUEc=t?6]pl+o6gYND>mFLb&[KGMNSSno,WNNkUDgEQJdscf`Y-Rtu45L#td`h)KNmU%s*>bTSlVb>l@C"-%RgsmW0HAef;j5_tW@@$C`B:Jo4h=pD-U\:D#r=]"@[$m79X.Z.SXN)G'!SC^&imRgR=Pj+fqLZK>erZRJ[JpGo*.E@+>-*g3/S8%;e')SS`1=FqM7;B5'i08(98UCn#_gW`Q8KIQ@kuZacm*/5am`HP@PF:U1=P,,9X4LDC,PtZ\ufNY"4p=sF7T[;XhC6=?=MN#!PaE3#U@[fL>&5'j)"6f(=1r8dn/5%^pgrPI/.7=XSs/+$'iH\FXNT5Oh;`mMZcTqpiTm(VJEl-oJaj'J"pjA:"W,'Ii6I6X%HEFiopC!U@mEL+XIWk!`.i9C-h/-C^7!<~>endstream +endobj +27 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2586 +>> +stream +Gb"/(>E@OI&q8_F0u-)d:6;;@h%cUHg#g^8OJ_9^Bn9`#BUQ<>Q6hl\f4g-?pWel3pmu:[IQ@]?<[,d+I[iJi*$"msc/3UQnDaNFY;gJ;#%QRTo3q,,?4P9[3b+bF`/YAP$c4mqJAkjQ-9gY8=cUET:RK>]&:u08DA7c^\h&da*eBV2IWEGkRQbbD3-OiVnfUUeHClLW'*@>5gC*(&?g:hn\;:G26`(jBf5T];+%.2(YcCO"jNdh4[Z/>WYXi&"*lLL\].0SM`_5^rDtk+eI\EAWg1pJ:jLWRRe7-lRmX33`,<(pR4L)]*IP$b3;@1ic$>gp+.i;k!l\O*6>jiW526?b*\*B#_W2'`&F!H0P_.I[$8A-X,?OSf']FXY!fhVQtfa)N?M_&dWP,b=^\inZSkk[9/W;OI5^;:A`jfI(uQ\,CI@^5.cOtcYK@ApK:Z_!CAPREm/+Yn9e5?.!XCp5Y$n86`t;5I3jpRElgo!+W+m/gEjE*!_l<\g.9G^o\'KTTM$ICoGK$__=e>"L-KV5aeu,q=W:f89mtn3?Kb$FKTB=B8@&(nWfA!GU2@j,iU)<^0e)$6'cg4]E=<*iaWuJVW4.M%[1!O80KUN*Uh9/_RB*H*?j&[/0iu-[DO)0ur6-%D8L.YPk19P?52FQ9&6>CRB2s43Q@rEn#8om;1p$5k8*QYP6l!,b#9Yo5`6mh:%iJ!jUC%K3E>d*.09rA+1_X-BE=E??Ao]LbOI8!M*jWJmR(He&B?)6.6183ZgfVgE2FiRt/?[&2uVn2/0XG8J_;1AD'eT1Uo''Q+Vm/F-gl2$$T(U=V-6`]APK=O+1$j_a%XX12RS<6B$I"nm[8L_*io:BQV')UWGp#OK!W9-bPS^mumT`,bsHlObrHK@rRj$5]LWbLO,q3;F@_A#^G160[%tIHAV`9\^VA,>;!ijPS-/PaG&!qdE_reIWNRPdUFK:(W2KLhEDo4,dO\,=GU,1"[+/_8>!akQ)2E,8\(O^>:GtrN4"5Rhsn*7.0[KS5R&4R9T(pNgPO^FIqq(U4NrjgK>_Ods'4f='oVDQOY%U#+TkEMj?:4mkF9nn)4T'J)=X"CJ[Jok(Cps"T($V*)GjD-B,cqGi,k3DiVB-#`/V=Ep9P5r##PKtJjs*E8jme+BWB>EkI0]'f(FGq]lJ!Qs$+l+mgWdm)aeB*J:3k5"G'G=UUOEe$]V\8bLF!f$`pj$mR8JtDEN+S=m%/jO0'GWU\<2b;4rR>"0%T\P0Yq<
gs)1,#_gYa8:VW.j:?qEQ`@U!?h,E@0>39"SM]tZ8DK+_tq'G6pLS;>9&&?r+Q[:3AaU^iRUAR)K@D'h58ejAaoQ"p"e+0[gelC&Sg:l?)L4>5>K:bb=5Xf0Sg+&rGl'/^'bGS0M7D\4%Hk3bm;>o-VAkS]N4?a03M,0(q!]T+B#L"O#]'5d70BYj14]']'0WXtj.T:S'mEo't>>9?'75V#XiXMnTXQUS-*h.%na&EZG-U_U`2!>d1E@*oG;nO*mk:!iAKmlMu^;q6Z*X;Jf:ilj(Vo-,m%ohCK/CcnS-FBk?3oA!]X=:&bH8`haP'@Zj>H3*Pj\E9)R,n$)mj2p&78oGA8CG#qMuf<5N.a/GEH^9Rq$9D9o=n+Y$b3\S5r'"nesaG6N:=olAJu;5r<4bgc%(5:j>mn*QN5=59/o^<>\r8cIRP*V.71D@h=I3s0XQoPI\S8%j]s9t7YL(^;^$V2\JY6FHWN2=f$cd;_@j,J-`jS*62r.YA2:d2cc$8<@ZAFEaSbYEPFa.V0DNYuWeY/..=C3EShl?PqCt5p89HM]`g[3&7/mR"n45T\/::6[')DHTN'#'"](n;=H\3pPiYO5ihIf77Q:r;CUdk:DBl1Us.O6RL/?mROe$-X(:j=n*/JBeT=p#,;7b1)+,(R4gd17_K[5gT/"?Yr4cXG%kiIBfd'-Jid<.n3[a_GQ[*ZXP\AfeUlnA,BD;,eL4o=Z]$Ya9KhtrS>>4endstream +endobj +28 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2150 +>> +stream +GauHM>>s99'RnB33&a`_P)"3\2.+Nr=rqT_O%jWb*:r_i\ZS,p8B5lWD>C;O?ee0['^hSYgKkO/ncN5.Ag+I;'aXp)bQ*&FfBRrec?q_TEB2-k/&LR=[nJnYkLo;Dm3L[h\\#qC5HE.$Q]ete/]tP+)GCI1f,DhGot#-!)QH?+-c>ghfX_WQ`.hYM!qq)#)L!%k;))V<5R!=4a:u#H]dK2/pRT'q1:!YI)^lqLV\0O4B#1^l*jA/?#4BBJ'W2/#%3Y#Z&)p"QQ0H83=dEZ*169$b.*ULrP+kULRk%^S"PpdTG/e&Pjk7np&>inV:LM-(N$T*R9.i27Bd#28,@qM6B$h7Q5Oiq`2F=W(#d/*L"lXXnDT9^t_Ko[A(UT2>\W'crh?dCuZSo>:tY`)C!DV/hWV)lYGK00P=@60mYe<"sjb]Y3%+#*%6O;GE/U]kd11u,i'ljAb_.l^>M1-SGhA-$6&1R9bC_-#4XoS[R]]`G<#d3Da&(T]@"![fAMtRnkktm`8iXB^b*X-K[>f79eW/uoT7M2[hQ=m[hDO,W5;W=$;.W?1D?6!$XQ7h6_!&RWdH3T5L]L;qiW'F`^c1@mGHgis#:iF2,;im1ICI/C8NH8*M^LH/>gKqfR1mmd%H&h/NKJWc,#ZF]SA/>1J#*=dFG@/Pku-Lo.3GDkH8F5@ShmUbd-eTO0bE*$iRaC9fla=;I2G@4[QE"uu5Fo\o_T`tPW$lqen-:L?(W2NKiGnI@gr:7,#R7@dAMJ;?52p27?&[qs+9Q"bVtJ_J<@E-PqAcF`";09=YkXsQc+#5Bp/-JU_d.b>8SrWBaYA*!~>endstream +endobj +29 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2772 +>> +stream +Gau`U=``=e&q9#Ik[_k:Zn$gm":!^1fqr8U(eH&gVipTY7#i[O+@$U%<77u-Sf(eGFB[?i`F&d(h-V8Qa+sf5s/>m?Z2f,6L:u9D-1J@teNj'GBRh(:^_o1XI/uiFO<=[5.8LeMidr6m@QurND(H5;$`o?k\e[*simk"t,Z$n,M+2DX$CE+/&j>77M?Rteq5B-8+ora0KMZ4c*gZ7L:_^dIBceZ2;=@7`mj][/O0.O:RgJYdM.B6r=bFZY9a%pOpi!^WHk<@-"O>u02ir2hl#aM5pH9n#JQi$9e\>@f5M-'%TT98?V<;eDCkaG@@05Z[-WIo'C1b@H^uOTNUX+/l>ucSTDsb4I-ke=Bb%J[m#3B8#g>Xj.HkB3-Hu9#V.mVT*5\>(2GF2ZGGiRb5Oe,g\io10VGDCgupXa3N]aIRUl'QBO.RJV8dd%(,eI-i-UZ)@(#QgdL^inl:`?dS7.S@ND[ORS*\gLWebZ9&^APdhkpikP6SnaW^(e[`/iDq==*dlNBSE`Ge0j0l^E+-V"VNW"^QVIJC`[RdN,XDjlEfJ=#f#V5:R8;H0qcY@gA.`/k]ng9^kZaVW##UU;U:L)B\\u19cWSNj9!>DXMlC%Ie:Wg%@FR^`gDWPJ7CJ*FW/1,TMS4:g3Z->9UJf2)4GjbibHoq5,I6F8LIPAdns%mm'A*Tf-VI;G\B6s:+:*)FIed!,(Hu]Xhj`h_9lr^TSeR^L6mo4u"B%bm7rn>Fh^G<>g>n[a,F0eB^`u(/Y7Fa(1UQl9&V/(b=0Q\>n9aT*1O&]hhPt@U7:&?2/$PVDZ@uh7;U\ghEk/2\Un^7*Gc2`5l45Q.PQf\FXlkh%a#D(jRkn?-Z@Or4+UulY=hu/X2;X0tRV'a*\jS0uj--Y=b)%e0D!o/^"-"(O;rRW\O;UQ,PlWFZ9AsU,5MOY&EU4K_Y`pb^#S717d9?n=KieD\D8p9>.oflYJF1@"rNiT$.Qj0JV0=]#uX]-aCtkRgk;>cZ%8ccJqK>YBPZp8`!TqkP&[5,L?Hh8StR,?j>)ZC/cN)9.'MH82QIIfjP44MPMENU":*anM.f$uo\;2_%1dj1F/[9cS4H,E)Q9l>;&HQ8KlCO,u6Bg9]4i(=*DIuD1t(0^7[.Do>EE&^.g[e)PHn2n9a]1ZPXrPmNo2M"Be^uF;mQ!F0k5PZW:i%%T^Qd48e;H@$HI%jmV-%Q@UHXX[X/%=on)B#[rrNYo>s#do#VpJ(55DAC'(;!9DCYK]KhZ7+p&Pf33c)7BnJi.pV*/ZCYH8f;\EMHG#]5&`8f?K%3J2e,Jaq'C,aIb?oJ4&FqV^`=]7Ah`eDZMe[?A](#GOU*]]^bF6fEm]L^b(mRcc^>te+1^%rhdR1];E(oJm+08qNMDRfQ\jl`Pp>m86l:WbqIFgBG.fkM*6Kq=l(WsCKqa8!%OrRu<#OrK;9Ic@$U.-FRsp3M6(D@,qUg_G$K@&/#j0F*5MIgG(epOg1/2)l]PO"<"JrX/r/DuB]qqB*E]&''2"G<\2UrWRQ"ZA>jZB`RrbS_6qg&8mD80BF[BS"g6.qcaN?M')(&%L.RbW80Q5ZJPfWV0ms+rm9:<@V8U3m\WF`1U$?'8CI!o`26s7ROg!!n7iWAA&U6E0W)]`#u8J%pb,k*oT4GICQWtr4dUjQ\p/XO5f2[i1:;W'eDe(C+o*NP8kp3*R'MmmWWp;SL:h488*pU)K0nTr#m>EtBZ2BkT-?JXG>3;HOQ-ciJrhKG?/J%&bq-Iij6]j.[X:!qDWehel#D5m2o_g@+Jf!ZEF)0kX27Q9#ZEiS*@&:'SDMB0/,miJ-u0;"nKS^u05FA9kSJ'e3LFuRWg:;=Oq>dO$[G(mYFGD-(RBF4jhn.4:rEe;l7DuqgX<1A\\F^D3m1g'$d0R34sC/"B>?ESD#[q=c7`cXQ$cMjfV`*P8BLAcaE+&7h$LEHMM?V`5?BOsuZ[C,hp%Ho=U]#dkKl[F@;eq"e_Pns4<49TjjrX\tIf?rR)n)fdDFfE-jhJE&^Anf5scr\XAC$>I*)7endstream +endobj +30 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2137 +>> +stream +Gb"/(CJWtM'`HlqEJ65I)1fhOm)5=BC;g0/F;OHu1Kk*!'iIt.o&qDLB]n"9F^@d8$Y>W]*R+j)pEoV,n\+WVG0PQs+"R?f&\s/c]_fd#ETga3'1>+c&GH!l^H9B.Y+buR-#I3t5a3S*.j,jU@Vi-59/tri866Gl8/jN9[c9%H;@V5r42e8T4&d]KZ/eU4m^0YMY"`b@bT`@9$3]=N\Y_jL?=@ou"nYa%GK]\mT/oBW4Z[R2;7=^P84G#:C^q%JNan`"lWFMVSSFD3%+6GF2+Ado40!f:JJbX>8aug;#dFE2,1Z"kkK30%[*O2r0nraK>(]O;@uppfGL'Jn8^[4A&5?j&j]@"qB"R:?V[_0e7)9F;Pi't\qhO^h/1GFT5mKW,oYU+.[G]T\n4lkI"W%l2=%Xo7R0oIruMEp+`3>E#B+W;tqQu+[5i8o$M>fu&)gBN.B.$Nem-&KTW20G\De3AEVYq[#&m1[7`V@b]2Hn9*85jKn"5mt%`gq]8c&9m!pGjo*/KGF$2q6HUdC'o&%NH2>R,'[ZgMY0MunFL6Lf$VJ'g9uk%S!-fY-Y_Z'36P(0eADH"1!;!]l:e`FcPEH/bsakQm;:J^6`ji(iZV(NiBY:W?_rtEX?P1E3U`(rF>?N!Eotad5=5bdd&!jm_Snj+)ooXdttfor+-AXksGq0REQG`OCAfm`>(@J._Go7?(C8La]20nI5(L9$s0,`C!d]RT2bPJ:gNN%N^MogUAm$[h'KU\,uRCUH'#rjXd,sKa/$,$oA7lmk;/RfOtal-;FZk72\fYNdJ!8EpM371dj^I65c6T^0>!@lM^X`[0Y$IUDI.3Ec#a$#:XT,O]'k475#(i)_@oQN?Fe7kRDbbX^u7a/bHpp/G^\_dBXhrQ6]E%qjM&M,C7@3s,K"ihMYaS\YK\$UCS=FOTsZol$nTrGXt%H39,am5!M3cXoQ?>5Ne68GNEBSnjM@]HC,@*5UIRfI@\7bI]H`FWU#J#hVNkpc,dN-:+&E=/!ek_@irN3@0U_E,7eK#&_;*5*d1XSTR1d#Gdq.+79>:&ctT;`fkT(-mu@h.U>HA7s!A1EG6LQLmpX*:U]CR[2lt-eCG;dACZ+:q>j6U+idl3(XH:u`gj9?arL>.tQ.FpPr@B-4N#Cpg#70HQ/OXB>E/<'EE2=6_j/.Tk;3+0N,_`X-EM";!A"u:,S'hT4UCVDZup6&5GP>VSLE'dn>g?jg>r2]D11g/DZ7!)1Z]gk=q!f%FUCD-,LF`I%81t^Fgu3X\iqV=g8f"q5`g@G,pLsRX5a<3XQn>_D#J@%oP:%H4(WKI\1(>EE$HWZA^]O0+5=otjVW$\j"('4o)Np%OWAc!-)djZ(NN`i.cZ(ELDl:h@.CES&te1Me:p*oN_\EeW9#b%?o2pp8sgP_KfT.(:CFrRQI,FDJk;-;~>endstream +endobj +31 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2380 +>> +stream +Gb"/(>Ar7S'Roe[3%l6!@V&,gUo4=3d*(";bITQN=('+-/2N;q=ir!M7ec4m-rRG=`,X3?BFCUP;j;MZ4I>LV8dm-IA_h:5!-g\^VnXup*6)X,";*=OJmZNmImt]XM5C>F,aDCB&04?6#cMC2&rAJ=#\RCn9I/T%<5:LkEQhmj%@fR&Oi5_h<,0Ztj@lRa9eT&3Vujs_&o"=2&2%*p=#bMcjA?!"a3c8prqP$"86@%!QkHM8'-J0d4Am.7+?Ak@-A-XKS;d@u[G>@[Pi!\t.#r$b6D0>e8Q*4;XG09]]VU4E8.dGU7cOXl]`$lo6[2n]9Tu#K<<;RCg19@uS8o[gZbn0:RO2rV1PM1XaqWCf_NaF4(gk)RYn]F[06h$:\AeKh1r(Ntd'V>kA,Bn"bKCS/2IQ.e4`sItV)1J:`opiYr8#:6*OP(,0PK4BKri?Kd=).r(*H"3Ukg4b'Hkb=YnRfu6:UB,hU,N1k5qs^7#R)*T.,^*N2=>0de3P.)@>e-oK;Xc%7J]$bH,5Z5l2jVTUiM/SAU-&:l`7Gjn.8ep-n=U%o9_-@>;#tXC$QI2_;4R)P_0q&WF[Y7%u&dL4"!H`OF/Ig1;*3q:0gd"1%232'cTR:Epeh:OhIhLWFKq3-f_$Rnf<0fa*=@3%uK]`>s@6qrah6F&V5G:@8^\6stUq)Z0F6om:;?H[]!0`ftFBa5B-Hq_fGi?6,eYNd[LmB^D$$P8.'Vi*Z!PbWbu@I$H8t0"M&KJ1r\VD.W&AmM]Y7%Drr:'e%&%Ol,VWT(fZV(-!f96X_[,qV%*lNl,K[f`d1G-@5qYa4e*fD@S1jAer!Yl66F-0D:']?dE"5[fT9m]Lhq"['2cq(l_G9qVjjtIf3Jp]>u.EQqmmiUMnZ<3&A_B7A>3>r_J0-R%H(@=H"/AW2$^hLG#[r5]osP&mu$/MR-kAWLM>8kG+CY21m53J'0iG`f+[q1G&hKhB/a!m3KX1B-hk)/U\Cj*R+:NFB!7?'%"@^E;f:M@=Q/]OleI2Fdtj;;%cb-n4()@Pe7O]C0WBJBnG1&Ht1RJ_*3Q5J`mG]V(Q_)?kRWQc"VL=lc']dhkEHETYo-*9I_BuTXLVMR&YGYZ-!68Pm..7@T8a0"*'2!aiOOq7QO=gqI:[B$OcOP6POj;q?!:P=#_nhfQA,%*O<7UoDDLEnb$+A1T%lh$?kME(l9.c_Zn-h$=#b'B@7>G+`O0!!J$;\bt7:8cH#-uNIYc?u&Mask:Bo3VH#eW!uaJ[M(XO;ESDc`egF0f,ZGZ+@hfL'A\5`fffF-tkd):-?ZH1eq;R6TWc=+YXJ-:HLhH5Gd6E?#aA9?9Uqar#A#jOe,#"8=!g-i6,YhgY'A?<=2Ie5f<9!B+^rD:7#1gWhUQO'I1sAltoGQW7`_7c;57=+ND??I-SRh.5LoL.m%,W/3cUcIrbQ9l@5g0n)C-Y65^fVFcA/gPLkoX0CMd6H7bE@snr&r;BePYoag.F=6ZE!98N9h!!lCDsWDE#u9Ug?M;)(Kfc'HCMY#hFdhHp5K']D\fY>4r'=h]-9"-lPXKbcfIr/[?F3O1bLRdO4uZ3*U7q'8[65rqFnTpZ#XQ+N!5kuB$oup-=Ng(56TeA'"lBq0En_mLdVsniXFk>:I+`^qa41NWlpl6c+0oIK[ib&KMG2gI%1f,A*u'cs:'R)>&3*D='G&%9BO;!..J#'R/d_G]PuC)nbGrXigb]d3e"XX9R@+c>Hu;V@-1K'*Y7$FOr/_37_Dd%:OOaQVGh@J\^omhB=VR&fMeM94*6&^j6H2)',`-rPi_TCZc+EGK(nP^`T'qtj2MYd`Ank7VgO0`orW@JS;p,~>endstream +endobj +32 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2151 +>> +stream +Gau0Eh/D%-%"HT1_3Y2U%TP&_o#JH![9^66O=a\#DA^_:a3bO/Nh*MI#'YRq;=\q"-?5/8Z,s@@r^_pBA;5\eIoQI:Y5k`&0_dna178%BapEjf#_pgbh`YEeY%)DnVQ_&`"QGf#\K$c$01'9+\qEpmJT(d$[]k9:@bEZI+d)9[?&uK!^MO7s@5p"l/@Xd)`3g!]-oHkG#R<=tK">\ccL2mfklT#Lq;%S@p1'-hfV/r3+HC#1%Y>=GR21Di#_k5SDW>".P?q[oUPs;O(d&%krF0TH!G^1R3eYnjbG=C&e`X(B;OrIe=IBjPaE&"F\1YHEJ]0"C5a%%cd2NR7'6[]NHZ1[seO1(p),7*4]+c\P!&a9dE'`kVcXaJHQd$rZ9rVN`4CHr!#H.[YeT4'c9Z1+HH:t&;i_b4bf50WYDEl%5$%1,H`cD(!.\5f13n%0pI"KMEd^Z.CoBe'/YbA*OX(lUAfaaCPaKrG!;bj*8m+O(GgGg<_ClhQfH^22Rue\4pCUgkEYp7,s]n2W1>&$A&P#$Ua\Fh5n;XQc_U_-D_4N`nTM3iZUJ$Gh_GZ?f;LKG/m=[S!45(RqrCHc[YTVW-D/?6oJ1,AA6AEC#TZ??re..^j:eA&'7SO]rMA7D8/^&D2O)2aDg%Oc6"_Af5ZI>l2B@(IP3q<292ug$G^,,+"BKc^o-"O:`tV2?=KORD%GGg"p3WRU&>IrGdU3[CXmc,l@^Gbq=8Wm4i;<$K1W8mZQ1oNBs/C@2rI8GL:%T[OQ=%<^h8[S72![6ho$U)A`et"4`(FXGp$`>uMif\XjFjZBg;sZ]m)7`KXN#_d5?B#3b!VV7P/@lNSdOu2q!f[mdHpHMs':`JqgQW;ruo,n1:tH!n3gL,PMi&k_)tgjfVi"?]O#pC6*nn#j-QP*n.qmf=/-oM6=BRW5MhEM^hELk3.f9AqX4&hqX_35cBIG#UPZReqF`AZBKu=oUt";_cS!L;B:L:"YA]2_dij)ZO)D+MC3PWOki'GS(*,+J?;HWoiB6?gOts^Qa'mp+F%X859=@X:q@\fiY/'ceq/U>SCd$5JY4KuAY:93B8(CmrAlX=c.oLNfb'$I"$H[%?=fj+uX!sggA^jtb_pXoMA2T&_p!"p2X!ucbU,A9POYipL=\Z4g+X6Kj),noWd@h[GtW)(Gm>dQR,R9&T>Opa_PP>lH2F+#")LhBN/<%],o-MG*2Be1K6TNFD)D?47qKdDP9HEXjU^o>MJhi[`XmmFc"#(mOc,V^a;Y;^fUX-:8kV.ZJSt'MF>"d+TQ*Q65O"MM=hA8E?>bWk3JVF,>@B*IDN<.AH5cWb7PInigQF_*%EMlIQ`;aX8`?-OlZEOoCV6SNQ]Y-NlWq#;%Ls_o`?cLqGS:(0d1h_CTrYY?TV>^1(,]Q^],JbOG"%.j$%X+I^@:H(r%!#^fE1]Xe%CP>$`#YCrghdu5I,D'$C3o0W$77/Nk]T_BOHSN]7~>endstream +endobj +xref +0 33 +0000000000 65535 f +0000000061 00000 n +0000000122 00000 n +0000000229 00000 n +0000000341 00000 n +0000000446 00000 n +0000000651 00000 n +0000000856 00000 n +0000001061 00000 n +0000001171 00000 n +0000001376 00000 n +0000001582 00000 n +0000001788 00000 n +0000001994 00000 n +0000002200 00000 n +0000002406 00000 n +0000002612 00000 n +0000002818 00000 n +0000003024 00000 n +0000003094 00000 n +0000003375 00000 n +0000003513 00000 n +0000005359 00000 n +0000008376 00000 n +0000011362 00000 n +0000014171 00000 n +0000016522 00000 n +0000018381 00000 n +0000021059 00000 n +0000023301 00000 n +0000026165 00000 n +0000028394 00000 n +0000030866 00000 n +trailer +<< +/ID +[<35a2564fd62d2f3a937cd464104f15c9><35a2564fd62d2f3a937cd464104f15c9>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 19 0 R +/Root 18 0 R +/Size 33 +>> +startxref +33109 +%%EOF diff --git a/Les10-Supabase-Auth/Les10-Slide-Overzicht.md b/Les10-Supabase-Auth/Les10-Slide-Overzicht.md new file mode 100644 index 0000000..22cff3d --- /dev/null +++ b/Les10-Supabase-Auth/Les10-Slide-Overzicht.md @@ -0,0 +1,933 @@ +# Les 10 -- Slide-overzicht +## Supabase Authenticatie & Row Level Security (15 slides) + +**Cursus:** AI Developer -- NOVI Hogeschool Utrecht +**Duur:** 3 uur (180 minuten) | 09:00 - 12:00 +**Project:** Poll App -- authenticatie toevoegen met Supabase Auth + +> **Voorkennis studenten:** Supabase basics uit Les 8 (project setup, database, tabellen). Poll App met Next.js 16, TypeScript, Tailwind CSS. Database met `polls` en `options` tabellen. Nog geen authenticatie. + +--- + +## Timing-overzicht + +| Tijd | Duur | Onderwerp | Slide(s) | Vorm | +|---------------|--------|----------------------------------------|----------|---------------------| +| 09:00 - 09:05 | 5 min | Titelslide | 1 | Presentatie | +| 09:05 - 09:10 | 5 min | Planning vandaag | 2 | Presentatie | +| 09:10 - 09:15 | 5 min | Terugblik Les 8-9 | 3 | Presentatie | +| 09:15 - 09:25 | 10 min | Eindexamenopdracht | 4 | Presentatie | +| 09:25 - 09:30 | 5 min | Wat is authenticatie? | 5 | Presentatie | +| 09:30 - 09:40 | 10 min | Supabase Auth -- 3 methodes | 6 | Presentatie + Demo | +| 09:40 - 09:45 | 5 min | Hoe werkt een sessie? | 7 | Presentatie | +| 09:45 - 09:55 | 10 min | Auth in Next.js | 8 | Presentatie | +| 09:55 - 10:00 | 5 min | Row Level Security (RLS) | 9 | Presentatie | +| 10:00 - 10:15 | 15 min | Pauze | 10 | Pauze | +| 10:15 - 10:30 | 15 min | Hands-on: Auth opzetten in Supabase | 11 | Hands-on (ref) | +| 10:30 - 10:55 | 25 min | Hands-on: Login & registratie | 12 | Hands-on (ref) | +| 10:55 - 11:10 | 15 min | Hands-on: Sessie & beschermde routes | 13 | Hands-on (ref) | +| 11:10 - 11:25 | 15 min | Hands-on: Basis RLS | 14 | Hands-on (ref) | +| 11:45 - 12:00 | 15 min | Samenvatting & huiswerk | 15 | Presentatie | + +--- + +## Slide-indeling + +--- + +### Slide 1: Titelslide +**Timing:** 09:00 - 09:05 (5 min) + +**Titel:** Les 10 -- Supabase Auth & RLS +**Ondertitel:** Authenticatie toevoegen aan de Poll App + +``` ++========================================================+ +| | +| LES 10: SUPABASE AUTH & RLS | +| | +| Authenticatie toevoegen aan de Poll App | +| | +| Tim -- NOVI Hogeschool Utrecht | +| AI Developer Cursus | +| | ++========================================================+ +``` + +**Kernpunten:** +- Les 10 van de AI Developer cursus +- Vandaag draaien we alles om beveiliging: wie mag wat? +- Supabase Auth + Row Level Security +- We bouwen verder op de Poll App uit Les 8-9 + +**Spreektekst:** +- "Goedemorgen allemaal, welkom bij Les 10!" +- "Vandaag gaan we onze Poll App beveiligen met echte authenticatie." +- "Na vandaag kan niet zomaar iedereen meer polls aanmaken -- je moet ingelogd zijn." +- "We gebruiken Supabase Auth, wat het hele inlogproces voor ons afhandelt." +- "En we leren over Row Level Security: beveiliging op database-niveau." + +--- + +### Slide 2: Planning Vandaag +**Timing:** 09:05 - 09:10 (5 min) + +**Titel:** Planning Vandaag +**Ondertitel:** Wat gaan we doen en leren? + +``` ++========================================================+ +| PLANNING VANDAAG | +| | +| 09:00 Theorie: Auth & RLS concepten | +| 09:15 Eindexamenopdracht introductie | +| 09:25 Supabase Auth deep dive | +| 10:00 -- PAUZE -- | +| 10:15 Hands-on: Auth opzetten | +| 10:30 Hands-on: Login & registratie | +| 10:55 Hands-on: Sessie & beschermde routes | +| 11:10 Hands-on: Basis RLS | +| 11:45 Samenvatting & huiswerk | +| | +| LEERDOELEN: | +| [x] Supabase Auth instellen (email/password) | +| [x] Login/registratie pagina bouwen | +| [x] Sessie beheren (wie is ingelogd?) | +| [x] Row Level Security policies schrijven | ++========================================================+ +``` + +**Kernpunten:** +- Eerste helft: theorie over authenticatie, autorisatie, sessies +- Introductie van de eindexamenopdracht +- Tweede helft: volledig hands-on, stap voor stap +- Vier concrete leerdoelen die je vandaag behaalt + +**Spreektekst:** +- "Dit is de planning voor vandaag. We beginnen met een stuk theorie." +- "Daarna introduceer ik de eindexamenopdracht -- heel belangrijk." +- "Na de pauze gaan we volledig hands-on. Ik laat reference slides zien die op het scherm blijven staan terwijl jullie werken." +- "Aan het einde van vandaag heb je een werkende login, registratie, en beveiligde database." +- "Vier leerdoelen: Auth instellen, login bouwen, sessie beheren, en RLS schrijven." + +--- + +### Slide 3: Terugblik Les 8-9 +**Timing:** 09:10 - 09:15 (5 min) + +**Titel:** Terugblik Les 8-9 +**Ondertitel:** Waar staan we nu? + +``` ++========================================================+ +| TERUGBLIK LES 8-9 | +| | +| +------------------+ +-------------------------+ | +| | SUPABASE | | NEXT.JS APP | | +| | | | | | +| | polls | | / (homepage) | | +| | +----------+ | | /polls (lijst) | | +| | | id | |<-->| /polls/new (aanmaken) | | +| | | question | | | /polls/[id] (stemmen) | | +| | +----------+ | | | | +| | | | | | +| | options | | TypeScript + Tailwind | | +| | +----------+ | | | | +| | | id | | +-------------------------+ | +| | | poll_id | | | +| | | text | | PROBLEEM: | +| | | votes | | Iedereen kan alles! | +| | +----------+ | Geen login nodig | +| +------------------+ Geen beveiliging | ++========================================================+ +``` + +**Kernpunten:** +- Supabase project met `polls` en `options` tabellen +- Next.js app met pagina's voor lijst, aanmaken en stemmen +- Alles werkt, maar er is geen beveiliging +- Het probleem: iedereen kan polls aanmaken en data manipuleren + +**Spreektekst:** +- "Laten we even terugkijken naar waar we staan." +- "We hebben een werkende Poll App: je kunt polls bekijken, nieuwe polls aanmaken, en stemmen." +- "De database draait op Supabase met twee tabellen: polls en options." +- "Maar... er is een groot probleem. Iedereen kan alles doen. Er is geen login." +- "Als je de Supabase URL en API key kent, kun je direct de database benaderen." +- "Vandaag gaan we dat oplossen!" + +--- + +### Slide 4: Eindexamenopdracht +**Timing:** 09:15 - 09:25 (10 min) + +**Titel:** Eindexamenopdracht +**Ondertitel:** Vrije keuze app -- jouw project! + +``` ++========================================================+ +| EINDEXAMENOPDRACHT | +| | +| Bouw je eigen full-stack applicatie! | +| | +| VEREISTEN: | +| +----------------------------------------------------+| +| | [x] Next.js 16 + TypeScript || +| | [x] Supabase (database + auth) || +| | [x] Authenticatie (login/registratie) || +| | [x] Row Level Security (RLS policies) || +| | [x] CRUD operaties (Create, Read, Update, Delete) || +| | [x] Deployed (Vercel + Supabase) || +| | [x] Nette code (componenten, types, error handling)|| +| +----------------------------------------------------+| +| | +| TIJDLIJN: | +| Les 10 (vandaag) Introductie + Auth leren | +| Les 11-12 Bouwen aan je project | +| Les 13 Inleveren + presentatie | +| | +| IDEEN: Todo app, Blog, Recepten, Budget tracker, | +| Quiz app, Bookmark manager, Habit tracker... | ++========================================================+ +``` + +**Kernpunten:** +- Vrije keuze: kies zelf welke app je bouwt +- Zeven technische vereisten waar je aan moet voldoen +- Alles wat we in de cursus geleerd hebben komt samen +- Tijdlijn: 3-4 lessen om te bouwen, daarna inleveren + presentatie +- Voorbeelden ter inspiratie, maar eigen ideeen zijn welkom + +**Spreektekst:** +- "Nu iets heel belangrijks: de eindexamenopdracht." +- "Jullie gaan je eigen full-stack applicatie bouwen. Vrije keuze -- dus kies iets dat je leuk vindt." +- "Er zijn zeven vereisten. Laten we ze doorlopen." +- "Next.js 16 met TypeScript -- dat kennen jullie al." +- "Supabase voor de database EN authenticatie -- dat leren we vandaag." +- "Je app moet login en registratie hebben, en de database moet beveiligd zijn met RLS." +- "CRUD: je moet data kunnen aanmaken, lezen, updaten en verwijderen." +- "De app moet gedeployed zijn op Vercel, zodat ik hem kan bekijken." +- "En nette code: goede componenten, TypeScript types, error handling." +- "Qua tijdlijn: vandaag leer je Auth. De komende lessen heb je tijd om te bouwen. En dan presenteren." +- "Begin alvast na te denken over wat je wilt bouwen. Een todo app, blog, recepten-app, budget tracker... het mag allemaal." + +--- + +### Slide 5: Wat is Authenticatie? +**Timing:** 09:25 - 09:30 (5 min) + +**Titel:** Wat is Authenticatie? +**Ondertitel:** Auth vs Autorisatie + +``` ++========================================================+ +| WAT IS AUTHENTICATIE? | +| | +| +------------------------+ +------------------------+| +| | | | || +| | AUTHENTICATIE | | AUTORISATIE || +| | | | || +| | "Wie ben je?" | | "Wat mag je?" || +| | | | || +| | +--------+ | | +--------+ || +| | | SLOT | | | | SCHILD | || +| | | [====] | | | | {X} | || +| | +--------+ | | +--------+ || +| | | | || +| | - Inloggen | | - Rechten || +| | - Email + wachtwoord | | - Rollen (admin/user) || +| | - Identiteit bewijzen | | - Wat mag je zien? || +| | | | || +| +------------------------+ +------------------------+| +| | +| VOORBEELD: | +| Bioscoop: ticket tonen = auth | stoel kiezen = autor. | +| School: pasje scannen = auth | lokaal betreden = a. | ++========================================================+ +``` + +**Kernpunten:** +- Authenticatie = wie ben je? Identiteit bewijzen. +- Autorisatie = wat mag je? Rechten en rollen. +- Twee aparte concepten die samenwerken +- Vandaag leren we beide: Auth met Supabase, Autorisatie met RLS + +**Spreektekst:** +- "Voordat we gaan bouwen, moeten we twee begrippen begrijpen." +- "Authenticatie: wie ben je? Je bewijst je identiteit. Bijvoorbeeld door in te loggen met email en wachtwoord." +- "Autorisatie: wat mag je? Welke rechten heb je? Mag je alleen lezen, of ook schrijven?" +- "Denk aan een bioscoop. Je laat je ticket zien bij de ingang -- dat is authenticatie. Maar je mag alleen in zaal 3 zitten -- dat is autorisatie." +- "Of school: je scant je pasje -- authenticatie. Maar je mag niet zomaar in elk lokaal -- autorisatie." +- "Vandaag leren we beide. Supabase Auth regelt de authenticatie. Row Level Security regelt de autorisatie." + +--- + +### Slide 6: Supabase Auth -- 3 Methodes +**Timing:** 09:30 - 09:40 (10 min) + +**Titel:** Supabase Auth -- 3 Methodes +**Ondertitel:** Hoe kunnen gebruikers inloggen? + +``` ++========================================================+ +| SUPABASE AUTH -- 3 METHODES | +| | +| +----------------+ +----------------+ +--------------+| +| | EMAIL/PASSWORD | | MAGIC LINK | | GOOGLE OAUTH || +| | | | | | || +| | +------------+ | | +------------+ | | +----------+|| +| | | email: | | | | email: | | | | Google ||| +| | | [........] | | | | [........] | | | | [G] ||| +| | | password: | | | | | | | | Login ||| +| | | [........] | | | | Klik link | | | +----------+|| +| | | [INLOGGEN] | | | | in je mail | | | || +| | +------------+ | | +------------+ | | || +| | | | | | || +| | Klassiek | | Geen wachtw. | | Social login || +| | Makkelijk te | | Veilig | | Makkelijkst || +| | begrijpen | | Simpel | | voor users || +| | | | | | || +| | WIJ GEBRUIKEN | | OPTIONEEL | | NIET VANDAAG || +| | DIT VANDAAG | | (bonus) | | (complex) || +| +----------------+ +----------------+ +--------------+| ++========================================================+ +``` + +**Kernpunten:** +- Email/Password: klassieke methode, makkelijk te begrijpen, we gebruiken dit vandaag +- Magic Link: email zonder wachtwoord, gebruiker klikt link in mailbox +- Google OAuth: social login via Google account, meest gebruiksvriendelijk maar complexer +- Supabase ondersteunt alle drie out-of-the-box +- In het Supabase Dashboard kun je providers aan/uitzetten + +**Spreektekst:** +- "Supabase biedt drie manieren om in te loggen. Laten we ze bekijken." +- "Nummer 1: email en wachtwoord. De klassieke manier. Je maakt een account aan met je email en een wachtwoord, en daarna log je in. Dit gaan we vandaag gebruiken." +- "Nummer 2: Magic Link. Je vult alleen je email in, en Supabase stuurt een linkje. Klik erop en je bent ingelogd. Geen wachtwoord nodig. Dit voegen we toe als bonus." +- "Nummer 3: Google OAuth. De 'Log in met Google' knop die je overal ziet. Heel handig voor gebruikers, maar de setup is complexer. Dat doen we niet vandaag." +- "Laat me even het Supabase Dashboard laten zien waar je deze providers configureert..." +- *Tim opent Supabase Dashboard > Authentication > Providers en laat de opties zien* +- "Zie je? Email staat standaard aan. Je kunt hier ook Magic Link, Google, GitHub en meer aanzetten." + +--- + +### Slide 7: Hoe Werkt een Sessie? +**Timing:** 09:40 - 09:45 (5 min) + +**Titel:** Hoe Werkt een Sessie? +**Ondertitel:** Van login tot beveiligde requests + +``` ++========================================================+ +| HOE WERKT EEN SESSIE? | +| | +| +--------+ +----------+ +-----------+ | +| | USER |----->| SUPABASE |----->| JWT TOKEN | | +| | login | | Auth | | (sessie) | | +| +--------+ +----------+ +-----------+ | +| | | | +| | email + wachtwoord | | +| | v | +| | +---------------+ | +| | | COOKIE | | +| | | (browser) | | +| | +---------------+ | +| | | | +| v v | +| +---------------------------------------------+ | +| | ELKE REQUEST | | +| | Browser stuurt cookie automatisch mee | | +| | Middleware checkt: is de sessie geldig? | | +| | Zo ja: door naar de pagina | | +| | Zo nee: redirect naar /login | | +| +---------------------------------------------+ | ++========================================================+ +``` + +**Kernpunten:** +- User logt in met email + wachtwoord +- Supabase Auth verifieert en stuurt een JWT token terug +- JWT wordt opgeslagen als cookie in de browser +- Bij elke request stuurt de browser de cookie automatisch mee +- Middleware controleert of de sessie geldig is + +**Spreektekst:** +- "Hoe werkt een sessie eigenlijk? Laten we het stap voor stap bekijken." +- "Stap 1: de gebruiker logt in met email en wachtwoord." +- "Stap 2: Supabase Auth controleert de gegevens. Kloppen ze? Dan krijg je een JWT token terug." +- "JWT staat voor JSON Web Token. Het is een gecodeerde string die zegt: 'deze gebruiker is ingelogd en dit is hun user ID'." +- "Stap 3: dat token wordt opgeslagen als cookie in de browser." +- "Stap 4: bij elke pagina die je bezoekt, stuurt de browser die cookie automatisch mee." +- "In onze Next.js app hebben we middleware die bij elke request checkt: is er een geldige sessie? Zo nee, dan redirect hij naar /login." +- "Dit is belangrijk om te begrijpen. Je hoeft het niet zelf te bouwen -- Supabase en de @supabase/ssr package regelen het voor je." + +--- + +### Slide 8: Auth in Next.js +**Timing:** 09:45 - 09:55 (10 min) + +**Titel:** Auth in Next.js +**Ondertitel:** Architectuur en bestandsstructuur + +``` ++========================================================+ +| AUTH IN NEXT.JS -- ARCHITECTUUR | +| | +| Package: @supabase/ssr | +| | +| +----------------------------------------------------+| +| | BESTANDEN DIE WE GAAN MAKEN: || +| | || +| | src/ || +| | +-- lib/supabase/ || +| | | +-- client.ts <-- Browser client || +| | | +-- server.ts <-- Server client || +| | | || +| | +-- middleware.ts <-- Sessie refreshen || +| | | || +| | +-- app/ || +| | +-- auth/ || +| | | +-- callback/ || +| | | +-- route.ts <-- Auth callback || +| | +-- login/ || +| | +-- page.tsx <-- Login pagina || +| +----------------------------------------------------+| +| | +| BROWSER CLIENT SERVER CLIENT | +| - Gebruikt in - Gebruikt in Server | +| Client Components Components & API routes | +| - createBrowser... - createServer... | +| - Leest cookies - Leest EN schrijft cookies | ++========================================================+ +``` + +**Kernpunten:** +- `@supabase/ssr` is de package die cookies en sessies afhandelt +- Browser client: voor Client Components (interactieve UI) +- Server client: voor Server Components en API routes (data ophalen) +- Middleware: draait bij elke request, refresht de sessie +- Auth callback route: verwerkt magic links en OAuth redirects + +**Spreektekst:** +- "Laten we kijken naar de architectuur. We installeren het package @supabase/ssr." +- "We maken twee Supabase clients. Waarom twee? Omdat Next.js twee omgevingen heeft." +- "De browser client gebruik je in Client Components -- dat zijn de interactieve componenten met useState en onClick." +- "De server client gebruik je in Server Components en API routes -- dat is code die op de server draait." +- "Het verschil: de browser client kan alleen cookies lezen. De server client kan ze ook schrijven." +- "Dan hebben we middleware.ts. Die draait bij ELKE request. Zijn taak: de sessie refreshen zodat de gebruiker ingelogd blijft." +- "En de auth callback route. Die is nodig voor magic links. Als een gebruiker op een magic link klikt, komt hij terug op /auth/callback, en die route wisselt de code om voor een sessie." +- "Na de pauze gaan we al deze bestanden stap voor stap aanmaken." + +--- + +### Slide 9: Row Level Security (RLS) +**Timing:** 09:55 - 10:00 (5 min) + +**Titel:** Row Level Security (RLS) +**Ondertitel:** Beveiliging op database-niveau + +``` ++========================================================+ +| ROW LEVEL SECURITY (RLS) | +| | +| ZONDER RLS: | +| +--------------------------------------------+ | +| | polls tabel GEEN SLOT | | +| | id | question | created_by | | +| | 1 | Beste taal? | user_abc <- leesbaar| | +| | 2 | Beste editor? | user_xyz <- leesbaar| | +| | ** Iedereen kan ALLES lezen, schrijven, | | +| | updaten en verwijderen ** | | +| +--------------------------------------------+ | +| | +| MET RLS: | +| +--------------------------------------------+ | +| | polls tabel [SLOT] | | +| | id | question | created_by | | +| | 1 | Beste taal? | user_abc <- policy! | | +| | 2 | Beste editor? | user_xyz <- policy! | | +| | | | +| | POLICIES: | | +| | SELECT: iedereen mag lezen | | +| | INSERT: alleen ingelogde users | | +| | UPDATE: alleen eigen polls | | +| | DELETE: alleen eigen polls | | +| +--------------------------------------------+ | ++========================================================+ +``` + +**Kernpunten:** +- Zonder RLS: de API key geeft volledige toegang tot alle data +- Met RLS: elke query wordt gecheckt tegen policies +- Policies definieer je per tabel, per operatie (SELECT, INSERT, UPDATE, DELETE) +- RLS draait op database-niveau -- je kunt het niet omzeilen vanuit de frontend +- Dit is de autorisatie-laag die we eerder bespraken + +**Spreektekst:** +- "Nu het laatste theoretische concept: Row Level Security, oftewel RLS." +- "Onthoud: onze Supabase API key staat in de frontend code. Iedereen kan die zien." +- "Zonder RLS kan iemand met die key ALLES doen: lezen, schrijven, verwijderen. Dat is een groot beveiligingsprobleem." +- "Met RLS zet je een slot op je tabellen. Je definieert policies: regels die zeggen wie wat mag." +- "Bijvoorbeeld: iedereen mag polls LEZEN, maar alleen ingelogde users mogen polls AANMAKEN." +- "Of: je mag alleen je EIGEN polls updaten of verwijderen." +- "Het mooie van RLS is dat het op database-niveau draait. Zelfs als iemand direct de API aanroept, worden de policies gecontroleerd." +- "Na de pauze gaan we dit ook daadwerkelijk instellen." + +--- + +### Slide 10: Pauze +**Timing:** 10:00 - 10:15 (15 min) + +**Titel:** Pauze +**Ondertitel:** 15 minuten + +``` ++========================================================+ +| | +| | +| PAUZE | +| | +| 15 minuten | +| | +| 10:00 -- 10:15 | +| | +| | +| Tip: Denk alvast na over je eindexamenopdracht! | +| Welke app wil je bouwen? | +| | +| | ++========================================================+ +``` + +**Kernpunten:** +- 15 minuten pauze +- Studenten kunnen alvast nadenken over hun eindexamenopdracht +- Na de pauze: hands-on, dus zorg dat je laptop klaar staat + +**Spreektekst:** +- "Oké, tijd voor pauze! 15 minuten." +- "Denk alvast na over wat je wilt bouwen voor je eindexamenopdracht." +- "Na de pauze gaan we direct aan de slag. Zorg dat je Cursor open hebt en je Supabase project klaarstaat." +- "Tot zo!" + +--- + +### Slide 11: Hands-on -- Auth Opzetten in Supabase (REFERENCE SLIDE) +**Timing:** 10:15 - 10:30 (15 min) + +**Titel:** Hands-on -- Auth Opzetten +**Ondertitel:** REFERENCE SLIDE -- blijft op het scherm + +``` ++========================================================+ +| HANDS-ON: AUTH OPZETTEN [REF] | +| | +| STAP 1: Starter project | +| $ Pak de starter zip uit en open in Cursor | +| $ npm install | +| | +| STAP 2: Environment variabelen | +| Maak .env.local: | +| NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co | +| NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci... | +| | +| STAP 3: Packages installeren | +| $ npm install @supabase/ssr @supabase/supabase-js | +| | +| STAP 4: Browser client (src/lib/supabase/client.ts) | +| import { createBrowserClient } from '@supabase/ssr' | +| export function createClient() { | +| return createBrowserClient( | +| process.env.NEXT_PUBLIC_SUPABASE_URL!, | +| process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! | +| ) | +| } | +| | +| STAP 5: Server client (src/lib/supabase/server.ts) | +| 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, opts })| +| => cookieStore.set(name, value, opts)) | +| }, | +| }} | +| ) | +| } | +| | +| STAP 6: Middleware (src/middleware.ts) | +| import { createServerClient } from '@supabase/ssr' | +| import { NextResponse, type NextRequest } from 'next' | +| export async function middleware(request: NextRequest) | +| // Refresh sessie bij elke request | +| // Redirect naar /login als niet ingelogd | +| | +| STAP 7: Auth callback (src/app/auth/callback/route.ts)| +| // Verwerkt magic link codes | +| // Wisselt code om voor sessie | +| | +| STAP 8: Supabase Dashboard | +| Authentication > Providers > Email aanvinken | ++========================================================+ +``` + +**Kernpunten:** +- Stap 1-2: Project openen en environment variabelen instellen +- Stap 3: Twee packages installeren: `@supabase/ssr` en `@supabase/supabase-js` +- Stap 4: Browser client voor Client Components +- Stap 5: Server client met cookie-handling voor Server Components +- Stap 6: Middleware die sessie refresht en ongeautoriseerde users redirect +- Stap 7: Auth callback route voor magic links +- Stap 8: Email provider aanzetten in Supabase Dashboard + +**Spreektekst:** +- "Oké, we gaan aan de slag! Deze slide blijft op het scherm. Volg de stappen." +- "Stap 1: pak de starter zip uit die ik gestuurd heb, en open het project in Cursor. Doe npm install." +- "Stap 2: maak een .env.local bestand. Kopieer je Supabase URL en anon key uit het dashboard." +- "Stap 3: installeer de twee packages: @supabase/ssr en @supabase/supabase-js." +- "Stap 4: maak de browser client aan. Dit is heel simpel -- je roept createBrowserClient aan met je URL en key." +- "Stap 5: de server client is iets complexer. Die heeft cookie-handling nodig. Kopieer de code van de slide." +- "Stap 6: de middleware. Die zorgt dat de sessie bij elke request ververst wordt. En hij redirect naar /login als je niet ingelogd bent." +- "Stap 7: de auth callback route. Die heb je nodig voor magic links." +- "Stap 8: ga naar je Supabase Dashboard, Authentication, Providers, en zet Email aan." +- "Neem de tijd, volg het stap voor stap. Ik loop rond om te helpen." + +--- + +### Slide 12: Hands-on -- Login & Registratie (REFERENCE SLIDE) +**Timing:** 10:30 - 10:55 (25 min) + +**Titel:** Hands-on -- Login & Registratie +**Ondertitel:** REFERENCE SLIDE -- blijft op het scherm + +``` ++========================================================+ +| HANDS-ON: LOGIN & REGISTRATIE [REF] | +| | +| STAP 1: Maak src/app/login/page.tsx | +| 'use client' | +| import { useState } from 'react' | +| import { createClient } from '@/lib/supabase/client' | +| import { useRouter } from 'next/navigation' | +| | +| STAP 2: State | +| const [email, setEmail] = useState('') | +| const [password, setPassword] = useState('') | +| const [loading, setLoading] = useState(false) | +| const [message, setMessage] = useState('') | +| const router = useRouter() | +| const supabase = createClient() | +| | +| STAP 3: handleSignUp functie | +| async function handleSignUp() { | +| setLoading(true) | +| const { error } = await supabase.auth.signUp({ | +| email, password | +| }) | +| if (error) setMessage(error.message) | +| else setMessage('Check je email voor confirmatie!') | +| setLoading(false) | +| } | +| | +| STAP 4: handleSignIn functie | +| async function handleSignIn() { | +| setLoading(true) | +| const { error } = await | +| supabase.auth.signInWithPassword({ | +| email, password | +| }) | +| if (error) setMessage(error.message) | +| else router.push('/') | +| setLoading(false) | +| } | +| | +| STAP 5: handleMagicLink functie (bonus) | +| async function handleMagicLink() { | +| setLoading(true) | +| const { error } = await | +| supabase.auth.signInWithOtp({ email }) | +| if (error) setMessage(error.message) | +| else setMessage('Check je email voor de link!') | +| setLoading(false) | +| } | +| | +| STAP 6: Formulier JSX | +|
| +| setEmail(e.target.value)} /> | +| setPassword(e.target.value)} />| +| | +| | +|
| +| ) | +| } | +| | +| STAP 4: Redirect na logout naar /login | +| router.push('/login') in handleSignOut | +| | +| STAP 5: Testen | +| [x] Ga naar / zonder login -> redirect naar /login | +| [x] Log in -> je ziet je email in de navbar | +| [x] Klik uitloggen -> redirect naar /login | +| [x] Na uitloggen kun je niet bij / komen | ++========================================================+ +``` + +**Kernpunten:** +- `getUser()` op de server om de huidige gebruiker op te halen +- Conditional rendering: toon email + logout als ingelogd, anders login-link +- LogoutButton is een Client Component (interactief) +- `signOut()` verwijdert de sessie, daarna redirect naar /login +- Middleware handelt de bescherming af: geen sessie = redirect naar /login + +**Spreektekst:** +- "Nu gaan we de sessie zichtbaar maken in de UI." +- "Stap 1: in je layout of navbar haal je de user op. Let op: dit is een Server Component, dus gebruik de server client." +- "supabase.auth.getUser() geeft de huidige ingelogde user terug, of null als niemand ingelogd is." +- "Stap 2: conditional rendering. Als er een user is, toon de email en een logout button. Anders toon een link naar /login." +- "Stap 3: de LogoutButton moet een Client Component zijn, want hij heeft een onClick nodig." +- "De handleSignOut functie roept supabase.auth.signOut() aan. Dat verwijdert de sessie cookie." +- "Stap 4: na het uitloggen stuur je de gebruiker terug naar /login met router.push." +- "Stap 5: testen! Open een incognito venster en ga naar de homepage. Je zou geredirect moeten worden naar /login." +- "Log in, en je ziet je email in de navbar. Klik uitloggen, en je gaat terug naar /login." +- "Als dit allemaal werkt, heb je een werkend auth-systeem!" + +--- + +### Slide 14: Hands-on -- Basis RLS (REFERENCE SLIDE) +**Timing:** 11:10 - 11:25 (15 min) + +**Titel:** Hands-on -- Basis RLS +**Ondertitel:** REFERENCE SLIDE -- blijft op het scherm + +``` ++========================================================+ +| HANDS-ON: BASIS RLS [REF] | +| | +| Ga naar Supabase Dashboard > SQL Editor | +| | +| STAP 1: Enable RLS op polls | +| ALTER TABLE polls ENABLE ROW LEVEL SECURITY; | +| | +| STAP 2: Enable RLS op options | +| ALTER TABLE options ENABLE ROW LEVEL SECURITY; | +| | +| STAP 3: Iedereen mag polls LEZEN | +| CREATE POLICY "Iedereen mag polls lezen" | +| ON polls FOR SELECT | +| TO public | +| USING (true); | +| | +| STAP 4: Alleen ingelogde users mogen polls AANMAKEN | +| CREATE POLICY "Ingelogde users mogen polls aanmaken" | +| ON polls FOR INSERT | +| TO authenticated | +| WITH CHECK (true); | +| | +| STAP 5: Iedereen mag options LEZEN | +| CREATE POLICY "Iedereen mag options lezen" | +| ON options FOR SELECT | +| TO public | +| USING (true); | +| | +| STAP 6: Ingelogde users mogen options AANMAKEN | +| CREATE POLICY "Ingelogde users mogen options maken" | +| ON options FOR INSERT | +| TO authenticated | +| WITH CHECK (true); | +| | +| STAP 7: Ingelogde users mogen stemmen (UPDATE) | +| CREATE POLICY "Ingelogde users mogen stemmen" | +| ON options FOR UPDATE | +| TO authenticated | +| USING (true) | +| WITH CHECK (true); | +| | +| STAP 8: Testen -- app werkt als ingelogd | +| [x] Polls laden nog steeds | +| [x] Je kunt nog steeds een poll aanmaken | +| [x] Je kunt nog steeds stemmen | +| | +| STAP 9: Testen -- zonder login geen schrijfrechten | +| [x] Polls zijn zichtbaar (SELECT = public) | +| [x] Poll aanmaken FAALT (INSERT = authenticated) | +| [x] Stemmen FAALT (UPDATE = authenticated) | ++========================================================+ +``` + +**Kernpunten:** +- RLS moet per tabel ingeschakeld worden met `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` +- Na het inschakelen van RLS is ALLES geblokkeerd totdat je policies aanmaakt +- `TO public` = iedereen (ook niet ingelogd) +- `TO authenticated` = alleen ingelogde users +- `USING (true)` = geen extra voorwaarden (alle rijen) +- `WITH CHECK (true)` = geen extra voorwaarden bij schrijven +- Altijd testen: werkt het als ingelogd? Faalt het zonder login? + +**Spreektekst:** +- "De laatste stap: Row Level Security! Open de SQL Editor in je Supabase Dashboard." +- "Stap 1 en 2: enable RLS op beide tabellen. LET OP: zodra je dit doet, is alles geblokkeerd. Je app zal even niet werken totdat je policies toevoegt." +- "Stap 3: de eerste policy. Iedereen mag polls lezen. FOR SELECT, TO public, USING true. Public betekent iedereen, ook niet-ingelogde gebruikers." +- "Stap 4: alleen ingelogde users mogen polls aanmaken. FOR INSERT, TO authenticated. Authenticated betekent: je moet een geldige sessie hebben." +- "Stap 5 en 6: hetzelfde voor de options tabel. Iedereen mag lezen, alleen ingelogde users mogen aanmaken." +- "Stap 7: ingelogde users mogen stemmen. Stemmen is een UPDATE operatie -- je update het votes veld." +- "Stap 8: test als ingelogde user. Alles zou moeten werken zoals voorheen." +- "Stap 9: test zonder login. Open een incognito venster. Polls zijn zichtbaar -- want SELECT is public. Maar een poll aanmaken of stemmen? Dat faalt nu. Precies wat we willen!" +- "Gefeliciteerd! Je database is nu beveiligd." + +--- + +### Slide 15: Samenvatting & Huiswerk +**Timing:** 11:45 - 12:00 (15 min) + +**Titel:** Samenvatting & Huiswerk +**Ondertitel:** Wat hebben we geleerd? + +``` ++========================================================+ +| SAMENVATTING | +| | +| 5 KEY TAKEAWAYS: | +| | +| 1. Authenticatie = wie ben je? | +| Autorisatie = wat mag je? | +| | +| 2. Supabase Auth regelt login/registratie | +| Email/password, magic link, of OAuth | +| | +| 3. Sessies werken via JWT tokens in cookies | +| Middleware refresht de sessie automatisch | +| | +| 4. Twee clients: browser (client) + server | +| @supabase/ssr handelt cookies af | +| | +| 5. RLS beveiligt je database met policies | +| public = iedereen | authenticated = ingelogd | +| | +| ---------------------------------------------------- | +| | +| HUISWERK: | +| [1] Bedenk je eindexamenopdracht (welke app?) | +| [2] Maak een lijstje van je tabellen + kolommen | +| [3] Bedenk welke RLS policies je nodig hebt | +| [4] Optioneel: voeg user_id kolom toe aan polls | +| en maak een policy: alleen eigen polls deleten | +| | +| VOLGENDE LES: | +| Bouwen aan je eindexamenopdracht! | ++========================================================+ +``` + +**Kernpunten:** +- Vijf key takeaways die alles van vandaag samenvatten +- Authenticatie vs autorisatie: twee aparte concepten +- Supabase Auth: drie methodes, wij gebruikten email/password +- Sessies: JWT tokens, cookies, middleware +- Twee Supabase clients: browser en server +- RLS: policies per tabel, per operatie, public vs authenticated +- Huiswerk: eindexamenopdracht bedenken en tabellen plannen + +**Spreektekst:** +- "Laten we samenvatten wat we vandaag geleerd hebben." +- "Takeaway 1: authenticatie is wie ben je, autorisatie is wat mag je. Twee aparte dingen." +- "Takeaway 2: Supabase Auth regelt de authenticatie voor ons. We gebruikten email en wachtwoord, en als bonus magic links." +- "Takeaway 3: sessies werken via JWT tokens die in cookies worden opgeslagen. De middleware refresht ze automatisch." +- "Takeaway 4: in Next.js heb je twee Supabase clients nodig. Een voor de browser en een voor de server. Het @supabase/ssr package maakt dit mogelijk." +- "Takeaway 5: RLS beveiligt je database met policies. Public is voor iedereen, authenticated is alleen voor ingelogde users." +- "Nu het huiswerk. Dit is belangrijk voor de eindexamenopdracht." +- "Nummer 1: bedenk welke app je wilt bouwen. Kies iets dat je leuk vindt en dat haalbaar is." +- "Nummer 2: maak een lijstje van de tabellen die je nodig hebt en welke kolommen ze hebben." +- "Nummer 3: bedenk welke RLS policies je nodig hebt. Wie mag wat lezen en schrijven?" +- "Nummer 4 is optioneel: voeg een user_id kolom toe aan de polls tabel, zodat je kunt bijhouden wie een poll heeft aangemaakt. Dan kun je een policy maken dat je alleen je eigen polls mag verwijderen." +- "Volgende les gaan we bouwen aan jullie eindexamenopdracht. Zorg dat je een plan hebt!" +- "Goed gedaan vandaag. Vragen? Anders zie ik jullie volgende week!" diff --git a/Les10-Supabase-Auth/Les10-Slides.key b/Les10-Supabase-Auth/Les10-Slides.key new file mode 100755 index 0000000..ac058af Binary files /dev/null and b/Les10-Supabase-Auth/Les10-Slides.key differ diff --git a/Les10-Supabase-Auth/Les10-Slides.pptx b/Les10-Supabase-Auth/Les10-Slides.pptx new file mode 100644 index 0000000..7e793b5 Binary files /dev/null and b/Les10-Supabase-Auth/Les10-Slides.pptx differ diff --git a/Les10-Supabase-Auth/eindopdracht/[IN PROGRESS] AI Developer eindopdracht v1.2 (30 EC).docx b/Les10-Supabase-Auth/eindopdracht/[IN PROGRESS] AI Developer eindopdracht v1.2 (30 EC).docx new file mode 100644 index 0000000..04ae0a4 Binary files /dev/null and b/Les10-Supabase-Auth/eindopdracht/[IN PROGRESS] AI Developer eindopdracht v1.2 (30 EC).docx differ diff --git a/Les10-Supabase-Auth/eindopdracht/[IN PROGRESS] AI Developer eindopdracht v1.2 (30 EC).pdf b/Les10-Supabase-Auth/eindopdracht/[IN PROGRESS] AI Developer eindopdracht v1.2 (30 EC).pdf new file mode 100644 index 0000000..02adcb6 Binary files /dev/null and b/Les10-Supabase-Auth/eindopdracht/[IN PROGRESS] AI Developer eindopdracht v1.2 (30 EC).pdf differ diff --git a/Les10-Supabase-Auth/eindopdracht/[IN PROGRESS] Leerlijnbeschrijving AI Developer v.1.2.docx b/Les10-Supabase-Auth/eindopdracht/[IN PROGRESS] Leerlijnbeschrijving AI Developer v.1.2.docx new file mode 100644 index 0000000..0716ebe Binary files /dev/null and b/Les10-Supabase-Auth/eindopdracht/[IN PROGRESS] Leerlijnbeschrijving AI Developer v.1.2.docx differ diff --git a/Les10-Supabase-Auth/poll-app-starter.zip b/Les10-Supabase-Auth/poll-app-starter.zip new file mode 100644 index 0000000..dd09534 Binary files /dev/null and b/Les10-Supabase-Auth/poll-app-starter.zip differ diff --git a/Les11-AI-SDK/Les11-Docenttekst.md b/Les11-AI-SDK/Les11-Docenttekst.md new file mode 100644 index 0000000..3fac217 --- /dev/null +++ b/Les11-AI-SDK/Les11-Docenttekst.md @@ -0,0 +1,814 @@ +# Les 11 — Vercel AI SDK +## Docenttekst (Klas A — 3 uur, fysiek, demo-driven) + +**Les:** 11 van 18 +**Onderwerp:** Vercel AI SDK — AI features bouwen, gekoppeld aan eigen dataset +**Duur:** 180 minuten +**Format:** Tim demonstreert klassikaal. Studenten kijken mee. Zelf bouwen = thuis. +**Demo-app:** Polderfest 2027 — fictief muziekfestival, 500 records in Supabase. + +--- + +## Hoe deze tekst werkt + +Dit document is een **lopend script**. Je kunt 'm letterlijk volgen op je laptop terwijl je lesgeeft. + +- `[SLIDE X]` — Klik naar slide X op de beamer +- `[SCHERM: slides | terminal | editor | browser | supabase]` — Welk scherm op de beamer +- **Vertel:** "..." — Letterlijk wat je zegt (mag in eigen woorden) +- `*[stage direction]*` — Korte instructie voor jezelf, niet uitspreken +- Code blocks = wat je typt +- 💬 = verwachte studentenvraag + +--- + +## VÓÓR DE LES — Setup (60 min) + +### 1. Tools open op je laptop +- VS Code / Cursor — leeg +- Terminal — open in `~/` +- Browser tabs: + - https://supabase.com/dashboard (ingelogd) + - https://platform.openai.com (key paraat) + - `localhost:3000` tab (nog niets) + - Dit docenttekst-bestand + - De slides PDF / PPTX + +### 2. Demo-repo `polderfest-demo` voorbereiden +```bash +cd ~ +npx create-next-app@latest polderfest-demo \ + --typescript --tailwind --app --eslint --no-src-dir --turbopack +cd polderfest-demo +npm i @supabase/supabase-js ai @ai-sdk/openai zod dotenv +npm i tsx --save-dev +git init && git add . && git commit -m "init" +``` + +### 3. Nieuwe Supabase project +- Dashboard → **New Project** → naam `polderfest-demo` +- Wacht ~2 min op deploy +- Settings → API → kopieer URL + anon key + service role key +- `.env.local`: + ``` + NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY=... + SUPABASE_SERVICE_ROLE_KEY=... + OPENAI_API_KEY=sk-proj-... + ``` + +### 4. Schema runnen +- Supabase → SQL Editor → plak `schema.sql` → Run +- Check Table Editor → `bands` tabel bestaat, leeg + +### 5. Seed test (verwijder daarna) +- Plaats `seed-polderfest.ts` in `polderfest-demo/scripts/` +- `npx tsx scripts/seed-polderfest.ts` → 500 records erin → check Table Editor +- **Wis** alle records vóór de les: `delete from bands;` (zodat je live kunt seeden in demo 2) +- **Verwijder** `app/chat/page.tsx` + `app/api/chat/route.ts` (maken we live in demo 3) + +### 6. Backup +- Zip van werkende eindstaat → op USB +- Check OpenAI usage dashboard — key werkt + credits aanwezig + +--- + +# HET SCRIPT — Lees mee tijdens de les + +## BLOK 1 — Welkom + Terugblik (10 min) + +`[SLIDE 1]` `[SCHERM: slides]` + +**Vertel:** "Welkom bij les 11. Vandaag de Vercel AI SDK — eerste keer dat we échte AI features gaan bouwen IN onze apps. Geen ChatGPT openen meer — AI in onze eigen code." + +`[SLIDE 2 — Terugblik]` + +**Vertel:** "Even kort terug: vorige lessen hebben we Supabase geïntegreerd. Tabellen en relaties opgezet. RLS-policies bekeken — wie mag wat lezen en schrijven. + +Vandaag iets anders. We gaan **niet** voortbouwen op QuickPoll. We beginnen een nieuwe demo from scratch. Nieuwe Next.js app, nieuwe Supabase, en dan koppelen we daar de AI SDK aan." + +`*[Wacht 2 sec, laat het landen]*` + +`[SLIDE 3 — Planning]` + +**Vertel:** "Dit is de planning. Drie uur. Eerst theorie — 30 minuten — wat is de AI SDK, welke modellen, welke functies. Daarna vier demo's. Eén: nieuwe app opzetten. Twee: 500 records in Supabase via een seed-script. Pauze. Drie: AI SDK installeren en chatten met de data. Vier: vragen stellen aan die data." + +`*[Wijs naar de gele rij]*` + +**Vertel:** "Belangrijk — dit is een **kijk-les**. Jullie typen vandaag niet mee. Pak je notitieboek of laptop voor aantekeningen. Thuis bouw je zelf een versie, met je eigen thema. Daar gaan de lesopdracht en huiswerk over." + +💬 Verwachte vraag: *"Kunnen we niet meedoen?"* +Antwoord: "Liever niet — als jullie ook typen, gaat 't te langzaam en haakt iedereen op een ander moment af. Vanavond is voor zien-en-snappen. Thuis is doen." + +--- + +## BLOK 2 — Theorie AI SDK (30 min) + +`[SLIDE 4 — Wat is de AI SDK]` `[SCHERM: slides]` + +**Vertel:** "Wat is de Vercel AI SDK? Een TypeScript-library die één unified API biedt voor alle AI-providers. OpenAI vandaag, Anthropic morgen, Google overmorgen, lokaal Ollama als je dat wil — je code blijft hetzelfde. + +Open source. Gemaakt door Vercel — de makers van Next.js. Daarom: naadloze integratie met Server Components, Server Actions en streaming. + +Wat zit er in:" + +`*[Wijs naar de bullets]*` + +**Vertel:** "Unified API. Streaming out-of-the-box — geen WebSocket-gedoe. React hooks zoals `useChat`. Tool Calling — komt volgende les. En type-safe gestructureerde output via Zod." + +`*[Wijs naar het code-blok rechts]*` + +**Vertel:** "Kijk hier. Dit is alle code die je nodig hebt voor één AI-call. Vier regels. En zie je `openai('gpt-4o-mini')`? Als ik dat morgen wil veranderen naar Anthropic — verander ik dat in `anthropic('claude-sonnet-4')`. Eén regel. Rest van mijn code blijft hetzelfde. Dat is de waarde." + +`[SLIDE 5 — Modellen + kosten]` + +**Vertel:** "Het modellen-landschap. Loop ik even langs:" + +`*[Wijs per rij]*` + +- "**gpt-4o-mini** — je default. Snel, goedkoop, $0.15 input / $0.60 output per miljoen tokens. Goed voor 80% van de use cases." +- "**gpt-4o** — multimodal, kan plaatjes lezen. 15× duurder dan mini. Pas pakken als nodig." +- "**gpt-4.1** — beste reasoning. Voor agents. Volgende lessen relevant." +- "**claude-sonnet-4** — Anthropic. Beter in coding, 200k context — dus lange documenten." +- "**gemini-2.5-flash** — Google. Ultra goedkoop, multimodal." +- "**llama-3.3-70b** op Groq — open-source model op snelste inference platform." + +**Vertel:** "Vuistregel: start met gpt-4o-mini. Werkt 't niet goed genoeg? Probeer gpt-4o. Pas daarna iets exotisch. Premature optimization is een valkuil — het is letterlijk één regel veranderen om te wisselen, dus geen reden om voorbarig te kiezen." + +`*[Wijs naar de blauwe callout]*` + +**Vertel:** "Onze hele les vandaag, inclusief Polderfest met 500 bands en 10 vragen? Ongeveer 1 tot 2 cent. Echt. Schaalt prima." + +`[SLIDE 6 — 4 kern-functies]` + +**Vertel:** "De vier kern-functies van de SDK. Deze tabel is je cheat-sheet." + +`*[Wijs per rij]*` + +- "**generateText** — wachten tot AI klaar is, dan krijg je een string. Voor korte server-only calls." +- "**streamText** — streamt karakter voor karakter. Werkt met useChat. Dit gebruiken we vandaag." +- "**useChat** — React hook. Complete chat UI in 10 regels. Ook vandaag." +- "**generateObject** — type-safe data via Zod schema. Voor database-inserts of classificatie. Vandaag niet — komt later." + +**Vertel:** "Onthoud: streamText en useChat — onze combo voor vandaag. generateObject zien jullie volgende lessen terug. Tool Calling — onderaan — dat is volgende les." + +--- + +## BLOK 3 — Live Demo 1: Next.js + Supabase scaffold (20 min) + +`[SLIDE 7 — Polderfest concept]` `[SCHERM: slides]` + +**Vertel:** "Voor we gaan coderen — wat bouwen we eigenlijk? + +We bouwen **Polderfest 2027**. Een fictief Nederlands muziekfestival. 500 verzonnen bands. Allemaal namen die niet bestaan. Geen Spotify, geen Pitchfork — pure fantasy." + +`*[Wijs naar de gele 'Waarom een fictief festival' callout]*` + +**Vertel:** "Waarom fictief? Omdat **geen enkele LLM** dit kan weten. Geen training data over Polderfest 2027 — bestaat niet. En dat is precies wat we willen demonstreren: AI alleen kan dit niet. AI mét onze data, wél." + +`*[Wijs naar het schema-blok]*` + +**Vertel:** "Onze tabel heeft deze velden: naam, genre, sub-genre, stage, dag, starttijd, stad, members, bio, tier, populariteit, ticket-impact. Genoeg variatie voor leuke vragen straks." + +`*[Wijs naar voorbeeld-vragen]*` + +**Vertel:** "Dingen die we straks aan onze AI gaan vragen: welke bands spelen vrijdagavond op de Main Stage? Vat de hip-hop scene samen. Welke acts komen uit Groningen? Allemaal vragen die ChatGPT niet kan beantwoorden — want hij weet niets van Polderfest. Maar onze chat straks wel." + +`*[Pauze, ademen]*` + +**Vertel:** "Goed — laten we 't gaan bouwen. Eerst Next.js en Supabase opzetten." + +--- + +`[SLIDE 8 — LIVE DEMO 1]` `[SCHERM: slides]` + +**Vertel:** "Dit zijn de 6 stappen die we nu gaan doorlopen. Ongeveer 20 minuten. Volg even mee — niet meetypen, kijk." + +`[SCHERM: terminal]` + +**Vertel:** "We beginnen in de terminal." + +#### Stap 1 — Next.js scaffolden + +```bash +cd ~ +npx create-next-app@latest polderfest-demo \ + --typescript --tailwind --app --eslint --no-src-dir --turbopack +``` + +`*[Druk enter, wacht ~30 sec]*` + +**Vertel terwijl het installeert:** "Standaard Next.js 15 met App Router, Tailwind, TypeScript. App Router omdat we Server Components willen. Tailwind voor styling. Niets bijzonders aan deze setup — dit kennen jullie al uit eerdere lessen." + +`*[Wacht tot install klaar is]*` + +```bash +cd polderfest-demo +code . +``` + +`[SCHERM: editor]` + +**Vertel:** "Editor open. Niets in `app/page.tsx` — standaard Next.js welkomstpagina. Standaard `app/layout.tsx`. Tailwind config. Niks bijzonders." + +#### Stap 2 — Supabase project aanmaken + +`[SCHERM: browser → supabase.com/dashboard]` + +**Vertel:** "Nu Supabase. Ik heb nog géén project voor deze demo — we maken er een nieuwe." + +`*[Klik New Project]*` + +**Vertel:** "Naam: `polderfest-demo`. Database password — kies wat, hoeft niet kopiëren. Region: West Europe. Submit." + +`*[Wacht ~2 min — gebruik deze tijd voor de uitleg hieronder]*` + +**Vertel:** "Terwijl het deployt: waarom een nieuw project? Omdat we van scratch beginnen. Geen vermenging met je QuickPoll-data van eerdere lessen. Clean slate. Voor je eindopdracht en huiswerk geldt: één app = één Supabase project." + +#### Stap 3 — Schema runnen + +`*[Supabase deploy is klaar]*` + +`[SCHERM: supabase → SQL Editor]` + +**Vertel:** "Schema-tijd. Open SQL Editor. New Query." + +`*[Plak inhoud van schema.sql]*` + +**Vertel:** "Dit is mijn schema voor de bands-tabel. Naam, genre, stage, dag, tijd, members, bio. Een paar indexen voor performance. RLS aan en een policy: bands zijn publiek leesbaar. Voor onze chat hebben we read-access nodig, geen schrijfrechten." + +`*[Klik Run]*` + +`[SCHERM: supabase → Table Editor]` + +**Vertel:** "Check — tabel `bands` bestaat. Leeg. Klaar om gevuld te worden." + +#### Stap 4 — Env vars + +`[SCHERM: supabase → Settings → API]` + +**Vertel:** "Drie dingen pak ik hier op: de Project URL, de anon public key, en de service_role secret key. Die laatste is belangrijk — straks bij het seed-script." + +`*[Kopieer alle drie]*` + +`[SCHERM: editor → .env.local]` + +``` +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=... +SUPABASE_SERVICE_ROLE_KEY=... +OPENAI_API_KEY=sk-proj-... +``` + +**Vertel:** "Belangrijke nuance — let goed op de namen: + +- `NEXT_PUBLIC_SUPABASE_URL` en `_ANON_KEY` — die staan in client-bundle. Mag — anon key heeft alleen leesrechten via RLS. +- `SUPABASE_SERVICE_ROLE_KEY` — geen `NEXT_PUBLIC_` prefix. Server-only. Deze key bypasst RLS — daarmee kan alles. Lekken = ramp. Gebruiken we alleen lokaal voor het seed-script. +- `OPENAI_API_KEY` — geen `NEXT_PUBLIC_` prefix. Anders zit-ie in je client-bundle en kan iedereen 'm gebruiken op jouw kosten. Server-only altijd." + +#### Stap 5 — Supabase client + +`[SCHERM: editor]` + +`*[Nieuwe file: lib/supabase.ts]*` + +```typescript +import { createClient } from "@supabase/supabase-js"; + +export const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, +); +``` + +**Vertel:** "Standaard Supabase client. Voor onze chat — dat is alles wat we nodig hebben. Niets bijzonders. Kennen jullie." + +#### Stap 6 — Dev server check + +`[SCHERM: terminal]` + +```bash +npm run dev +``` + +`[SCHERM: browser → localhost:3000]` + +**Vertel:** "Standaard Next.js welkomstpagina. Werkt. Supabase staat. Schema staat. Klaar voor data." + +--- + +## BLOK 4 — Live Demo 2: Seed script — 500 records (20 min) + +`[SLIDE 9 — LIVE DEMO 2]` `[SCHERM: slides]` + +**Vertel:** "Demo 2. We gaan onze bands-tabel vullen. Niet handmatig — met een seed-script. 500 records in ~30 seconden." + +`[SCHERM: editor]` + +`*[Maak folder: scripts/. Plaats seed-polderfest.ts erin]*` + +#### Stap 1 — Het seed-script bekijken + +`[SCHERM: editor → seed-polderfest.ts]` + +**Vertel:** "Dit is mijn seed-script. 200 regels. Laat me even door de structuur lopen — niet alle regels lezen, alleen de aanpak." + +`*[Scroll naar top]*` + +**Vertel:** "Bovenaan: Supabase client. Belangrijk — met de **service role key**. Niet anon. Want we gaan inserts doen. RLS blokkeert dat anders." + +`*[Scroll naar de bouwstenen arrays]*` + +**Vertel:** "Hier zijn mijn bouwstenen. Adjectives — 'Lost', 'Velvet', 'Iron', 'Neon'. Nouns — 'Tigers', 'Wolves', 'Mirrors'. Cities — Nederlandse steden. Genres — muziekgenres." + +`*[Scroll naar generateBandName]*` + +**Vertel:** "Hier de naam-generator. Vier patronen: +- 'Lost Tigers' — adjective + noun +- 'De Wolves' — Dutch prefix + noun +- 'Sanne Van Dijk' — solo artist +- 'Sanne & The Wolves' — solo + collectief + +Met 30 adjectives × 30 nouns = al 900 unieke combinaties mogelijk. Genoeg voor 500 records." + +`*[Scroll naar generateBio]*` + +**Vertel:** "Bio's. Drie blokken — opening, middle, ending — gecombineerd. 'Begonnen in een garage in [stad]', '[band] experimenteert met analoge synths', 'Debuut-EP eind 2027'. Compositioneel. Geen handmatig getypte bio's — 500× zou krankzinnig zijn." + +`*[Scroll naar bottom — async function seed]*` + +**Vertel:** "De main functie. Genereert 500 bands, dedupe op naam, insert in batches van 100 — Supabase trekt 500 in één keer niet altijd. Done." + +#### Stap 2 — Service role key uitleggen + +**Vertel:** "Even pauze voor één belangrijk ding — de service role key. Die zit boven aan dit script. Drie regels die jullie moeten onthouden: + +1. **Alleen lokaal gebruiken.** Niet in productie code. Niet in client. Alleen scripts. +2. **Nooit committen** naar git. Service role key in `.env.local`, en `.env.local` in `.gitignore`. +3. **Lekt-ie?** Direct draaien in Supabase dashboard → Settings → API → Reset service role key. + +Vergelijk het met een root password. Behandel 'm zo." + +#### Stap 3 — Dependencies (was al klaar — kort tonen) + +`[SCHERM: terminal]` + +```bash +# We hebben deze al uit setup, maar voor je eigen project: +npm i @supabase/supabase-js dotenv +npm i tsx --save-dev +``` + +#### Stap 4 — Run het seed-script + +`[SCHERM: terminal]` + +```bash +npx tsx scripts/seed-polderfest.ts +``` + +**Vertel terwijl het runt:** "Daar gaat 'ie. tsx is een TypeScript-runner die geen build-stap nodig heeft. dotenv leest de `.env.local` automatisch. 500 bands genereren, vijf batches van 100, klaar." + +`*[Wacht ~10-30 sec, output verschijnt]*` + +``` +Genereren van 500 Polderfest bands... +Schrijven naar Supabase in batches van 100... + ✓ 100/500 + ✓ 200/500 + ✓ 300/500 + ✓ 400/500 + ✓ 500/500 +Klaar! 500 Polderfest bands staan in Supabase. +``` + +**Vertel:** "Done." + +#### Stap 5 — Verificatie + +`[SCHERM: supabase → Table Editor → bands]` + +`*[Klik refresh — 500 records verschijnen]*` + +**Vertel:** "500 bands. Allemaal verzonnen. Laten we er even één openklikken." + +`*[Klik op willekeurige rij, toon bio]*` + +**Vertel:** "Kijk — 'Begonnen in een garage in Groningen, De Tigers experimenteert met analoge synths en gefluisterde lyrics. Polderfest is hun grootste festival tot nu toe.' Compleet verzonnen. Geen Wikipedia, geen Spotify — pure fantasy. Maar overtuigend genoeg voor onze AI om mee te werken." + +#### Stap 6 — Quick check met SQL + +`[SCHERM: supabase → SQL Editor]` + +```sql +select genre, count(*) from bands group by genre order by count desc; +``` + +`*[Run]*` + +**Vertel:** "Genre-verdeling. ~30 per genre. Mooi gespreid. Klaar om mee te chatten." + +--- + +## BLOK 5 — Pauze (15 min) + +`[SLIDE 10 — Pauze]` `[SCHERM: slides]` + +**Vertel:** "Pauze. 15 minuten. Tot zo." + +`*[Coffee. Stretch. Check je OpenAI key nog even.]*` + +--- + +## BLOK 6 — Live Demo 3: AI SDK + chat-route (30 min) + +`[SLIDE 11 — LIVE DEMO 3]` `[SCHERM: slides]` + +**Vertel:** "Welkom terug. Nu de echte AI-stap. We bouwen een chat-route in onze API en een chat-pagina in Next.js. Daarmee kunnen we vragen stellen aan onze Polderfest-data." + +`[SCHERM: terminal]` + +#### Stap 1 — Packages + +```bash +npm i ai @ai-sdk/openai zod +``` + +**Vertel:** "Drie packages. `ai` is de SDK zelf. `@ai-sdk/openai` is de provider — we gebruiken OpenAI vandaag. `zod` is voor schema validatie. Vandaag gebruiken we 'm niet, maar volgende les wel." + +#### Stap 2 — Chat API route + +`[SCHERM: editor]` + +`*[Maak file: app/api/chat/route.ts. Typ live, niet pasten — geeft studenten tijd om te volgen]*` + +```typescript +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(); +``` + +**Vertel:** "API route. POST, want we ontvangen messages van de chat. We destructuren de messages-array." + +```typescript + // 1. Haal alle bands op uit Supabase + const { data: bands, error } = await supabase.from("bands").select("*"); + if (error) throw error; +``` + +**Vertel:** "Stap 1 — ALLE bands ophalen uit Supabase. Voor 500 records werkt dit prima. Voor 50.000 niet — volgende les lossen we dat op met Tool Calling. Vandaag: simpele aanpak, alles meesturen." + +```typescript + // 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"); +``` + +**Vertel:** "Stap 2 — we maken één grote tekst-context. Per band één regel met de belangrijkste velden. AI kan namelijk geen SQL, maar wel tekst lezen." + +```typescript + // 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.`; +``` + +**Vertel:** "Stap 3 — de system prompt. Dit is de **rol** die AI krijgt. Drie belangrijke instructies: +1. 'Verzin niets' — voorkomt hallucinaties. +2. 'Gebruik alleen bovenstaande data' — niet uit training-kennis halen. +3. 'Antwoord in het Nederlands' — anders krijg je Engels. + +Een goede system prompt is je hefboom. 50% van de kwaliteit komt hier." + +```typescript + // 4. Stream naar OpenAI + const result = streamText({ + model: openai("gpt-4o-mini"), + system, + messages, + }); + + return result.toDataStreamResponse(); +} +``` + +**Vertel:** "Stap 4 — de AI-call zelf. `streamText` — onze keuze van vandaag. Model gpt-4o-mini. System message, plus de berichten van de user. En `result.toDataStreamResponse()` zet 't om naar het juiste streaming-format voor useChat aan de client-kant." + +**Vertel:** "API route klaar." + +#### Stap 3 — Chat pagina + +`*[Nieuwe file: app/chat/page.tsx]*` + +```tsx +"use client"; + +import { useChat } from "ai/react"; + +export default function ChatPage() { + const { messages, input, handleInputChange, handleSubmit, status } = + useChat(); + + return ( +
+

Polderfest 2027 — vraag de AI

+ +
+ {messages.map((m) => ( +
+
+ {m.role === "user" ? "Jij" : "Festival AI"} +
+
{m.content}
+
+ ))} +
+ +
+ + +
+
+ ); +} +``` + +**Vertel:** "Client component — `'use client'` bovenaan. `useChat` hook regelt alles: messages-state, input-state, submit-handler, streaming. Vijf properties, geen extra useState nodig. + +UI is bewust simpel. Tailwind classes. Berichten van user blauw rechts, AI grijs links. Input + verzenden onderaan. Disabled tijdens streaming via `status !== 'ready'`." + +#### Stap 4 — Testen + +`[SCHERM: browser → localhost:3000/chat]` + +**Vertel:** "Naar `/chat`. Eerste vraag." + +`*[Typ in chat-input]*` + +``` +Hallo, wie ben jij? +``` + +`*[Druk Enter. AI antwoordt streamend.]*` + +**Vertel terwijl AI antwoordt:** "Daar gaat 'ie. Karakter voor karakter. Streamt. Veel sneller voelend dan wachten op heel antwoord. UseChat regelt het, je hoeft niks zelf te doen voor streaming." + +**Vertel:** "Klaar. Werkt. Nu de leukste vraag: vragen aan onze data." + +--- + +## BLOK 7 — Live Demo 4: Vragen aan onze data (15 min) + +`[SLIDE 12 — LIVE DEMO 4]` `[SCHERM: slides]` + +**Vertel:** "We gaan vijf vragen stellen. Eén voor één. Bij elke vraag — let op wat de AI doet, en hoe dat anders is dan een SQL query of een gewone chatbot." + +`[SCHERM: browser → /chat]` + +#### Vraag 1 — Filter + +`*[Type in chat]*` + +``` +Welke bands spelen zaterdag op de Beach Stage? +``` + +`*[AI antwoordt — geeft een lijst]*` + +**Vertel:** "Filter. AI heeft door de tekst-context gefilterd. Even bevestigen met SQL —" + +`[SCHERM: supabase → SQL Editor]` + +```sql +select name, start_time from bands +where day = 'Zaterdag' and stage = 'Beach Stage'; +``` + +`*[Run]*` + +**Vertel:** "Zelfde resultaat. Maar het verschil — onze chat geeft natuurlijke taal, kan vervolgvragen aan, kan samenvatten. SQL doet alleen filter + select." + +#### Vraag 2 — Sort + Reasoning + +`[SCHERM: browser → /chat]` + +``` +Geef me 3 headliners met de meeste popularity, en hun bio's +``` + +`*[AI antwoordt]*` + +**Vertel:** "Sort. AI heeft op `popularity` gesorteerd en de top 3 gepakt. Plus de bio's erbij." + +`*[Vraag 2b in zelfde chat:]*` + +``` +En welke daarvan zou je aanraden voor iemand die houdt van techno? +``` + +`*[AI antwoordt — beargumenteerd]*` + +**Vertel:** "Dit is **reasoning**. AI redeneert over genre + sub-genre + bio-tekst om een aanbeveling te doen. Een SQL query kan dit niet. Dit is waar AI waarde toevoegt bovenop pure data." + +#### Vraag 3 — Aggregate + +``` +Hoeveel jazz fusion acts spelen er totaal op Polderfest? +``` + +`*[AI antwoordt met een getal]*` + +**Vertel:** "Aggregate. AI heeft geteld. Even SQL-bevestigen —" + +`[SCHERM: supabase → SQL Editor]` + +```sql +select count(*) from bands where genre = 'Jazz Fusion'; +``` + +**Vertel:** "Klopt. Goed." + +#### Vraag 4 — Samenvatting (waar AI uitblinkt) + +`[SCHERM: browser → /chat]` + +``` +Vat de electronic-scene op Polderfest samen in 3 zinnen +``` + +`*[AI antwoordt met een echte samenvatting]*` + +**Vertel:** "Hier is geen SQL voor. Dit is **samenvatten**. AI heeft alle electronic acts gelezen, gewogen, en in 3 zinnen samengevat. Dit is de unieke kracht van AI bovenop data." + +#### Vraag 5 — De mist in (bewust) + +``` +Wie was de hoofdact van Polderfest 2025? +``` + +`*[AI antwoordt eerlijk dat hij dit niet weet]*` + +**Vertel:** "Goed. Geen verzinsels. AI zegt: 'mijn data is alleen 2027, dat zit er niet in'. Dat is de kracht van onze system prompt. Zonder die prompt zou-ie waarschijnlijk wat hallucineren." + +`*[Pauze, kijk de klas in]*` + +**Vertel:** "Zien jullie wat hier gebeurt? Geen LLM ter wereld kent Polderfest 2027. Geen Wikipedia, geen training-data. Maar onze chat beantwoordt alles — omdat **wij** de data leveren. AI + data = product." + +--- + +## BLOK 8 — Data + AI = kracht (5 min) + +`[SLIDE 13 — Data + AI = kracht]` `[SCHERM: slides]` + +**Vertel:** "Reflectie-moment. Drie scenario's:" + +`*[Wijs naar de grijze box]*` + +**Vertel:** "**Data alleen.** Wat heb je? SQL queries. Filter, sort, select. Geen taal, geen interpretatie. Gebruiker moet zelf SQL kunnen." + +`*[Wijs naar de roze box]*` + +**Vertel:** "**AI alleen.** ChatGPT zonder context. Generieke kennis uit training. Hallucineert. Geen privé data, geen live data." + +`*[Wijs naar de blauwe box]*` + +**Vertel:** "**Data + AI.** Wat we vandaag bouwden. Filter via reasoning. Antwoorden in natuurlijke taal. Samenvattingen, vergelijkingen, aanbevelingen. Schaalbaar — voeg data toe en je hebt nieuwe antwoorden mogelijk." + +`*[Pauze]*` + +**Vertel:** "Quote om mee weg te lopen:" + +`*[Wijs naar de gele callout]*` + +**Vertel:** "*Een LLM zonder jouw data is een gewone chatbot. Een LLM mét jouw data is een product.* Onthoud dit. Dit is de fundering van alle volgende lessen." + +--- + +## BLOK 9 — Lesopdracht + Huiswerk uitleg (20 min) + +`[SLIDE 14 — Lesopdracht]` `[SCHERM: slides]` + +**Vertel:** "Lesopdracht. Voor thuis — niet vanavond per se, maar vóór volgende les. Je bouwt **een eigen versie** van wat we vandaag deden. Met **je eigen thema**." + +`*[Loop checklist langs op slide]*` + +**Vertel:** "Eisen op een rij. Bedenk een eigen thema — moet fictief zijn. Nieuw Next.js project, nieuw Supabase. Eigen seed-script. Minstens 100 records. Chat-route en chat-pagina werkend. Drie vragen stellen die alleen werken dankzij jouw data." + +`*[Wijs naar pink callout]*` + +**Vertel:** "Inspiratie: fictief restaurant-aggregator in een verzonnen stad. Scriptie-archief van NOVI met 1000 nep-titels. Museumcollectie met verzonnen kunstenaars. D&D NPCs. Cryptid-sightings in Nederland. + +Belangrijk: **moet fictief zijn**. Als je echte restaurants in Amsterdam pakt, weet ChatGPT die al — dan zie je niet wat we vandaag demonstreerden. Het hele punt is: data die geen LLM kent." + +💬 Verwachte vraag: *"Mag ik echt elk thema?"* +Antwoord: "Ja, zolang het fictief is en minstens 100 records heeft. Twijfel? Stuur 'm op Brightspace, dan kijk ik even mee." + +`[SLIDE 15 — Huiswerk]` + +**Vertel:** "Het huiswerk bouwt voort op de lesopdracht. Drie onderdelen — alle drie verplicht." + +`*[Loop A, B, C langs op slide]*` + +**Vertel:** "**Onderdeel A.** Pas het Polderfest seed-script aan voor jouw thema. Het script staat klaar als bijlage. Open 't, lees 't, en pas 't aan. AI mag je helpen — letterlijk: open OpenCode, plak m'n script erin, vraag 'pas dit aan voor [mijn thema]'. Klaar in een paar minuten. Daarna jij review. Minstens 200 records. + +**Onderdeel B.** Voeg minstens 1 extra veld toe aan je schema. Iets dat een **nieuwe vraag** mogelijk maakt. Niet zomaar een extra string-kolom. Concreet voorbeeld: een museumcollectie met `acquisition_story` veld — dan kun je vragen 'welke kunstwerken zijn op een veiling gekocht?'. Update seed-script, re-seed, test in chat. + +**Onderdeel C.** Schrijf een `AI-CHAT.md` in je repo-root. Drie secties: +- Mijn thema — wat is het, waarom kan een gewone LLM dit niet? +- 3 leuke vragen die werken +- 1 vraag waar AI moeite mee had + hoe je je prompt aanpaste." + +`*[Wijs naar gele callout]*` + +**Vertel:** "Bonus, geen verplichting: deploy op Vercel, loading skeleton, vergelijk gpt-4o-mini en gpt-4o." + +💬 Verwachte vraag: *"Hoe lang gaat dit duren?"* +Antwoord: "Lesopdracht ~2,5 uur. Huiswerk ~1,5 tot 2 uur. Samen een lange middag. Loop je vast — Brightspace of plan een korte 1-op-1 met me." + +--- + +## BLOK 10 — Vragen + Afsluiting (15 min) + +`[SLIDE 16 — Volgende les: Tool Calling]` `[SCHERM: slides]` + +**Vertel:** "Eén ding voor we afronden — wat komt hierna." + +`*[Wijs naar pink callout: Het schaal-probleem]*` + +**Vertel:** "Wat we vandaag deden: ALLE 500 bands sturen we mee als context bij elke vraag. Dat is ~30.000 tokens per call. Werkt prima voor 500. Werkt **niet** voor 5.000 records. En al helemaal niet voor 50.000." + +`*[Wijs naar blauwe callout: De oplossing]*` + +**Vertel:** "Volgende les — **Tool Calling**. In plaats van alle data meesturen, geef je AI **functies** die hij zelf kan aanroepen. Hij hoort vraag 'welke bands op vrijdag?' en besluit: ik roep `searchBands({ day: 'Vrijdag' })` aan. Krijgt 60 bands terug. Antwoordt. Schaalbaar tot duizenden records." + +**Vertel:** "Daarna in deze leerlijn: Agents met maxSteps. RAG met embeddings — semantic search op heel grote datasets. Testing, deployment, performance. En de laatste twee lessen: eindopdracht-werkdagen en je pitch." + +`[SLIDE 17 — Afsluiting]` + +**Vertel:** "Vragen?" + +`*[Open de vloer. Verwachte vragen + antwoorden:]*` + +💬 *"Wat als ik geen schoolkey heb?"* +→ "Eigen OpenAI account — $5 starter credit zit erin gratis. Of Groq — gratis tier. Of Anthropic — $5 gratis credits." + +💬 *"Hoe weet ik welk model het beste is?"* +→ "Start met gpt-4o-mini. Upgrade alleen als het écht niet werkt. Premature optimization is een valkuil." + +💬 *"Kan dit lokaal zonder OpenAI?"* +→ "Ja, via Ollama. Niet vereist voor deze les. Komt eventueel in latere lessen." + +💬 *"Moet ik de Polderfest demo zelf ook namaken?"* +→ "Nee. Wat we vandaag deden is jullie laten zien. Voor jezelf bouwen = eigen thema, in lesopdracht en huiswerk." + +💬 *"Hoe duur is dit nou echt?"* +→ "Onze hele les vandaag met 500 bands en 10 vragen — onder de 2 cent. Met gpt-4o (de grote) zou hetzelfde ~30 cent zijn. Met mini blijft het peanuts." + +`*[Sluit af]*` + +**Vertel:** "Zorg dat je vóór volgende les minstens je seed-script werkend hebt voor jouw thema. Dan kunnen we volgende les meteen Tool Calling toepassen. Tot dan!" + +--- + +## Backup-onderwerpen (als tijd over is) + +1. **Andere provider tonen** — Open `route.ts`, vervang `openai("gpt-4o-mini")` door `anthropic("claude-sonnet-4")`. Werkt direct. Eén regel. +2. **System prompt fine-tuning** — Verzwak de prompt ("Help bij vragen"). Vraag iets. Verzin de fout. Versterk weer ("Verzin niets, gebruik alleen onze data"). Toon verschil. +3. **Token-kosten dashboard** — Open platform.openai.com/usage. Toon je verbruik van vandaag — letterlijk een paar cent. +4. **Privacy / data retention** — Wat gaat naar OpenAI? Zero-data-retention via EU-endpoints. Belangrijk voor productie. +5. **Hallucinatie-test** — Probeer met zwakke prompt of de AI iets verzint over Polderfest 2025. Toon dat sterkere prompt dit fixt. diff --git a/Les11-AI-SDK/Les11-Huiswerk.md b/Les11-AI-SDK/Les11-Huiswerk.md new file mode 100644 index 0000000..28ac384 --- /dev/null +++ b/Les11-AI-SDK/Les11-Huiswerk.md @@ -0,0 +1,181 @@ +# Les 11 — Huiswerk +## Seed-script aanpassen + uitbreiden + reflecteren + +**Vak:** AI-Assisted Development +**Opleiding:** NOVI Hogeschool Utrecht +**Deadline:** Voor de volgende les (Les 12 — Tool Calling) +**Inleveren:** GitHub repo + `AI-CHAT.md` in root + +--- + +## Doel + +Bouwt voort op de **lesopdracht** (eigen thema-app). Hier: + +- **A.** Pas het seed-script aan voor jouw eigen thema (mag AI bij helpen) +- **B.** Voeg een **extra veld** toe dat een nieuwe vraag mogelijk maakt +- **C.** Schrijf een **reflectie** over wat werkt en wat niet + +> Niet klaar met de lesopdracht? Eerst die afmaken — daarna komt dit. De huiswerkopdracht heeft de lesopdracht-app nodig om op te bouwen. + +--- + +## Onderdeel A — Seed-script voor jouw thema (verplicht) + +Het Polderfest seed-script is je voorbeeld. Pas het aan voor jouw eigen thema. + +### Stappen + +1. Open `seed-polderfest.ts` als referentie +2. Pas aan voor jouw thema: + - Domein-arrays (in plaats van adjectives + nouns → wat past bij jouw thema?) + - Veld-namen + types + - Bio/beschrijving-fragmenten (de samengestelde tekst-generatie) +3. Run je seed-script tegen je eigen Supabase +4. Verifieer 100+ records in Table Editor + +### Pro tip: AI als seed-script writer + +Open OpenCode (of Cursor) en typ: + +> Hier is het Polderfest seed-script. Pas het aan voor [mijn thema]. +> Mijn schema is: [paste schema] +> Genereer 200 records met realistisch-ogende variatie. + +AI doet dit in 1-2 minuten. Daarna jij review — controle. + +### Eisen + +- [ ] Werkende seed-script in `scripts/seed-[thema].ts` +- [ ] Minimaal **200 records** in Supabase (was 100 voor lesopdracht — nu meer) +- [ ] Procedureel gegenereerd (niet handmatig — gebruik combinaties) +- [ ] In je README: korte uitleg hoe je 't gegenereerd hebt + +--- + +## Onderdeel B — Extra veld + nieuwe vraag (verplicht) + +Voeg minstens **1 extra veld** toe aan je schema. Iets dat een **nieuwe interessante vraag** mogelijk maakt. + +### Voorbeelden + +| Bestaand veld | Extra veld | Nieuwe vraag mogelijk | +|---------------|-----------|----------------------| +| Restaurant — `cuisine` | `dietary_options: string[]` | "Welke veganistische opties zijn er?" | +| Scriptie — `year` | `keywords: string[]` | "Vat scripties over AI samen" | +| Festival-band — `tier` | `collaborations: string[]` | "Welke acts hebben samengewerkt met X?" | +| Museumstuk — `period` | `acquisition_story: string` | "Welke kunstwerken zijn op een veiling gekocht?" | + +### Stappen + +1. Update je `schema.sql` met het extra veld +2. Run de SQL in Supabase (kun je `ALTER TABLE` gebruiken — niet alles opnieuw) +3. Update je seed-script om het nieuwe veld te vullen +4. Re-seed je tabel (eerst wis bestaande records: `delete from items;`) +5. Test in chat: stel een vraag die alleen kan dankzij het nieuwe veld + +### Eisen + +- [ ] Nieuw veld toegevoegd aan schema +- [ ] Seed-script gevuld voor nieuwe veld +- [ ] 1 vraag aan chat die specifiek dit veld gebruikt — werkt +- [ ] Screenshot van die vraag + AI antwoord in `AI-CHAT.md` + +--- + +## Onderdeel C — `AI-CHAT.md` reflectie (verplicht) + +Schrijf een markdown-bestand `AI-CHAT.md` in je repo-root met: + +### Sectie 1: Mijn thema +- Wat is het thema? +- Waarom **kan een gewone LLM** deze vragen niet beantwoorden zonder jouw data? +- Welke velden heb je gekozen en waarom? +- Welk extra veld heb je toegevoegd (onderdeel B)? + +### Sectie 2: 3 leuke vragen die werken +Voor elke vraag: +- De vraag zelf +- Het antwoord van de AI (screenshot of plak-tekst) +- Waarom dit een goede demo is + +### Sectie 3: 1 vraag waar AI moeite mee had +- Welke vraag was het? +- Wat ging er mis (vaag antwoord, hallucinatie, foute filter)? +- Hoe heb je je **system prompt** aangepast om dit op te lossen? +- Werkt de vraag nu wel? + +### Vorm +- Max 600 woorden in totaal +- Concrete voorbeelden (geen vage reflectie) +- Mag wat informeel — geen scriptie-toon nodig + +--- + +## Bonus (optioneel, niet verplicht) + +Iets extra's? Mag, geen extra punten maar wel leerzaam: + +- **Deploy op Vercel** + production URL in je README +- **Loading skeleton** in de chat UI (terwijl AI antwoord aan het streamen is) +- **Vergelijking gpt-4o-mini vs gpt-4o** — beschrijf het verschil in antwoorden +- **System prompt variaties** — drie prompts proberen, screenshot per variant +- **Themed UI** — Tailwind aanpassen zodat 't past bij thema (kleuren, fonts) + +--- + +## Inleveren + +1. **GitHub repo URL** in Brightspace +2. **`AI-CHAT.md`** in repo-root +3. **Seed-script** in `scripts/seed-[thema].ts` +4. **Updated schema** in `schema.sql` (met extra veld) +5. **Screenshots** ingevoegd in `AI-CHAT.md` + +Optioneel: Vercel deploy URL als bonus. + +--- + +## Beoordeling + +| Criterium | Punten | +|-----------|--------| +| A — Seed-script werkt + procedureel + 200+ records | 3 | +| B — Extra veld + werkende nieuwe vraag | 2 | +| C — `AI-CHAT.md` aanwezig met 3 secties | 3 | +| Chat werkt end-to-end (geen broken pages) | 1 | +| Reflectie sectie C is concreet (geen fluff) | 1 | +| **Totaal** | **10** | + +Voldoende = 6+. Bonus telt mee bij twijfelgevallen. + +--- + +## Tijd-indicatie + +| Onderdeel | Tijd | +|-----------|------| +| A — Seed-script aanpassen (met AI hulp) | 30-45 min | +| B — Schema uitbreiden + nieuwe veld + vraag testen | 30 min | +| C — AI-CHAT.md schrijven met screenshots | 30-45 min | +| **Totaal** | **~1,5 - 2 uur** | + +--- + +## Veelvoorkomende valkuilen + +- **Thema dat LLM al kent** — Yelp-clone, Spotify-data. Werkt niet voor demo van data-power. +- **Te weinig records** — 200+ vereist, anders is variatie te klein +- **Vage system prompt** — "Help bij vragen" werkt slecht. Wees specifiek. +- **Geen reflectie op slechte vragen** — sectie C wordt vaak vergeten, terwijl daar je leercurve zit +- **AI verzint feiten** — system prompt versterken: "Verzin NIETS. Gebruik alleen onze data." + +--- + +## Tips + +- **Schrijf je AI-CHAT.md gaandeweg** — niet aan einde. Sla goede prompts/screenshots op zodra ze werken. +- **Maak de prompt-iteratie expliciet** — sectie C wordt mooier als je echt 3-4 prompt-versies probeert. +- **Niet bang voor AI in je workflow** — laat OpenCode het seed-script schrijven. Tijdwinst is enorm. + +Succes! Volgende les pakken we Tool Calling — dan kun je dezelfde demo schaalbaar maken naar 50.000 records. diff --git a/Les11-AI-SDK/Les11-Huiswerk.pdf b/Les11-AI-SDK/Les11-Huiswerk.pdf new file mode 100644 index 0000000..4d54a32 --- /dev/null +++ b/Les11-AI-SDK/Les11-Huiswerk.pdf @@ -0,0 +1,175 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 6 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +7 0 obj +<< +/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +9 0 obj +<< +/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +10 0 obj +<< +/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +11 0 obj +<< +/PageMode /UseNone /Pages 13 0 R /Type /Catalog +>> +endobj +12 0 obj +<< +/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260519160527+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260519160527+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Les 11 Huiswerk) /Trapped /False +>> +endobj +13 0 obj +<< +/Count 6 /Kids [ 4 0 R 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R ] /Type /Pages +>> +endobj +14 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2227 +>> +stream +GauHL>BcPr&:WeDbY-8R]="@@#gnu%Bp(A;VqYFgksNo)%#RE0_$/Wlr>fkip_q2t2U8Qn8>H!VDe;2LJDu@7hpDkjob`_H0OPm?2o&\X_Y?9aO.DmQiKF)@#=Ksi!Y-Iejq!?KPCY*<%!]QQ;4f8o"]mFQ<1nd%38\@4'H1jq3"1TrWNTPLMKr+A[IJi>c>dTcGZ"aHj'2PJEA&H?oa]eorm;SV?nEdB8931Xl,Xh+&:[P:>XA="qsSY6N5FS/!A8iF`.9n[m?-ge^d9^+HN\B+0V!#*dSPDREQ<1CRZp1IQ/W*:Id^LK0XgSVmmO72LQFb^,)iUfogZ*5iM>X-tbT"dj3BQ,M@9m'dQk+?GNa,Sa%cWOCGk-2c7J^W#6P!VdYHX9Wt)W!dG9jG?Kes^e!,\i'I.p-Ip(EJh]:30f5#EV\^^j15dZ+RMMIie"X-b&t:J0e5F?G=H(.7i(O<5&E:c1ugfMRn]VlsF"4F0?Yi#$sUfTa@Mm*SShTuk_:+&S!W,D8Z*/4"[olC1I7A9hY0i&H&UEN-j(9pVW7^doR\^P,+!9Vs&UN1o8S>7(f&60:fnP,Z3iYoY-$>,qcYYg7/6?mdftFb/VT=h;nPXhcZd1=a4Fm:Z.b5-nC)PVBj,P8Y*HeLR7%^n%qaBSVPWo$pl\Yu=S]$:WgWbp%@-7;,.<1??jSIB[T40Paa=s'JPhk9"+7lA_8ahA@-UG=<^H'>br%Ooi%+A>nAm:PtTXZO+q0L;n`gPn31;qGF*]e^.iEQ$n;hI_&L[]bT^gS8XNe"\hg,NYgN1FAmik,O9K\6S=g;lVjGmH_BPWU&VF>9]uqLkAaD]`Imj2`]*9?&Q5%MG9?Q*P?5p##5FpB*?`UP$W3LHSefk,('Ld9hE7FW\\:S=F>K%lnmZ6l"M%l:a)\pt*P$nn<3E4,u'cqege267XI?@L?fNW[P):*]$k-7*6>ngjVTYa<_6s-]Rk(n&@9K&9D5-iQs,B5S/jPF5nN55#e\:kQ&RcI'%D/=-jI!hmZKZuq^^(gO3i$nICc.tSs9,:7)cc^BTF^$]t4!0S8ul%J#ed3"IiJ5#:c.F.'r4U#tl0@lXYED*nSZogAS4t?1idJ3O%=EO$)n1P@Vcd%+DUomXV]\a';l.4fSbeKA[9h#"9TtG_&/L_UOgh3aQ=G##RRR,[#rWVgJf=3N_+l=ZjTV:%+.3^]`gb\n^BI~>endstream +endobj +15 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 444 +>> +stream +Gas2E92EGZ&;9NJ'm$,m@D%&E>7j:HnQ+St9.U,Y)jil7KJYq_SbM2B10tG'K()P3?m#MR(Yk$W$`?s?D1m$arN0GsKdC%Tgkm^n[QBqs(UP+GgJo19aa/k]Nm@=Bis!_dS?(VI-t[_&9-0+.oTf"2?6"-hC0]^^WQD1-=-q04WEg+^*rT=RDT[1^;J'>oXXZFOk.C1<<"/F#<][6FS2X8"'=%<)W[6p3S0CWe'f:7o2V,2?_Y,V2fcT2oY"+5XBlIGa@^?M0=5/]sOP5-AOg@oP-/N4g,ikIf`9\U;DQTB[GU];C[;!?j[u_>~>endstream +endobj +16 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1935 +>> +stream +Gb"/'gN)%,&:N_Cm%]`&&H]UrH-+]qe"e39NNFH:FXKOTCP`ZW1.bWV^V1)=+t6Yr8+e-uqBh'=0$[o:c7ZmuVf,o>oco!N*kZm@JIC[I"#lVS]e5iKZl=u2><&J`b`KEp$=L$1^*"G00j$???/]gc]#1=RJgi^DdZkrrc>g>r.Dkb!N<=6kA%/cJ((WhC$Ll1Y^,&B#V,/OuFI;H`2L0?kmoj-_ZjZ>o&K"X(+'gGk^EuF/D&<]pZ$SbtpEajOs9jG9pe:&,(0_VF@!:=YL..u6e:9/fN7A!VIJgb61A>3WQ!1SL:5D:9&B[=KJl&ktmG"s7'FDjfh.pc2[7T'P?-?8suIBW!:6Z;aksF>eqp$gu0I+(4N5GMeTU*[Gb!$F2c'ik_7:D2FuBs+]L"@_dO2;i(dYJ067AR(ZhnJ<\S5/2-oX:]jA7GNbl-!*=>R]UL3]Is7]Rh,i=N<6MR^g'f_i>1bV=+DDc/6r5-W!0YG^J$71"lM75SL)E;FN1;\Ph'XM:p#PVX)e8IT0o4Ec3_d7:H_XBMH(NR?tjiZ'QSmXfB#PJ<8HMB3q9AcN-)%?h!LZ(6gsfGHb.^`/;@dY,WoRlnB;tI*Fcbr5Y.WLd381SL>`>Odu!0/nX2JW-Q\[>4ArF]aq3#QLH^G`JR,/fAWUDdD+rhQU9f$!mW$)9W4XWiqElocr[DAW>;0=:e"Eg^6pc]PFSb/r\\5l01gH(]uSd*k#1+B$j317/?6Y,MG43h#kF==IT*Pkp#m?OoMZ)`ot"2(HpI@u%pd`tQdPYW*$1Y5s\?*m"QPU#'V\X&!6:D[.if3XU0C3-@JWQeI\XeMF+i)MfIkgL)eNq`n_IU"5uPQW6N-@$@$$YkcSNY+'.;tcL;:quqjrGdleH\dM4/Oi6(rr7DkPMXp(q3UPc)Cj+EqDq$Z34eKuN>-H=:;I[7'O=P@5jip&3,7b:I1o(&>KNThuK['B$Irf3)U:6MA/8TqY2&\?LZ\8**AlA`t'A?o;\?HU;k0T%%2./n@'j@$*S`IFpFINqi%Z9gs;eTh%.f>;HtVcq;!'b&WM%<.WO#i4ndFeJKJe]si#3R$FhkJBhemZY/AWOc9g\,sE1c(*\kW;B)b+1#EB2TGBq=%*kOdS95DKtH[cH0Z]0E,td@@@1hg,1iAQ.uPg@`IJMtj-jcK@%c5>?$^HmW1EAE/Mo\?%c>R8k3k%;?BZ\^rW1$n=_@~>endstream +endobj +17 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 771 +>> +stream +Gb!;abAQ&g&A7ljk*Q8X,O.QlV]pNl$'7?&]'[HQem?OW-..uk]D_W0AB0gLfKaH_>)A=NeF(WK/4Ul,;OD'HO(RlQe24Hc;s.mJW/V@Lg?*n'TnV`G%tHn%O@Cqa`#8:P\t2P28[4pgh[Bol=!+\ee@@Wa-bt#HJrO@]><_t+O'NoahESs%q@(n@$EZ*4/A03KFnq7=hRo.I]'XFf@.O]c85g;@^l6k720Rjapt/L1isW*Ail%dMoqD$9?,ZK:ftj[Tch4jN"1#B1T*jFQd'6??;J/1>0kZXngU^;oB++\"[4Onc?'KWJeUnA&`pI;d8bcB1"c[kK9$c[)+jINs$OU%]Au-gWJ~>endstream +endobj +18 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1607 +>> +stream +Gb!kt9m<'d']&Xf]VXB4":5,"@M[6ZS'8uJgOs[6%07?i_*C<)dojn5T-qFHZ&S/@a_#!04rA;.]6_oUHQceVMc>1Y$,D6"kFW%l$Jl="1Dk@\E:%dsFq!Y4Qe>bKgpHnlN4u-<+HJPifT+q_BqGAa?%]`t(afONI1?s`F'[cl/n"IZ6bmX]e)^uH:2G\IDk=EHLJjp[L@$u!p#hh>\.5]\3)+K3B)P1Q*'kK?F&\&8-F$hq]Er][r;,FhDUM&i_'i#fGZ;3Z7LG_8F^]7D)\duWPR-5?]S\\n-6c;/^`.q#;0+>J1Wq\DA\+r;GG5((i22aX<9,\imPd@nM%B:md!)#0)3R@V86tKtgbp#sA+uKHKjfS.M9eEHZrIc,X/pFsd\JGPWLIKlVMPlSdt\\7jT+FAj\WZ*J6C^2l)Jl7/[WOiFIcQlLu'5Z3l%,Z8hddBa-di*f(ZY+Nr6<%.sJ5j8C">"R`Gu=+=InNIg+h]qAtT.%.b6k=H6aq\gUK?QM)X\OmlO2QTOh:oDS;5\.6s]n-be"U0$G%;t0)8dT9?19l)[o(8%6*EZpTTPUaaB?B7_.Nm3R3hbj2H_S))!T]JAoU/]I*nPghYpjNj4bV*o^dPM2_A,Hck^TW>H2cI=a:M-K58W:\U/s:AcAXjcRU"h[tRY\4+6tS@?e;WWD>Rdt=?-4C6^IWKG53#M1V:mOPSqX&E[[E%Y7O(oDINWg88*;-ikoNHfj-4!nE-X8/N(LNNUn3*CSu-7X7!93"efGIWE=s@H#^bb]1/jZ!ep!#C?*82\M/3]ZoJh\g=m50D%P']L%\re:^JXjWSHoDY09uEbj^,IG7Sfc1hF$ao)4_iX`MSTsXm*MMPVIWuH;pPqLNn1o;7hZ_G>I[&oQ(LLFRl)/]2&jHcVD1PIG9Ia'J'OY&K=hZ(mi2ApdT0V];,doUh5G@S>9-%Pr=E.)Sccp.5`g8pg>ap(sE6^iQ*A9ZVVJMH:d:'4Cu6[LqN5eG&FN0WkO83oCW]!CBEXZ\4a$UG/imj*"O_d72((h."-)1G!qOS[^4fmdWTNdh-j6'B?Zh>aCs(]kl[1KoV1$endstream +endobj +19 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 830 +>> +stream +Gar>DflEQI&:E)2oSZA&'I;#5-ocm/.2eAlqC2n:ToB29=<(k#_Xn[q1-TmA`*f+g``"*`j6:leT9@m3@Y0,]A^RKgC`W6J03ArjeNoLYDLL^[TFDTJ=H9=64J5J'=)V?1kHMc!6=SlInlXUBjnCut3g#mHQg%%kA`8);gd0UPo2g]#sKLU<+aNb+!j7U]]*KM5aEuT7&Of`_r,?dmdf$es6kaf0)[9m-$%u)T[(3Z:L4TZF$g('AKK^*r7r2pepSJ#M'ZE.(QRq>.B`7aE9c)>iK^Nc]Z"=j*S_bl,&1o>l0oHuE-JoDJ=Kr#k+8r$PI@at\5p,TC>rLsF7Gs2Cet>R5JaJI8jeZWp>4`l"Ybe]t(KStiRaiFMQL[0s<"W@6W+$Yu#k\QIKcKEYbh#"3nMPC'6i)U5`@8bCgU;U&fjf+,nr9U+NcSNB;F7q^e#4qf+RHqKV0qPh!XY0;Ae\SR>laFJ\q@=ad6HK~>endstream +endobj +xref +0 20 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000536 00000 n +0000000741 00000 n +0000000846 00000 n +0000001051 00000 n +0000001256 00000 n +0000001461 00000 n +0000001667 00000 n +0000001737 00000 n +0000002030 00000 n +0000002121 00000 n +0000004440 00000 n +0000004975 00000 n +0000007002 00000 n +0000007864 00000 n +0000009563 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 12 0 R +/Root 11 0 R +/Size 20 +>> +startxref +10484 +%%EOF diff --git a/Les11-AI-SDK/Les11-Lesopdracht.md b/Les11-AI-SDK/Les11-Lesopdracht.md new file mode 100644 index 0000000..ab7877b --- /dev/null +++ b/Les11-AI-SDK/Les11-Lesopdracht.md @@ -0,0 +1,195 @@ +# Les 11 — Lesopdracht +## Bouw je eigen AI + Supabase app + +**Vak:** AI-Assisted Development +**Opleiding:** NOVI Hogeschool Utrecht +**Wanneer:** Thuis, vóór volgende les +**Inleveren:** GitHub URL + screenshot van werkende chat + +--- + +## Doel + +Bouw een complete versie van wat Tim live demonstreerde — **met je eigen thema**. Tim bouwde Polderfest 2027. Jij bouwt iets anders. + +Je oefent end-to-end: +- Next.js project from scratch +- Nieuwe Supabase aanmaken + koppelen +- Eigen seed-script schrijven (mag AI bij helpen) +- Chat-route + chat-UI met `streamText` + `useChat` +- 3 vragen kunnen stellen die alleen via jouw data beantwoord kunnen worden + +> **Belangrijk:** dit is **niet** dezelfde Polderfest-demo namaken. Je kiest een eigen thema. Anders leer je vooral kopiëren. + +--- + +## Vereisten + +### Jouw thema moet: +- [ ] **Volledig fictief zijn** — geen Spotify, geen restaurants in Amsterdam, geen Wikipedia-data +- [ ] Minstens 5 velden hebben (waarvan 1 categorisch, 1 numeriek, 1 tekstrijk) +- [ ] Minstens **100 records** bevatten +- [ ] Vragen oproepen die je écht niet aan ChatGPT kunt stellen zonder jouw data + +### Verboden thema's (te bekend voor LLMs): +- Restaurants in een echte stad +- Films / muziek / boeken die echt bestaan +- Sport-statistieken uit de echte wereld +- Klimaat / financiële data uit publieke bronnen + +### Geschikte thema-richtingen (kies of bedenk eigen): +- Fictief restaurant-aggregator in een verzonnen stad (bv. "Polderstad") +- Galactische bestuurders archief (sci-fi) +- Verzonnen scriptie-archief van NOVI (1000 nep-titels) +- Fictieve museumcollectie met verzonnen kunstenaars +- Fictief NPO-programma overzicht voor 2030 +- Verzonnen mysteries / cryptid-sightings database NL +- D&D campagne-NPCs voor een fictieve wereld +- Fictieve nederlands ondernemers ecosysteem + +--- + +## Stappenplan + +### Stap 1 — Thema kiezen + schema ontwerpen (30 min) + +- Bedenk thema +- Schrijf op papier (of in Notion): welke velden? Welke vragen wil je kunnen stellen? +- Vertaal naar Supabase SQL schema (zie `schema.sql` van Polderfest als voorbeeld) + +### Stap 2 — Next.js project + Supabase (15 min) + +```bash +npx create-next-app@latest mijn-thema \ + --typescript --tailwind --app --eslint --no-src-dir --turbopack +cd mijn-thema +npm i @supabase/supabase-js ai @ai-sdk/openai zod +npm i tsx dotenv --save-dev +``` + +- Maak nieuwe Supabase project aan +- Run je schema in SQL Editor +- Vul `.env.local` met URL + keys + OpenAI key + +### Stap 3 — Seed script (45 min) + +Open `seed-polderfest.ts` (zie bijlage) als referentie. Je hebt twee opties: + +**Optie A — Met de hand:** +- Kopieer de structuur +- Vervang Polderfest-bouwstenen (genres, stages, cities…) door jouw thema-bouwstenen +- Pas de `generateBand` functie aan naar `generateItem` voor jouw thema + +**Optie B — Met AI hulp (aanbevolen):** +- Open OpenCode / Cursor met `seed-polderfest.ts` als context +- Vraag: "Pas dit seed-script aan voor [mijn thema]. Schema is [paste schema]. Genereer 100+ records." +- Review wat AI maakt — vragen om aanpassingen waar nodig + +Run je seed: +```bash +npx tsx scripts/seed-mijn-thema.ts +``` + +Verifieer in Supabase Table Editor: 100+ records. + +### Stap 4 — Chat route (20 min) + +`app/api/chat/route.ts` — gebruik Polderfest-versie als template: + +```typescript +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(); + const { data: items } = await supabase.from("items").select("*"); + + const context = items!.map((i) => + `- ${i.name} (${i.category}, ${i.rating})` + ).join("\n"); + + const system = `Je bent een assistent voor [thema-naam]. +Hier is alle data: + +${context} + +Beantwoord vragen op basis van bovenstaande data. Verzin niets. +Antwoord in het Nederlands.`; + + const result = streamText({ + model: openai("gpt-4o-mini"), + system, + messages, + }); + + return result.toDataStreamResponse(); +} +``` + +### Stap 5 — Chat UI (15 min) + +`app/chat/page.tsx` — kopieer Polderfest UI, pas titel + placeholder aan. + +### Stap 6 — Testen + 3 vragen (15 min) + +Browse naar `/chat`. Stel deze 3 vragen aan jouw AI: + +1. Een **filter-vraag** ("Welke X hebben Y?") +2. Een **aggregatie-vraag** ("Hoeveel X zijn er in totaal?" / "Wie heeft de hoogste Z?") +3. Een **samenvatting-vraag** ("Vat de Z-categorie samen in 3 zinnen") + +Screenshots van werkende antwoorden bewaren — die heb je nodig. + +--- + +## Inleveren (vóór volgende les) + +1. **GitHub repo** met je code +2. **3 screenshots** van werkende chat-antwoorden in je README +3. **Eén alinea** onder de screenshots: waarom kan een gewone LLM deze vragen niet beantwoorden zonder jouw data? + +--- + +## Veelvoorkomende problemen + +| Probleem | Oplossing | +|----------|-----------| +| `OPENAI_API_KEY is not defined` | Dev server herstarten na `.env.local` aanpassen | +| Supabase insert: `permission denied` | Gebruik `SUPABASE_SERVICE_ROLE_KEY` in seed-script (geen anon) | +| AI verzint dingen | System prompt verstevigen: "Verzin niets. Gebruik alleen onze data." | +| AI antwoordt in Engels | Voeg toe aan prompt: "Antwoord in het Nederlands." | +| Chat hangt / streamt niet | API endpoint moet `result.toDataStreamResponse()` returnen | +| `tsx command not found` | `npm i tsx --save-dev`, run met `npx tsx ...` | + +--- + +## Tijd-indicatie + +| Stap | Tijd | +|------|------| +| Thema + schema bedenken | 30 min | +| Project + Supabase setup | 15 min | +| Seed script (met AI hulp) | 45 min | +| Chat route + UI | 35 min | +| Testen + screenshots | 15 min | +| **Totaal** | **~2,5 uur** | + +Loop je vast? Vraag in Brightspace of plan korte 1-op-1 met Tim. + +--- + +## Tips + +- **Begin klein.** 100 records is genoeg. 500 als je extra wil. +- **AI laten helpen bij seed.** Schaal je productiviteit 10×. +- **System prompt is je hefboom.** Goede prompt = goede antwoorden. Slechte prompt = AI verzint. +- **Test met simpele vraag eerst** ("Hoeveel records zijn er?"). Dan opbouwen. +- **Token cost in de gaten houden** — onze hele les kostte <2 cent. Jouw test ook. + +Succes! diff --git a/Les11-AI-SDK/Les11-Lesopdracht.pdf b/Les11-AI-SDK/Les11-Lesopdracht.pdf new file mode 100644 index 0000000..ad51cf7 --- /dev/null +++ b/Les11-AI-SDK/Les11-Lesopdracht.pdf @@ -0,0 +1,156 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 6 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +7 0 obj +<< +/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +9 0 obj +<< +/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +10 0 obj +<< +/PageMode /UseNone /Pages 12 0 R /Type /Catalog +>> +endobj +11 0 obj +<< +/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260519160527+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260519160527+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Les 11 Lesopdracht) /Trapped /False +>> +endobj +12 0 obj +<< +/Count 5 /Kids [ 4 0 R 5 0 R 7 0 R 8 0 R 9 0 R ] /Type /Pages +>> +endobj +13 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2031 +>> +stream +Gb!;d97,A^']&@2mNu;31mU"oGBZfG\4N5MEAhV;0u+VhLa;>jrq/E1#YBQ_`-'ef@"b>Dq`OW..i?ra1%Q9\6neCYnsU0Ne/o?F/XQ=soW$gGhQs`<"4nd@$#M'LA4;OXULMZpaR^uJiXqs4%]h@'aUOXQL/CJ]qgs>%<(IollS5[Hh[Vu>XH7^Vk\oj4/>%!^FtM&7UWrbFIA&PPZM`E;R(7T3(fmlpWCEoJ=uPsRO8::G^&N'ALAo7AZAb],T$Z)hOR]]P!TsKS^,\A/93##56ndYe9_*HYZg'"40<+tFlfuQ8\SL`V%G'X5bE<\e*:ip(&<5n7@\PVDX<[$Eijocbo@d=(@9*qb%e?k`kWL$bd[hD&b=LBPi$>V96gha6%_+J&3$G$dE5$Au,+TkCqT]2d%W*k8IHOJ@W_q>PdCqOeNUP+,]9R!;b5S[7>*rrJN.n?U$paGeKR-ddR=6`giT)k?WH6F-*@"p/$]qQ`%i_19laY35(2-ih;C*GGJ*;rK:(3;2EV'Idf03'WC<`>ED46m&R!mNa(QgTI&qE:0($bl@OF*eW?fOu6$Gsc\SrAGoP\%oE5H_"Jn1[U9aD_8o3jjAdLrO-<[f;]+\dZHLg2HuMr2a=FFZcNOBjhuq",1T4qPIk*\<5XG@4*Z"2/)7k*/)Na\8Qjj9aJ&/`*9g68WdIQC"35WXImI.]!r.d$-*l=hcb8+ZP9ibI+gV7j\sGWoWqp3[Up$E:;4;'$8=>al]V91Eb`#Wn<,AJsdtS<+IUDPmghQJONlEr]>$>*b[LI@,,rHnkaqX"fb-$n\r0@ZpOjUS8W39)keRTu[:\0MH2>CI*!*b,T%)Q9\[)8!]8rK[Puf\T[U":I_W,bHaGPTPr4^F_TE+Uh!Jg5iR^Mg)5cker#&S.%UhnVB2bU0TkQnECFlAk'H%9%3.O]D]6-"\\@6[lOB@GIX;F$SXpY]:!K)D]5B.;j/mENO0KLVUmC6/L0JT$[A6pr4`9$IK&rnIm#5XNhk!]rHNNt\q#\W,!Vb@TOO[([=`nG^1O'o"AD*C^mQZ41j0o%=kQ6Xn%G5,_<,Wfe^18Fn8YIbL%2@Sl&F-'70_-8V#MO'+1'Z*/E#`Wr;C59B.^8,:M_^2c%7J!WZHrlib4@qF1s>&sJ>e"&[aDFF1`W,N=:&>b/9T"Wm]L%*mAet[dp5(Q_Q&kgm9eUu6ffQ8Po69\DrEf_gmje@pjS"o9<[dgpb]##nRBE2:A;TT"L2V.ZM`O*DQEB0Tu@qD2!J^sg7/H?(@?g!>UU-5D_OlI%"4/"_BLN3#qKQF#?+AakIgb@#nTb)^=:%g^M2\ZoBL2m/6VO0l(d+@q9eIG*i@PTCn6r[Q&O:Ij+DbT0Y,+\`s<6mrVi%0C$%D+/,KmD5\"e^DX%$&J4*KS`&"47aVeGC0dEk%>~>endstream +endobj +14 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 596 +>> +stream +GauI1_/J$](rbtA(%4!,9";=ifT(.?b0VU]ek0XP>d@a([_uTo5hc56R=mmD.T.q<<\2r"Ic#L$@A&(Rp77MbQPao[#0%6._#t@+Vt_/@ILc^N>_s";cnpP<8/1D3eChV]PXIu'@aJ'dXTX;h7-dBrq`:R@6>`s>*AA$NWR:i9ZVYC^F_PZ5KD6hRgKh!(?Z8;U,h+8mC)e(C?`Ndn:(HKc,g1$+hIA_GHT`#mPpSPf_WTm=Bm\Y2C6P?.1Am6W>J9``Y2hoI4n04q-hhMqhjA93_`:Z\6t0M6^duP6B"_O7ju[O*$@ar+ho],4iR0bo2n9ePFh5`?&oB2k+et:=Re9&SK@2U/*cC#i`qj&f-;!SWpuV-oIZM0+jS,AIj&96B>b8'dgh%%SUe-"\creP~>endstream +endobj +15 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1846 +>> +stream +Gb"/'>>s9G'RnZ;3,^,-GgTj)>32S(C""i6#mjB';#7Ce$PqqA8N%j/^1VSDJ7I$e:1g^nem?\qgN1YtJr=!ir.c`:M`L.H!sU%/p&jEEPCY*<$s5b\\NWm)!21f<.T!p"TC5]#8ctte*=B`OY-.s>76RX2(]-4TN$2t&4=PoPq`FV<'e^]8P7GQJIn0uLcq^Qk.Mp%"T'sKXKq6h654$1*q3e5!7@kmr^+N-iW8X*u+85D@HW:e^[>EgSZSG*YUaE.7_#,27:G&aVIDH%pJafkl5;3gK$M_]#W71q66[]__)Yf!:>I80A9X7Dqhc5>Nf11=l27g]Q3/S(m,btqA8D>-(&r6;$FbQ9C1"EB=K_gj+8%a:FkR:tRTF,g5-:7g:$To8>F5!c7"R$(W'X2L'%`MR3::^NNGMj)pmO1MUSO(>7o:X)ko?^W"%Bf',FoUk[Mk:3hP,Jj7'=Ui*0LZjL0;5))V]*%lM-gO*c$]+CTe@A]!F]?`(QHZ]3ucmQqh)a%u-=_Z4$-M#e,/*OhI)"gQ3,K!=4(pA5SDeS\LlU5@B5DC]@p>YDC`q`DjJpTKE5'b]?e;0:ZJ(UUq=Lst\.?^SoF(Jn>"grT\OLq>fE7UGi;j="8tnbFWp"VeP(:&,2M9laYB@gMAp'E$PW8puH-Fh2HfeSin.K.1T<'?IO5iFlBX'3uJ8H@+")q[:,VDt"hmS\tWY*;miB\&CVQ*]VLeQMJ&tCCG=f30&3][l2kJORO)Wf=/;O\k!eKL>gfPUO`PK(kf<@;b-V3>AO^XM'J7#2i^6m73[R=>^nf/qDFs&N;8%,6X6f=6%WMJ8W=aK$=BdSZo\nA7pZrJC=L'?AaT2Pg5O9\J\`LCr8ujjMgtqh1->k,nHA$^EEcIK6!,%2Ros@+lBs8^k6^j^)Ei;9c0@3(*T(01od#5],kkLc!6f'JVZQ1c1+m=rd;a1EB+[%a3rYTuM).5Wjg1rqB2MpfA]sWi06DHDSe^G,kDZg]_It.TqL&b]DemKjlP*fQ??Jfpm2ND'$8bNAGZI6L]6Rg?m!d6TosFVZ8@kt,+NeJ`X\c>rYKSOQX08@IU&=m(gahE7#C5l_CmLE"$k:/e/\8ft>\9DG^?5Vjd+K\d6d1[lWT!ff"4ZbUI&c]+#(a.jDgRtO8V)Xh:nKu7afX\srkNgMaP\)FMiGb=@`&ha^rIeu0X8K.Qp0tBGL5j/-.1e"5B.%;V-fua^DANWnjTYpbl;e;HJFt[YbjNYc=eRup-:DX7>+[s1*su:>'\BHU4"R.KXm,h"bkCcA"HM3_Sf2Z^\^#D?R9_NXUdFTTpV3oWa(R<+dYnpaQ6M4@bVps\V=[*4lE]uI>Mn#Su0TP]!+gcK3LC/2o_Hi]!iKV#;>=^a/:8HlLn$[XNME/;?.egDf4AQl"?K)C:a41-AGg;]fq@L6%nfJE"&g22GmgBM?Kn)*0E"-oV$_)GF5,@HXXLX3Ig#so:K)k'2O^F:k9:S]K_X8_K1;a)ljH1qX_m$e`;ClZjb)";ME\'l+HOnih?B(hK`ek9G0[ALmugV8"Y!9YH0r\i^~>endstream +endobj +16 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 649 +>> +stream +Gatm7a_oie&A@rkhCWUFdiq4P<%if\ejt=aD%JR1cjO^>Zr1%#.Dc8boc;4K][T4Y6mAE/3H*ckiJ?^Zm<:YthA,5b3Oh=/"d_oW\Aj;uo=Mn..PdEB`@?ad!LCdL#MsW=,UlB-O`RHL@k*\(MDM)L"[R96`\Vjll$8&:ASIAX$)Vs1Wq<5RAuQ?k>-]DJo>CD^5II;RZZOHPBW`,%d0[N&[7-'$Mg1C2.-B`Br,d.:94psTSiKm29CQOU$hu/=L;?l.cno;IbQRCZ_Gor3DX-]q`BB+MAZm,%.3oS;bkCS19`r[,5PHl%H40[SgToCl4'4H&lOhf,O-iN`8U8[+e8DI3[E1gR11H`rVHuR.X-+ing*%P(0'W(b@Y&UJPV)^""da8L['hJlfBO+o6ThdS2L+G%-!4nbfDBaNll,9V>&O40hQhbU$Bn-s[KX/$6S[d,L','"endstream +endobj +17 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1821 +>> +stream +Gb"/'gN)%,&:N/3lq>>n=u-9ehD3R%.#F*8FV;_mh!'c04HKhAPY$HM+h>ib^V($EU5O/6PI+,nA5SSqkNi9&K3](nnNkCb,R&EIKVu![&.6;o+O":K4GVdto;,qoO8@'(b6_u4UgSn:re"\6ah[R#0;KbB_"OnGB>=RX"[Nt*76BBS4qn'9Sob6?^1[/YJaDNcRPB.$\Mph:9Eq`1^A;Mqpb1%tI?H@+1dYHm=@h4fc9\Xj/9!6L%]MWGBBZ;a7CQ'!C$$TeSg/q5L.!"O""0bR^ij,+@*PnR6UMOq0(=I%O>['f2LeGu9E+'MTUq7-I&dFZj\m[\uA"H4RG-^Adph^kk32ieQ,,0>:nI$KFT$s"OUi\=4etfp,J:GOPn[.n;BTP[`m>8Q'VuGr*H6ch9ucMSHTCCe3T]RZ'b`>YGj;[-Cn*?69W^2CIo9P"%;4p.8A4`]5q;Vn/6b/Y"DUI.[1>9b"lJZIBe,jK4LO,qt_4r?KCkO`M)E(;cb1e^)H+CmNEEiG6WSdo3HU<1G=+n>`13d@X]H/;jI$%4T+_S^De!fI@N-:-7B,kL!C>pBldfI[ein&?:TR+#*T+dU.DbhMA4Ml^H?7\(>Ther0Jn:gkeX1;]A&:]F*o%nPS[^cX)`QPeE/84I81!r`=5E["7)d>OM-(qjaJ&SKdRI*1q69+RM4:cg[`j7rV5QEorV\DBmA^oUK%&B[AXqm,1$opP@Uk0>.SA%/>:Glj0K$[05J]*%_Tks>A*i6!1egt^5j*'n9RVT~>endstream +endobj +xref +0 18 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000536 00000 n +0000000741 00000 n +0000000846 00000 n +0000001051 00000 n +0000001256 00000 n +0000001461 00000 n +0000001531 00000 n +0000001827 00000 n +0000001911 00000 n +0000004034 00000 n +0000004721 00000 n +0000006659 00000 n +0000007399 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 11 0 R +/Root 10 0 R +/Size 18 +>> +startxref +9312 +%%EOF diff --git a/Les11-AI-SDK/Les11-Lesstof.md b/Les11-AI-SDK/Les11-Lesstof.md new file mode 100644 index 0000000..2e1ff6b --- /dev/null +++ b/Les11-AI-SDK/Les11-Lesstof.md @@ -0,0 +1,527 @@ +# Les 11 — Vercel AI SDK +## Lesstof + +**Vak:** AI-Assisted Development +**Opleiding:** NOVI Hogeschool Utrecht +**Onderwerp:** Vercel AI SDK — AI features bouwen, gekoppeld aan eigen dataset +**Demo:** Polderfest 2027 — fictief festival, 500 records + +--- + +## Inhoudsopgave + +1. [Wat is de Vercel AI SDK?](#1-wat-is-de-vercel-ai-sdk) +2. [Het modellen-landschap](#2-het-modellen-landschap) +3. [De vier kern-functies](#3-de-vier-kern-functies) +4. [Project setup van A tot Z](#4-project-setup-van-a-tot-z) +5. [Seed script: dummy data in Supabase](#5-seed-script-dummy-data-in-supabase) +6. [Chat-route + chat-UI implementeren](#6-chat-route--chat-ui-implementeren) +7. [Waarom data + AI samen krachtig zijn](#7-waarom-data--ai-samen-krachtig-zijn) +8. [Best practices & valkuilen](#8-best-practices--valkuilen) +9. [Wat komt hierna? Tool Calling teaser](#9-wat-komt-hierna) +10. [Bronnen](#10-bronnen) + +--- + +## 1. Wat is de Vercel AI SDK? + +De Vercel AI SDK is een open-source TypeScript library waarmee je AI-features in je webapplicatie bouwt. Gemaakt door Vercel — de makers van Next.js — en daarom perfect geïntegreerd met React, Server Components en Server Actions. + +### Wat krijg je? + +- **Unified API** — Zelfde code voor elk model (40+ providers) +- **Streaming out-of-the-box** — Geen WebSocket-setup nodig +- **React hooks** — `useChat`, `useCompletion` voor instant chat UI +- **Tool Calling** — AI kan functies aanroepen die jij definieert (volgende les) +- **Structured output** — Type-safe data via `generateObject` + Zod +- **Multi-step agents** — Via `maxSteps` + +### Eerste indruk + +```typescript +import { generateText } from "ai"; +import { openai } from "@ai-sdk/openai"; + +const { text } = await generateText({ + model: openai("gpt-4o-mini"), + prompt: "Vat de Polderfest 2027 line-up samen", +}); +``` + +Wil je naar Anthropic? Eén regel veranderen: + +```typescript +import { anthropic } from "@ai-sdk/anthropic"; + +const { text } = await generateText({ + model: anthropic("claude-sonnet-4"), + prompt: "Vat de Polderfest 2027 line-up samen", +}); +``` + +Dat is de waarde. + +--- + +## 2. Het modellen-landschap + +| Provider | Model | Sterke punten | Prijs (in/out per 1M tokens) | +|----------|-------|---------------|------------------------------| +| OpenAI | `gpt-4o-mini` | Snel, goedkoop, default | $0.15 / $0.60 | +| OpenAI | `gpt-4o` | Multimodal (vision), krachtig | $2.50 / $10 | +| OpenAI | `gpt-4.1` | Reasoning, voor agents | $2 / $8 | +| Anthropic | `claude-sonnet-4` | Coding, lange context (200k) | $3 / $15 | +| Google | `gemini-2.5-flash` | Multimodal, ultra goedkoop | $0.075 / $0.30 | +| Groq | `llama-3.3-70b` | Ultra-fast inference | $0.59 / $0.79 | + +**Vuistregel:** start met `gpt-4o-mini`. Werkt het niet goed? Upgrade naar `gpt-4o`. Dan pas exotisch. + +**Wat kost onze Polderfest demo?** +- Context = 500 bands → ~30.000 tokens per chat-request +- 1 vraag = ~$0.005 (een halve cent) +- 50 vragen = $0.25 (kwart euro) + +Met gpt-4o (15× duurder) zou dezelfde demo ~$4 kosten. Daarom: start mini. + +--- + +## 3. De vier kern-functies + +### `generateText` — Antwoord ophalen +Wacht tot het AI-antwoord compleet is, returnt dan een string. +```typescript +const { text } = await generateText({ + model: openai("gpt-4o-mini"), + prompt: "Geef 3 koffie-poll opties", +}); +``` +**Wanneer:** Korte antwoorden, server-only, niet-interactief. + +### `streamText` — Streaming antwoord +Streamt karakter voor karakter. Goed voor chat UI. +```typescript +const result = streamText({ + model: openai("gpt-4o-mini"), + messages, +}); +return result.toDataStreamResponse(); +``` +**Wanneer:** Chat UI, lange antwoorden, "ChatGPT-gevoel". Vandaag gebruiken we dit. + +### `useChat` — React hook +Aan client-kant: complete chat UI in 10 regels. +```tsx +"use client"; +import { useChat } from "ai/react"; + +export default function Chat() { + const { messages, input, handleInputChange, handleSubmit } = useChat(); + return ( +
+ {messages.map(m =>
{m.role}: {m.content}
)} + +
+ ); +} +``` +**Belangrijk:** werkt alleen met `streamText` als API endpoint. Vandaag gebruiken we dit ook. + +### `generateObject` — Gestructureerde data +In plaats van een string krijg je type-safe data terug — gevalideerd met Zod. +```typescript +const { object } = await generateObject({ + model: openai("gpt-4o-mini"), + schema: z.object({ + question: z.string(), + options: z.array(z.string()).length(4), + }), + prompt: "Maak een poll over koffie", +}); +``` +**Wanneer:** Database inserts, formulieren vullen, classificatie. **Niet** vandaag — komt terug in latere lessen. + +--- + +## 4. Project setup van A tot Z + +Dit is wat Tim in de les live deed. Voor je eigen project (lesopdracht/huiswerk): zelfde stappen. + +### Stap 1 — Next.js scaffolden + +```bash +npx create-next-app@latest mijn-thema \ + --typescript --tailwind --app --eslint --no-src-dir --turbopack +cd mijn-thema +``` + +### Stap 2 — Supabase project aanmaken + +- Ga naar https://supabase.com/dashboard +- **New Project** → kies naam +- Wacht ~2 min op deploy +- Settings → API → kopieer: + - Project URL + - anon public key + - service_role secret key + +### Stap 3 — Schema definiëren + +Pas `schema.sql` aan voor jouw thema. Bv: +```sql +create table items ( + id bigserial primary key, + name text not null, + category text, + rating int, + description text, + created_at timestamp default now() +); +``` + +Run in Supabase → SQL Editor. + +### Stap 4 — Env variables + +`.env.local`: +``` +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=... +SUPABASE_SERVICE_ROLE_KEY=... +OPENAI_API_KEY=sk-proj-... +``` + +Belangrijke regels: +- `NEXT_PUBLIC_*` = client-leesbaar +- `SUPABASE_SERVICE_ROLE_KEY` = server-only, voor seed script (geen `NEXT_PUBLIC_`) +- `OPENAI_API_KEY` = server-only (geen `NEXT_PUBLIC_`) + +### Stap 5 — Packages installeren + +```bash +npm install @supabase/supabase-js ai @ai-sdk/openai zod +npm install --save-dev tsx dotenv +``` + +### Stap 6 — Supabase client + +`lib/supabase.ts`: +```typescript +import { createClient } from "@supabase/supabase-js"; + +export const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, +); +``` + +--- + +## 5. Seed script: dummy data in Supabase + +Een seed-script is een TypeScript bestand dat **éénmalig** je tabel vult met dummy data. Geen handmatige inserts — procedureel gegenereerd. + +### Het Polderfest script (voorbeeld) + +Het volledige `seed-polderfest.ts` zit als bijlage bij deze les. Kernidee: + +```typescript +import { createClient } from "@supabase/supabase-js"; +import "dotenv/config"; + +const supabase = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, +); + +const adjectives = ["Lost", "Velvet", "Iron", "Neon", "Silent", ...]; +const nouns = ["Tigers", "Wolves", "Mirrors", "Clouds", ...]; +const genres = ["Indie Rock", "Electronic", ...]; + +function generateBand(i: number) { + return { + name: `${pick(adjectives)} ${pick(nouns)}`, + genre: pick(genres), + // ... + }; +} + +async function seed() { + const bands = []; + for (let i = 0; i < 500; i++) bands.push(generateBand(i)); + + for (let i = 0; i < bands.length; i += 100) { + await supabase.from("bands").insert(bands.slice(i, i + 100)); + } +} + +seed(); +``` + +### Runnen + +```bash +npx tsx scripts/seed-polderfest.ts +``` + +### Waarom procedureel? + +- **Met 500 hard-coded records** = 500 regels handmatige data → mind-numbing +- **Met combinaties** van 30 adjectives × 30 nouns = 900 unieke namen mogelijk +- **Met seed-random** = reproduceerbaar (zelfde data bij re-run) + +### Voor jouw eigen thema + +Open `seed-polderfest.ts`, kopieer de structuur, en vervang de **bouwstenen**: +- Domein-specifieke arrays (in plaats van bands: restaurants, scripties, kunstwerken…) +- Domein-specifieke velden +- Domein-specifieke bio/beschrijving-fragmenten + +**Pro tip:** vraag een AI om dit te doen! "Pas het Polderfest seed-script aan voor [thema]." OpenCode of Cursor doet dit in 30 seconden. + +--- + +## 6. Chat-route + chat-UI implementeren + +Dit is wat **alleen nieuw** is — Next.js + Supabase kennen jullie al. + +### De chat-route + +`app/api/chat/route.ts`: + +```typescript +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 data op + const { data: bands } = await supabase.from("bands").select("*"); + + // 2. Format als context + const context = bands! + .map((b) => + `- ${b.name} (${b.genre}, ${b.tier}, ${b.day} ${b.start_time})`, + ) + .join("\n"); + + // 3. System prompt met context + const system = `Je bent een festival-assistent voor Polderfest 2027. +Hier zijn alle bands: + +${context} + +Beantwoord vragen op basis van bovenstaande data. Verzin niets. +Antwoord in het Nederlands.`; + + // 4. Stream + const result = streamText({ + model: openai("gpt-4o-mini"), + system, + messages, + }); + + return result.toDataStreamResponse(); +} +``` + +### De chat-pagina + +`app/chat/page.tsx`: + +```tsx +"use client"; +import { useChat } from "ai/react"; + +export default function ChatPage() { + const { messages, input, handleInputChange, handleSubmit, status } = + useChat(); + + return ( +
+

Polderfest 2027 — vraag de AI

+ +
+ {messages.map((m) => ( +
+
+ {m.role === "user" ? "Jij" : "Festival AI"} +
+
{m.content}
+
+ ))} +
+ +
+ + +
+
+ ); +} +``` + +### Voorbeeld-vragen die we live stelden + +| Vraag | Wat AI doet | +|-------|-------------| +| "Welke bands spelen zaterdag op de Beach Stage?" | Filtert door context | +| "3 headliners met meeste populariteit" | Sorteert + select top | +| "Hoeveel jazz fusion acts totaal?" | Telt | +| "Vat de electronic-scene samen" | **Samenvatting** — alleen AI kan dit | +| "Wie was hoofdact van Polderfest 2025?" | Eerlijk: "weet ik niet" — perfect | + +--- + +## 7. Waarom data + AI samen krachtig zijn + +### Data alleen +- SQL queries: filter, sort, select +- Geen interpretatie, geen taal, geen samenvatting +- Gebruiker moet zelf SQL kunnen + +### AI alleen +- Kennis is generiek (training data) +- Verzint vaak (hallucinatie) +- Geen toegang tot live data of private data + +### Data + AI +- AI filtert via reasoning op tekst-context +- Antwoorden in natuurlijke taal +- Samenvattingen en interpretatie +- Domein-kennis = jouw data, AI redeneert erover + +> "Een LLM zonder jouw data is een gewone chatbot. +> Een LLM mét jouw data is een product." + +--- + +## 8. Best practices & valkuilen + +### Doen + +- **Begin met `gpt-4o-mini`** — upgrade pas als nodig +- **System prompt is essentieel** — "gebruik alleen onze data, verzin niets" +- **Stream alles** — `streamText` voelt 5× sneller dan `generateText` +- **AI-calls altijd server-side** — keys blijven veilig +- **Loading state** — AI duurt 1-5 sec, zonder feedback voelt het stuk +- **Foutafhandeling** — `try/catch` rond elke AI-call + +### Niet doen + +- **Geen `NEXT_PUBLIC_OPENAI_API_KEY`** — wordt zichtbaar in client +- **Niet de output blind vertrouwen** — AI hallucineert +- **Niet alle data altijd meesturen** — werkt voor 500 records, niet voor 50.000 (volgende les) +- **Niet `gpt-4o` als default** — 15× duurder dan mini, vaak onnodig + +### Veelvoorkomende fouten + +| Fout | Oorzaak | Oplossing | +|------|---------|-----------| +| `OPENAI_API_KEY is not defined` | `.env.local` niet geladen | Dev server herstarten | +| `Cannot find module 'ai'` | npm install vergeten | `npm i ai @ai-sdk/openai` | +| Seed: `permission denied` | Anon key i.p.v. service role | Gebruik `SUPABASE_SERVICE_ROLE_KEY` | +| AI antwoordt in Engels | Niet expliciet om NL gevraagd | System prompt aanpassen | +| AI verzint feiten | System prompt te zwak | Voeg toe: "verzin niets, gebruik alleen onze data" | +| Chat laadt niet | `useChat` zonder `streamText` API | Endpoint moet `result.toDataStreamResponse()` returnen | + +--- + +## 9. Wat komt hierna? + +### Het schaalprobleem + +Vandaag sturen we **alle 500 bands** mee als context bij elke request. Dat is ~30k tokens. Werkt prima voor 500. Werkt **niet** voor: +- 5.000 records → te duur, te traag +- 50.000 records → past niet in context window +- Real-time data → context wordt steeds opnieuw gebouwd + +### Volgende les: Tool Calling (Les 12) + +In plaats van **alle data te sturen**, geef je de AI **tools** (functies). De AI besluit zelf welke te gebruiken: + +```typescript +const { text } = await generateText({ + model: openai("gpt-4o-mini"), + messages, + tools: { + searchBands: tool({ + description: "Zoek bands op dag, stage, of genre", + parameters: z.object({ + day: z.string().optional(), + stage: z.string().optional(), + genre: z.string().optional(), + }), + execute: async ({ day, stage, genre }) => { + // Supabase query + const { data } = await supabase.from("bands").select("*") + .eq("day", day || undefined) + .eq("stage", stage || undefined); + return data; + }, + }), + }, + maxSteps: 5, +}); +``` + +Workflow: +1. User: "Welke bands op vrijdag?" +2. AI: "Ik roep `searchBands({ day: 'Vrijdag' })` aan" +3. Supabase: 60 bands terug +4. AI: "Op vrijdag spelen 60 bands. De headliners zijn..." + +Schaalbaar. Slim. Multi-step (combineer meerdere tools). + +### Daarna in deze leerlijn + +- **Les 13:** Agents + `maxSteps` (autonome multi-step taken) +- **Les 14:** RAG + embeddings (semantic search op heel grote datasets) +- **Les 15-16:** Testing + Deployment + Performance +- **Les 17-18:** Eindopdracht-werkdagen + Pitch + +--- + +## 10. Bronnen + +### Vercel AI SDK +- Hoofdpagina: https://ai-sdk.dev/docs/introduction +- Voorbeelden: https://ai-sdk.dev/examples +- `streamText`: https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text +- `useChat`: https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat +- Tool Calling (volgende les): https://ai-sdk.dev/docs/foundations/tools + +### Supabase +- JS Client: https://supabase.com/docs/reference/javascript +- Row Level Security: https://supabase.com/docs/guides/auth/row-level-security +- Server-side usage: https://supabase.com/docs/guides/auth/server-side + +### Inspiratie +- v0.dev — Generative UI in actie +- chat.vercel.ai — Officiële demo van AI SDK +- Vercel templates met AI: https://vercel.com/templates?type=ai + +### Tokens & kosten +- OpenAI pricing: https://openai.com/api/pricing +- Tokenizer: https://platform.openai.com/tokenizer +- Usage dashboard: https://platform.openai.com/usage diff --git a/Les11-AI-SDK/Les11-Lesstof.pdf b/Les11-AI-SDK/Les11-Lesstof.pdf new file mode 100644 index 0000000..ad9748e --- /dev/null +++ b/Les11-AI-SDK/Les11-Lesstof.pdf @@ -0,0 +1,334 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 6 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +6 0 obj +<< +/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font +>> +endobj +7 0 obj +<< +/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +9 0 obj +<< +/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +10 0 obj +<< +/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +11 0 obj +<< +/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +12 0 obj +<< +/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +13 0 obj +<< +/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +14 0 obj +<< +/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +15 0 obj +<< +/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +16 0 obj +<< +/Contents 33 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +17 0 obj +<< +/Contents 34 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +18 0 obj +<< +/Contents 35 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +19 0 obj +<< +/Contents 36 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +20 0 obj +<< +/PageMode /UseNone /Pages 22 0 R /Type /Catalog +>> +endobj +21 0 obj +<< +/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260519160527+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260519160527+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Les 11 Lesstof) /Trapped /False +>> +endobj +22 0 obj +<< +/Count 14 /Kids [ 4 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R + 16 0 R 17 0 R 18 0 R 19 0 R ] /Type /Pages +>> +endobj +23 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1834 +>> +stream +Gatm;>BcPr&:WeDbffEG]Cg@8^hQug?iJ32WsGK$NW9-k#T:^[_I8]J@WTQTjHJ;[O%_fjqBQ.$k+Pu=nV1&!,$@tre:I60OFgdL^!DEn9Su"1C+fPZ"tP[Uo)=d\>F^@IVU3:$R/jD16C!/d]pTNN(E2*i4.*QQijmN`D_BiEfJ]M3=0mI%cE135om1iq1W_5r4Oe*))e"Ji,spEiVDlFhpo'N:[]@+I-'#I.c&D##=p+E\Xll;,"r@6gVkTBkWNY.1[Wm0oK!JKZWWA<2Nj/7qI_iO&4g-+=ch(YBZSV<,f8R%]=8o@_u`@_r=/uWqE\JE+_HXhPoT4aV^k,(nGGa0?Y]H3f);a1ot_=&c-(;(8.SC4"kma2T8'^I[UM\N[(1b3Rjq,NO=pBPdc#(g+>R@gFh.G(B4:H-bi_L-m8df\?uO8#/fQ?q/9\7Ym(&;r-"j>'LV-@6qVf5Jff]7DF&AE6(o*3,scR/N/Fqi<#>($C4:9C9c1%2;DPQTH\Je(3rK%ZK,T$hBWEeA._kE!rr0!la5#._aH.+bJrWRj>C7;6FHWli.Lm8,c^u#,!aLKOqGqM@$'a_SZZdPS/u0\F>6O$P[I+Ks/<=e9M'D-B"aqf5P6huY4VMs':M?J#ra08)#Kl.8?mjZDn#WS9O?A_!2qGoG;2]Acb6\-LTqLDl1Q4kDV85^kXo7MMGEQg[QD?BHpIXQe@BmhH/XQL0:O8!?5HMH3HPk6G=oh*nVC2o/fLD0:NbZtFH#Q=-R_o@Nnf=S3Qbl_?nQS@#eQERqL_C&J7'rG5"_XBHqRSMdb(qm7endstream +endobj +24 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1749 +>> +stream +GauHK>Ar7S'RnZ;367<4`[j\Y'%LITZ>R>UD2XQ#s;T&Eh_ghkR%AWtJRHI6CcLW[Y3ChVOGeP9VIXYe![-!(\hcPYG2K&`9'l;o0L[#45R2:*%8R_8jo[=-EQ7m=p?06D6+&/`09*8,<"#rI^+'2HI)*NW(qFW,\Om:.1_INt:-W\h!?O0Ch2")dj4'_ksJ7"S`k7-dG>/Zg15/?uE0l;Jh^+hI;P.3+RE%gnhgi5&&dM9Lc+a(:bR`eHl3'8=J$9=2f\QL3a$[b.&GjUs@l"#%T,l3W36m8]@4m9+Ueu\*EBE\tdfNop8_n3"b8NS'KBo*h`>ERj)cH+8JSJq[W,NmLSF3;4cXag&qk"_J[5*;?,(i1(bKM0X\@<>]:Cg?(;<"G12Kg(R?\1j#_(SrUr_6m0PW\l?8/V1H5AmJjea-i2tlO-],8K9/mCUhI;f7q)E3`Tp+@$]tel^#fgegFY%SKVmB6D;T5Nf6FmUDIL$PA13goiFMHeIe>;nk,8<.Sksb_h.2Ca,u5MX[r\_Y;:!'fM%RQ@"sYZ))#tDbi:8*\jYC0]#p4jcEMU-]tZrpID!A&\fP0%lc9QsRB;T\D72sTi!:6&C9D!BN.S*4@C]ne-\FUr=Q690VsmbOgD>c<%)m^R`#n<``+*>@"fR+qC"jk@>u0#&F'Mf&1$-*IZhKeTFIAEo$d_6T1NS*1;fs(53WaI#O7M5+!B4mDn4R3VJEB_*YGj(?RUsI)7Ul4o$X)AsU-)c(&7V@`R*\FTIcEKAjX0f^6/%r\6Q`E?F.`"5;;V*l9S=nbpj^jP"RCREI?=ioh]$RCXH_nDLrE-AGK'iJ8qFP14@39,fEKaRns0CmpQteK;?bheHAb47G\HRT^+$YIek94\s;oD:/p>^C#kQCB~>endstream +endobj +25 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1636 +>> +stream +Gat=+gN)%,&:N_Cm"7*hU_/P9$"hlr\#CBlP3/X;1EF6W'L^EI8=?:0:;=LN&>WuF_PWVMOPoP9&,%PtaaiB+\_hKH;VU)W"#h7":Rg8dR["RR]0,=Hhr>ISE&%\W%a0Sot>k/\egRWH\ZK^?SmKMb_$h8$:uXEOb1OjB"u+\H3nMibV\QY<>G#"X\F,j_A25GS6aFdRO2rVZ\6rmd1k,CD9DQ4/8end_QGc%0-qtQh0Wo:X_E;?^nGJCUg]=<9fX2fU%lGMjNA8O"!cA)+u/<^[&K]SbkrS3kRQ:4C)MBdF^ao@]/Y2W\J7]#6$8^j`/DDsUl.okm]T!T`JtS.?_#r5F>uXg)QjGI'?UNiSC$fQ%!IuBRoCG)jNEU4KqlPqe55koF5&-[Om-#L=Ymd4ilT)>-0YQ-SS]tjS71XIX^S[+>H]nbo9AmZXaOfV:NUaR4&9^;/Zqo5&i`*I$_X+&ai>rDMfk$9.Ip$RgX?VHiOt612](=#.e)(/)cCeHj3D&)ME1`blOJBf3=\b&!2%&95P++m8QA_M7t:)6mq@rttN-("fUh%-HQtXK,FlUPn4BQg!=8X?1Hh5;q4?@OOk-g,>onRq.Sq3AeA-Ok:h@Ifb5Y6!N05Z>n\lRM8jSm2OK0LYW)6_oLK^U$l@L$4hN#it[,09sI%a>2j`6^eaBt,k*idl>$9t-hFG(;?YV/-[9i=5k$EZ+_Oa~>endstream +endobj +26 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1785 +>> +stream +Gb"/'?#SIU'ReT:\F>\\`%4JW&sZl9?+Y7\g9k_P%N,,\b-\9SQ5k`VeeS#L]@7'3VciCD7F?n05pXk-iSil]LR2F7n\aY,aTg[i.6N"#J8pr/J-dE-qAasTH5@X]+cf"k`0\u73KVn&5>WuG*.j9*0om2C)A*hn&<[VS@gO.4M]rp&WF>>4@mHcM7T'5K/-oh^g&!r>3QIfs$,SC1q0pL_>'_n0>BWQDq*d"(_:hI@<*p3ZJgc06#0+EmI!6(8'h_gYX.m\nXXSCu1,.`NJk,!f7obgT7aL%M^=6;jh<gTGtTugP5%5#E%Sd@E6lS?87.#oKT4"SdbMFBbi9;/<6.?EQYeBA=;:=79@3?'4P,R84U14>Eh<%cp#*WQU!R"14GZZ*4n-P&uVec&Feq?bjSns<9_Z?k67L&q*X2V]BSc!S#X9%1#WH`E@+;u-I\u]HtOH1,HSnhW'eV!MB)'eUuBI=UeT0fBt93k;M4Ss@1H)f+7bmh\J3m-G!al$4Mo=U]pTMDoKoTo'FIR`Q_`^fN,H[P6n.a%1[L06cYakA[8+Ssgn`,WI/UL[N)Ma6flh"'@>,XkVU+W3EljZZCO3=6^VP6ZqKRDj>$3q`UP-iYiA1/qe[@*^##pn&h"*GX$qq?V]fHChcaO&n%`^!=%u39]aXQ<4q[Y`%IH?kdDS$%r%kF;5DU7Nn8$HI]f10KMEN:IP=u:k?%9r7dVcY3LW[s]F(3R^t.cs6S8&g3kLjf>V@4\W1c>[GNN*/U'EE[=9Vq/GCA)LOl_70__0Wp-'@'h$A3u^l[,=X6'Sna01Rm'&j1hh-;C;=6Qf!`:]fmq+";=AlAjP9/BouVYk$2`YirG#93XG!q%gL3dW3TAEBB(/fm"Fendstream +endobj +27 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 402 +>> +stream +Gar?-b>,r/&4Q?hMRtF)R+dRmb9Em`;ur7N=)=V^9`oZ+')6pG#H.gHE[#ekbfL7u$P+?)I0n[S=KFKl5]R!o,`un^QUDo-#q_*8T\-R&$':)BKdM^--r&D%a'kAZCF#_;A?E0L+jV!Cd!-Pn&I02uN0PO/Q+$NrISo.bR)S\LZGS.f#\\;CYD;KI8J6W'8Mu(-ZXPd*j(u(6b\R2-In/QpnMK/&`WX3$7:f=G6sa^',dZh.2o?dcupk)62JQ_S[XS\CmhNendstream +endobj +28 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2096 +>> +stream +Gb!#\D/\/e&H8h>EF/nq=j<%m6r=s3bir@`=HK>ddr'd=6]i6jM(H3#o!T)+m5sTeOrYoFL*2t&fHS:]m`<"emOSPVhlQ_c(B=[bf`/m?E>Vs]F,0pn,8cB@mhXl6H]AKPQIPUg&Du%laC\HSXrtUE4p+A>,!?/NabZ1q"HIMoa#qEW>jPT%LaT,K*?^NU6Ac$=2A)ISC59;cq!7LYI8ZQt'2_thAl]uV_\K90PX2\t6CI/M0Nu9;n]BBMpbeVA4Ii<`$ti#+_Kc/lc9:A5pA0%Ujb9/?0Bu$Gl#NJ\@M_M#0\Y!e3r"`QM2UMuiZG->(^']XMtE`q4Ul"#&qK3(I)[sZJ-<%bor'.E9g/-fP6Z!YGVIQhKHjl5EL0XkAhLO%7jrLjB9(/]Ps^odG^%/EeqB?W_[@f2+)tr9a*#*(#22L5W>>.V"]RFC[6@F[*9s*!m#M=2,W&77p1o.b,an5$C_;M>;8WjlBP9oqU^MR7mu\&F!hWJLkNJl$kr_]01[CFSk@bXIpkAV@S0T$VW7lLdP8kmEb`LC_aX,bG#*p8/-)"3a(gDQ_;Ge;1n@s(k7b8g(,\@>)iWCjVRiqaC>Pki6GH3O\sr)S)CjqBKciK`RAQK*6*H'u4qE[91em?D$=6)[/amYku5KU/$I0tJ6t]S(%^,bZ2bd"&`W]uX;`Ae:s_k`kY6m+eHi(i=E[XHMrlBPVB8hsRATgmD)"pn_3YVPNd>cPadQ[#&F:?H%;t]Rqn:6(rT2HEN#holH(e]RWFmRIlBI=p-N#Dl2(iP2fE)%b-R?]j>]n7,lLdAYB>p']2C%'JC%>^Ju=FM8#1L]fZ[LdjW\gY-[_A)2)'elY:mVWc6[sEa+j@_D+p].M:"mLl^'YXb,O)1,oebeYM]0@;kG%9=kDUnoj%b6PA1=p-rA?/K:R4[.ZVfLG&+URHH_J,ZXr4+m4&k]nk50qD:a10(u2/5!MhqC^R#QrD1_--/97.SM_7[P?U$&EhM(!d'l)ujI7K:pmYd/-BKjBVO):Ks4!XsE(g4G7mg8j;)6=%maV7#iQHqXdUf4R#%q;'i("'91k@0"O-dE"DPBs%Z8>U.lG7p65?;tJ:V8;)fG,V>_icn)dDO:t][NfMZgCa(]-W'KnGg301?Rl189CmT@ifV1XeuA9fNOPuN!3f_=$\Vr,B7#N2"f2;A3BfP&ba,uP:E(R91%:"I,)%u:'PNt3b!orQe[;As^KXe'cM?_Qa>GN:+#htQNE#.[0Y&(mo]g[+@WrSs)Wr,ASVU*W%ej*46QqjlhKd'].2\IYuAR5MkYs+*#n]lCRq/*unpSVlQJ^'u^^/q!0VkO6eOrG%3@1[?)!,`0rIkC[*D6WMca+Wc##sd\fou`$o'tDB*Uq^+"+t4e,DX_31+*Iq(OIZAc@..s6f<8FBY+LUr#Ubrpe,#jL;rI*sa#C8.6_-*.7R.pi/"`EcO!]*_nX7:/2&7rPJqBiDMSKlKOVB[u.Gq@qBNsU#2socIE>=>fE/"rmr(.d8E1V6E*CV?-BIF&ECPR_R9'0qp2a'NO,!-.Q+F5!=YEM/qfecHs'"foJQ!VQ6KjD\'^2X,,T?!#eP4%RA&-QufiCq.`RaRC[HWI2r'Yl4[dhuW'~>endstream +endobj +29 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 900 +>> +stream +Gb!#X>u03/'Re<2\DmnBlMCBFBG-#ML/o$kdqP#-WfXe'Q8W$4/n^VVDVB@h"mUhI!i('@/#cJ2kC&?K[;k05U_:a+<5A,qf!;T?NW\Bj.M.h5etUp\T8?(Oq^_:$3m0Hbn1`(G)9RL#nSDtsf58mQ.kKTtV5rgKH3kntbull=:HlbM9_ZJLg9AK5,&P^m]Wqh*(Q([s&hZ[$qjALWRqV2apO\=lkfn"S:So-?!U"Q%jt9s_g_cAMc1+!b/%?RB$U=/7?k0>/9";<#N@",220*M`R_MAO82_YWL;RP.@Q3LUS19h;a'9NQAX>V<7=VD$(`YgO6#ZM-$H!WmQ$HMtO7endstream +endobj +30 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2295 +>> +stream +Gau`TH#q`M(&snuimlMKaA*-E+:3YVH>j1G8L7S##lNP1LNeg\`@j$^E=VL9)Q#/)0Fl^D^c^Vmo`^jk49'I0(Hh27+KhMXaFT#Ir]i>b$Le32+V,G])A!d2i(6D6$mflJ(Bc:>9#9nG_ghlA#1=Ej"^&Xt2ARh,Gl!B,(!hHJRTBV#tOa:Z_DW7)5In1_o#]d"`[sATgeo5W99"K3^]_(d;NK6eVXiSkA@rJVspA4d59q?At$-Z#f(FS'kYjo[=W1G)Yqu,$XXU*>tW*9?(7"+]Pi@V)^;\F=r5L21OJCn:e[h$%Ic7/V8[i\+fBq'Uhr@=6["NJn1/0^HiJi&YoS8Xd7@/<3';j+Tg%qL;&USs6^ggrYpIO$<);*233qWraY9?M5q4VJ]IW=;1HmD'6u9\4IK>KFld&U4-1*WLCU5W[cFbWMA9Y\Cq#kf=28Zd/_S?p8rHZ`bhAY-JTMQmW_$*I<7]?3M*$mj"T"FE^?WoKCOoef#5klmNjfpcp78FLrR\7$dPTQMg>m0)2&n4g2dBpNZQXMN"35%o8nS5FPsR_5=dSP+*AVWKZ9nQA%j05+_Md#OcMXa0K2)bf39C'8rh+q`Z,0s.HR:ONfU%h2dLE/17Q)oFpC2JuS"MHCX%*'DrpEsrG_8NPU]O#r.P9G[aK[Kg$9AlTR-2c62$iHNiQ:dhiTJm\NTELFi8B9PXCZi*\Hbo/1m`%O4=,';3?ZmT_;;5ai\/W(DgbVPV-BPkW&F?:I'3nO*[<*?_,41$Y-id"FBZi&;G[=1@t1>@7USkk"YfN_oIKl[p)F`qa<2=V]UjZ4kfV0"dNf-Q!k\V7RG/!s$dN,U4L*;pYB^E?dDe0Iu3]XcKI:QIBuDA1qF$$7*C.Ob-)V1Z6#g/lQHHNnk9[SL_*0q29[FdC#S%IG%/:CXS"pUG!'=7>13PDUu5rm;G@eZ(#sYrc(RpgJ4"s*(\jq)D&X$GdSqIN>F=LA:*B':E,\`+85`ILjt2?TAOUO1ABl$;LJ7Rq((!%Ct(+45a53["n3BmGMgql0(@SO8lf=./FRA"XEln4RKN'kOQ8=`k1V_%'lo>2HG_T==1fg_Uj:[`HoA3@n5,9j5b"nqD?4rHc\rAPR[*%&>DX9UamQ@;(q1nrANoTU@A2Hm(2OHXRKcj2B:aKj,HT10c#O'GXG00;lRZLp!@:L]^_:q>0u/iDf4&E@iX((RF5-d5b+ZDrAFs@jlj@1F^>b;*/$`TXr`O/9kb^$%=O%&%[?3Propi(\LD44mi"AhBqq]Bp@UU&U\2`iMW0sq[!qspMV_fg\uP&$,&ir=mmm&!lfd:V/BZKJ*8RSV,4MIeUA5B`=[k7o]!fTd[k9D!ajUD3MI,qNIkNNB?BZ!8S0oX`_HU-CR)!oZRmdGfA"DpbIendstream +endobj +31 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1632 +>> +stream +Gatm;968iG&AIa;Ccsf"D?Kt_8:]s#e$O!>l"#r6ond5d"m^*cmI^lU<1m`0=@5JifDkFp5DHV6b!P3Qo#1So*D3QYl"Icf"c6Mre\SR_*5%_;+EO.dKrha-O`O&NtFMqQ%W`J[dZFnn^0fSPD3+LVuB;qno\q3:/8,YqQ6_ZqfOOf,CtP@>!N0q16"TI0OJX?&bh-M'+]u]@hBeAKkDG5+sd_hCGjKg8,js"l2l#]>@Whgs#L/t$9&H!5_.$<4Lng_9W)J5X-m739id_];^\bY;msk"rI)FA]pukK3)1.?&]VoKjtToZa_I)88!XIO.>gP-Oi9s[,XX:SegGG,*UdYj'U[K].]#@UAUJ\/*"?_?,?lS8%g#[uC7!R^)[>bG2DIi+l-::"=':u!RH.L4E3U=i76MI_4H6ZaX5`A`1d'AV?`G0K8_):=1ejqG32_2M48o,"J)_*K5;HDrdaYlaCZ@\Fr#>ffiF;>n`Dl>jqAl>J0Pf-RD8^Gm:iP/-r2G,@p%~>endstream +endobj +32 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1540 +>> +stream +Gat=*hcJPj'Rod`EDKpK>u;kM[c@q0e#m7nVe9d87UP_\3_EkCmEBaH/LUSqY%5Q_/`<0#_-#oMpU`Y35;Ai+c2@#e#C&CcaXI>3@CcFcLk+`:GWQB6#mcU&'G6F'd4jNEP0eVC[1!Cu@M1%N@A'PE8dZ,e&Z$*D*:9P4&h4!AB)r)Y@k*`DU0&,1p]p\X`13T<6)$>6^:3S+.7Z$7EQ8Q'[Qb%P4'3:uNuA?l=X55Zk&Bh1T,2/0Y$@DV?'V7?^RNBAWs31+96bT&oW#(#\hS8NA$1eCf=V"S@"'8QWKkXS(#'VZ(Ucfm@L,M4f^pk"dnCJ^e;a/5L_Dglnf;B4X/D%+Y2-5juKMb0t6;h]c_^-1qTUUc0%4,E(A[h/>-HDk6BjTQRG@E/2^[p7)hd%9M]Xa_aeYq=Z*uWm[@'$Zn`iq$m&Rp3;2$7Yp&$%O6OGHU+2Dc43@e&19jOV>-Y4nY3+*.#YNo-b&tQh2[SQ,]OgVh%5"T^pUTiF)pSR[YWgabmo6,OgZq]E?cY?LJmN'SiG:GAmn5@aSB4f4S]L.^i;h)WTpQSRre=GgUC[ui9'E%(/Tlk/%sh)n#@H5ha3N*Vl"nYqFPpN;3oaFA*j-W]#+.l@%J7G0jq>'""8P?DMgN'p)+=&)W"opf]@WE2'9d!Ll;6fcqS>kLXZ.oOMorf0ZNRQSQ-;`!_`MWE.o4Ljc@IC<!=\Yu_,nj/K&P?5nf^n4bj=Rd28aCD7"uNUjE-o*1]FQSEY(k\4W@!i6'I%/a6(o"a)'9d4Kg_TM)QbmID"-m&nI%!EeqbOrm?.VlbYq!N+H;::Epr=dZt:"hj2Xrm3T[E%oFM,ufLiDP1&L,)jm&OaaCdBqk=>F]H/+&@91HTu?6`XaigaUi4i15'H`uoBK(@\$9dp=U[iHUjNd9+19dT]Z'm.0EF4M*"3hUr1Un(.i];nf5C\Hi2/a7q/DYTX+bUZGWlendstream +endobj +33 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1376 +>> +stream +Gb!Skh/D%+&:`;=E@=Z!bn?><,`3MELC@n+U=i%5YC4NN,%#0*ZKOi$1?>XrOch":R?OKrJn;A5M%oE&GJ#T#!)A%&n+d=Kk:+&l00i'qSqDi$"Ogk*2[qAA-C%9!(g*_^QqCb#k^H@SK*l),<%:fIRO:cV&O]eVX]#[lk`S=Z:a;M!"Thf#GbQEj>/X%6Hh8I[;A/;O`CctNkr\^J'/*psPE*Rt0*7m`U/S'^%3rk]SA[*e_;t>T5(A)iboE4#@8$uWfc'I9Ki9E63;d`7fJH$,E<\pXKr%qUYd/1TH3)JXa(p>MKpn,Va_grcCd89!7q9eh$i=Qp0<0u.7mJH/WEUVo4%U*g=W$^_N:Ghd#9iJn%O2];=9UHlm;"pf"u9VW7s[!>Fgii0,#NAC'5uL`5sIjm6B`VTBI]j$Bad.J.,$Pl!8E@#"Pg_XJY92UprIe)hK-d@5"JQ!TGOmYmaW.gSd11h4-f]P[X\223KNo@`H:8b,F]"bU_Y^2>L5k*=F6M`,J,YWDY)]I#[_lTPf`gtO^t^fa`27XJ'#"MXC.@I%"9XN5^]>iqW;MmZ>,KG;@.=Oo:7o#/I/.lg,*Ad,Y8'*JqmQYVK_el?mQ$oCZdVB,2-gPY?hB?Zml''HS<$]R\+j$Qi5n5a2"mr]">hfP`FE.eV)lsE`IY@B%s'[BZ`NM"]dmE#eeZg6+`3iKfl,C5XPOXaXC5\e_@;goi68gg<7Sanq8jZFI@S9Ur,-LDOoDac(5MI1)eC+rWeq#ERUbH2D$d*R>A$An53K93l'n"[BV2QDTf@\*/9ABSaQf7b8j5]!_lE\0UWR-UKCb$QAW;DL\3cj0c+5MUBqVrl'7u-4hm\EPCAN0o78eP(]f)aZq18eb=dr,k()KX9[HqI;O&)M`9`!SIfe5@8*17NWe\SPn!'e;Rfj+EEZ1!^h23L,M\LuIq$L:1qajAJ8nY,UJ),c)USo@^SJ`[a=Z<4`YP,MCm-*$?%3Ku@n`\@tZ@,.T5C)FsP%g+*t;*PDeJW0jFV$;$fc\[r/AVWg[HqVq16,8tS@&dcY4Ld^Jus,"1h[iiYKh,u-de@UQ8q=km+GRiH]2nD]/)?=:,[jl!OXp<7(>]9(C8MTGD@7fK[8L"#6JQ(6+Y1=GNAV@a1oHMWFg76l^c!8*F'bVj0bitnjR0:N+K1TZ3(t8=/TIXm![<)D.]8b969"2+SZ0e[FZf~>endstream +endobj +34 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1796 +>> +stream +Gb!;dD,]1K&H;*)Z(J2+"0*Z^ao)uIg<1K='N4(2L)EJ58n)76WNCh(^]%9Bj#Cb:5c;L."A#8#?_5g5g"fK/nNj5U;$Lu%K_M)e:^4fL:m\[G4GEd]qIU?%ZbZ6a38A[D"se<7%hrM(#L=cHa?g6h%L(un;':F.#M]L"WTS@&#C2+"N1jsPGeP8;auB<>j'G:S`]J:Kr^CD.K/TB+Xc?%jl3/4;1H7?U^E11_C+RPAU/VJ,FWmN:L)\,6kREPuAXScd`c*+<10(YSk5$Y)(gi7boLWj3KWF*)LWPnpJgFhrr1#D8;#n;:5?1F5-,-,"e(R-/+['LJ+^?(0P%n;J/=8TuL>ZX444b1GX>Q0[0'Nb)P`A^K,5Y!=sT:UjCVlB6\dnp%WXunb2mTJ(>Rop+?QYJ+ruF1\X0MZ%g:%HdFht$GZ]XV9+&4q$L9(:&tZgX0]>poRiQg]VGomJ&&9>Ds[[kF<2a=`/e4`.ohC"c)F>!P)V8S^#-J),(>WmeC5U@J7mVlO;jnUm1aFJ(+L=t]\%b2lfRSMAuR7"FdlAg<3[F"H.pJF['%/uZ40CG!kU4(`Lb@UY/>]->?t`+A<(&tZ7>Jkbh8mj(ZT9pll(t&1XWA&iZmTo"*$-/<-p=jI^4`^Al;)t)paLCkjMLkY[>cCo"BA&DG86!Bq<1(-\BefX!]/kdsaJl;_/L(G(Xu(\LJYSR/VKBA%o=S6[,qbQ7AEgo5;aX=_5Gjrn6sD^VS]^)%[4kNKC^1I?$6ILs-Y\VrQ'2DGBD5)%i?lN?!I,BJR`cNfiZ,"jTo.S#9AnihOBiEhNGfK2DdAn41Wj#Fb^O-.RJ4tk01^$$L2Y55V/rQFNCa8YJfq.\a)Dk#Kf:g4ukaRt1c.O3N^IZC*OpNYN8Mus82M18DOq9WKK&u\3j3;IH`=mMjEfHW%ZLpUh0,q0=A(\^`54$PX7eFrJAZn_"ndqBJ_Q>g>endstream +endobj +35 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1941 +>> +stream +GauHL=``=U&:WfGf^Y/qfE_J/,j8"WELNcH9VtN&24<^m"em--2@l4/iZ+0]p+F-J<$[qn3jE4Z"G(L+4g;OP",\2_r['Y@UFA-;t<>a/e(J"'%X(C"KA7r\IooXq?Ug?8]&eo,\3Y&dm9A!gT>oU_=*^_a^1W(XLUs'40k(uV5Kjg'GcTiPm$JGB;?QY=kY.HE)0Le?\(1dYj1BlVHhb=Gun,)"b3!GQHKZ-;5?TiB+L(mjV@6!+iliCM>?8\BK\DjWp]ZGB@roATarCnES+H32nfJcr^_b[^F6D-t^4M"X^ZO)A*2ps>4@(\_YQ1f(alcNTU41qnJ/`!29\hN5f:4pF2\P]4aSO"NadbH*HphuCJX"lVQOm8rC^VJ>ghQJEPdUEALX'/S7Tb0oNLW8um9@e#s@HA#Yi_2+QWXWA2/![6;Eh7!9mmoYo?\AOW4$o=`P.>,^dKd1ccofR^>/B:-.MbXYTJi93(N?,MQ.HRD$?LSp6a#fQ%7s\>rQ`2@]`am3C#&L8)dOBas8)?p9R3ImR_TkJ>o?aGBs-B2=]Q;pF@o5q\*hZ.meBJ5<1A'4j#C`h@m!]*h&9+:8%ubW9`AteG[4o3>q@gC\j"jXNEMH?q!<6q;@r:ng04M!LX#s\f-]\,@'8g3P8a4hZR%ZRg4$R]&sK&"<.RmRSb4D6$T_CE_f)#;`"9TbS(ic\8XOZ(RC.Z6)3Cpm,fSM[esNJp1$=-;#M6^"Y/l]#+T0EGE[=Wr;3XRATO9pl9Mg`g\"cf[^QK_R1HRo&NuB$@M3b%0f(R[I+AuRgc`&rO5RIn-[5p^7\NI0]QC!/T&T6Uo'pZZlg\or#TC<7R/l=06>'q-U?>N;RiJQ]k#@SCH!7"6Zf;_S\94pXR\&;ob+p9`HG2g*)!)V$,?1lM&.Ilb$:(^nQ!?GW)<8qLN,$6siI\@j!*EXnYL9gMbMiqjr[IM?RtJj@/C`qfP8#Kg@]`\qUG9F`N_YSP!.V+#Q#!-FjS4:jo1PX5>Fc(BZ=H=oaAfj^&@JJk)9aG6@o>AA.^KXqbcnOq;(-T!SSI&5aftD-FNF%%p`^Uf('[,MqOLA>#)T;TI?@p+qe1+h/T2fkiQl#M&c9(^L;@;J\dbl?7L_7@]ihBr<`u`0arH&dq>4hQWud@Q)8csfk4,,J!)i.,\b,),64eR)GKBdI`9K$C;".rfLMl=u`P2eTA2Fpm/-fT5Z!K%QVUj$EHCX3eSs,D)tKAseK]Jc'8YX"(eN#%?*bI5QO)bRM!6$Z(i0\Sc;f#jbP:OJDkdMQaI@VFi!uP([RSNs(nkF4i^H1C:N/>*R'=SA'1Oi2/F:X7i*SiQTn7D:=cnQ0D"3g=JC+kY@=bQXMVO9'ef[JulAZn$E4q5L'n4XDb@3E6?%G@ncEs@pB!1n5um#o"G#%"$XBEeZu:'.#GZ2ocL..[jrMj1:aU`DeZI)f8E6I(Zt>o:N4sEiDC1e:JGl78*HfHJG1,r:g@f~>endstream +endobj +36 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1102 +>> +stream +Gb!Sk>E@5m'RnB330+/>*SYRMB;F'm(m_!+>*a$aW@oE7gTYBsihXB)kN(&J1Yp=b#`VfPW,Z[HF2%+ui\I0OZ1^94!'Ha's+1:30OFkp7)8^Xr%[q0H%\hn6jP'b1l+,Q-tke@Y.Og&8S#aa#+@$9PlRKqd5o^k;,/R;TJWm#l7jDR;PaO.bsJDkOaKD>k@e%\:s/"1I#h&^n00''F9o4b;&Se04fL__+:)PP$=UZ"Mne/^T>F751+l^6S&;k<1s*=\"m65qVNWOn109kXSsu:mm<2usl3Y[!_Ye+XCSY;?RNNDh;u^XnJ2+ipX6d,jqbOlIMATKU'YQr5mPRSm,?7^,+J9'oEMpjj!'R(Z+bbT/a$\8aLUl4#MGPepBTDD-QQSsn5+W)THP17a_ZA4W,Tf`L\1\1tclK?.cN%?;OW_I<3;48mZGRk,'[a`him@jc*.JA>Hf9OqLjA!^]f)jYX,F[kU.Lq7HR7'fIZ.__d@1a2)FK1Z]:Xau;t@fq=HO@>PI2[hf!D,H\[^@4ciL[]f)#7)8@=#;6n`*:q3mnK4;*&ALFcX5K^4>o@PLnoE/R^UCd`opa_C?@lFfZS'[d%`apXs_S>3nkEkZ!^1p"GqsK1/*mo/a2U9b1TMN0+jMDerK!adkGL4XITBFE:$\UtCiL)=L-RsEc%E.eU?SnG`LFF6;B<(pc-hiR8;uH.9G1PJ^QY$a-uoQ8:*5Pn_:Eg-YFW41`t.-7B0L4),e3_(b)aX=LBu4q=4_Sbk?5'A`Pp-b.oW5JEoRp2NnpqHFcj[;0R]Zm4E]Q:C!cepSuM:j8?Eiep_d?0TDL1.Cft@4'Y!!hh3:#eA=Y@$+>6JAr)bA5nSo_mD8)MI*[Wu-:s08Wh-1lk[MAm=[YJ+o!DfKrpbeX)[BUEfX~>endstream +endobj +xref +0 37 +0000000000 65535 f +0000000061 00000 n +0000000122 00000 n +0000000229 00000 n +0000000341 00000 n +0000000546 00000 n +0000000651 00000 n +0000000728 00000 n +0000000933 00000 n +0000001138 00000 n +0000001343 00000 n +0000001549 00000 n +0000001755 00000 n +0000001961 00000 n +0000002167 00000 n +0000002373 00000 n +0000002579 00000 n +0000002785 00000 n +0000002991 00000 n +0000003197 00000 n +0000003403 00000 n +0000003473 00000 n +0000003765 00000 n +0000003917 00000 n +0000005843 00000 n +0000007684 00000 n +0000009412 00000 n +0000011289 00000 n +0000011782 00000 n +0000013970 00000 n +0000014961 00000 n +0000017348 00000 n +0000019072 00000 n +0000020704 00000 n +0000022172 00000 n +0000024060 00000 n +0000026093 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 21 0 R +/Root 20 0 R +/Size 37 +>> +startxref +27287 +%%EOF diff --git a/Les11-AI-SDK/Les11-Slide-Overzicht.md b/Les11-AI-SDK/Les11-Slide-Overzicht.md new file mode 100644 index 0000000..0298e7f --- /dev/null +++ b/Les11-AI-SDK/Les11-Slide-Overzicht.md @@ -0,0 +1,375 @@ +# Les 11 — Vercel AI SDK +## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven) + +**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken mee, gaan thuis zelf aan de slag. +**Demo-app:** Polderfest 2027 — fictief muziekfestival met 500 verzonnen bands in Supabase. Studenten kunnen vragen stellen aan dummy data die LLM's onmogelijk vooraf konden kennen. + +--- + +## Slide 1: Title +### Les 11 — Vercel AI SDK + +**Visual:** +- Background: CREAM +- "Les 11" in BLUE +- "Vercel AI SDK" in BLACK +- Subtitle: "Praat met je eigen data — vandaag bouwen we Polderfest 2027" + +--- + +## Slide 2: Terugblik +### Waar staan we? + +**Vorige lessen:** +- Supabase geïntegreerd in je app +- Tabellen + relaties opgezet +- RLS policies bekeken (wie mag wat lezen/schrijven) + +**Vandaag bouwen we niet voort op QuickPoll — we starten een nieuwe demo from scratch.** +We laten zien hoe je een Next.js app aan een verse Supabase koppelt en die data combineert met AI. + +**Visual:** Twee icoontjes (database + Next.js logo) met pijl naar AI-icoon. + +--- + +## Slide 3: Planning +### Vandaag — 180 minuten + +| Onderwerp | Duur | +|-----------|------| +| Welkom + Terugblik | 10 min | +| Theorie: Wat is de Vercel AI SDK? | 30 min | +| **Live Demo 1** — Next.js scaffold + Supabase koppelen | 20 min | +| **Live Demo 2** — Seed script: 500 records in Supabase | 20 min | +| **Pauze** | 15 min | +| **Live Demo 3** — AI SDK installeren + chat-route | 30 min | +| **Live Demo 4** — Vragen stellen aan onze data | 15 min | +| Waarom data + AI samen krachtig is | 5 min | +| Lesopdracht + Huiswerk uitleg | 20 min | +| Vragen + Afsluiting | 15 min | + +**Belangrijk:** Vandaag is **demo-driven**. Jullie kijken en luisteren. Thuis gaan jullie zelf aan de slag met jullie eigen thema. + +--- + +## Slide 4: Wat is de Vercel AI SDK? +### Eén SDK, alle providers + +**Content:** +- TypeScript-first SDK voor AI features +- Werkt met OpenAI, Anthropic, Google, Mistral, Groq, en meer +- **Unified API:** zelfde code voor elk model +- Streaming out-of-the-box +- React hooks (`useChat`, `useCompletion`) +- Tool Calling (volgende les) +- Open source · gemaakt door Vercel + +**Code teaser:** +```typescript +import { generateText } from "ai"; +import { openai } from "@ai-sdk/openai"; + +const { text } = await generateText({ + model: openai("gpt-4o-mini"), + prompt: "Vat de Polderfest line-up samen", +}); +``` + +**Visual:** Logo's van OpenAI/Anthropic/Google met pijl naar één AI-SDK doos. + +--- + +## Slide 5: Modellen + Kosten +### Welk model wanneer? + +| Provider | Model | Use case | Prijs (in/out per 1M tokens) | +|----------|-------|----------|------------------------------| +| OpenAI | gpt-4o-mini | Default — snel + goedkoop | $0.15 / $0.60 | +| OpenAI | gpt-4o | Multimodaal (vision) | $2.50 / $10 | +| OpenAI | gpt-4.1 | Reasoning, agents | $2 / $8 | +| Anthropic | claude-sonnet-4 | Coding, lange context | $3 / $15 | +| Google | gemini-2.5-flash | Snel + multimodaal | $0.075 / $0.30 | +| Groq | llama-3.3-70b | Ultra-fast inference | $0.59 / $0.79 | + +**Vuistregel:** start met `gpt-4o-mini`. Upgrade pas als nodig. + +**Voor vandaag:** `gpt-4o-mini`. Onze hele les inclusief Polderfest-Q&A kost ongeveer 1-2 cent. + +--- + +## Slide 6: De 4 kern-functies +### Wat je vandaag gaat zien + +| Functie | Wat het doet | Wanneer | +|---------|--------------|---------| +| `generateText` | Wacht tot AI antwoord klaar is — string terug | Korte server-only antwoorden | +| `streamText` | Stream karakter voor karakter | Chat UI, lange antwoorden | +| `useChat` | React hook voor instant chat UI | Client-side chat | +| `generateObject` | Type-safe data via Zod schema | Database inserts, classificatie | + +**Vandaag gebruiken we vooral:** +- `streamText` + `useChat` — voor de chat UI +- Onze Polderfest-data als context — AI beantwoordt vragen op basis van onze 500 bands + +**Volgende les (Les 12):** `generateText` + `tools` — Tool Calling, waar AI zelf besluit welke DB-query te runnen. + +--- + +## Slide 7: Wat bouwen we vandaag? +### Polderfest 2027 — een fictief festival + +**Het idee:** +Een fictief Nederlands muziekfestival met **500 verzonnen bands**. Volledig fictief — geen enkele LLM kan dit weten uit training. Dit is precies waar AI + jouw data sterker wordt dan AI alleen. + +**Schema (Supabase tabel `bands`):** +- `name`, `genre`, `sub_genre` +- `stage`, `day`, `start_time`, `duration_min` +- `origin_city`, `members`, `bio` +- `tier` (headliner / mid / opener), `popularity`, `ticket_impact` + +**Voorbeeld-vragen die we kunnen stellen aan AI:** +- "Welke bands spelen vrijdagavond na 22:00 op de Main Stage?" +- "Geef me 5 acts uit Groningen, gesorteerd op populariteit" +- "Vat de hip-hop scene op Polderfest samen in 3 zinnen" +- "Welke headliner is qua bio het meest interessant voor electronic-fans?" + +**Visual:** Festival-poster mock-up met genre-tags + Supabase logo. + +--- + +## Slide 8: LIVE DEMO 1 +### Next.js scaffold + Supabase koppelen (~20 min) + +**Wat ik laat zien:** +1. `npx create-next-app@latest polderfest --typescript --tailwind --app` +2. Nieuw Supabase project aanmaken (dashboard) +3. SQL Editor: schema runnen (zie schema.sql) +4. Supabase client installeren: `npm i @supabase/supabase-js` +5. `.env.local` met `SUPABASE_URL` + `SUPABASE_ANON_KEY` + `SUPABASE_SERVICE_ROLE_KEY` +6. `lib/supabase.ts` aanmaken (client) +7. Tabel-check via Table Editor: leeg, klaar om te seeden + +**Wat ik NIET uitleg:** Next.js / Supabase basics — dat hebben jullie al gehad. + +**Visual:** Badge "LIVE DEMO" in PINK + screenshots Supabase dashboard. + +--- + +## Slide 9: LIVE DEMO 2 +### Seed script: 500 records in Supabase (~20 min) + +**Wat ik laat zien:** +1. `seed-polderfest.ts` openen — uitleggen wat 't doet: + - Procedureel 500 bands genereren + - Combinaties van adjectives + nouns + bio-fragmenten + - Insert in batches van 100 +2. Service role key uitleggen — alleen lokaal, niet in client +3. `npm i tsx @supabase/supabase-js dotenv --save-dev` +4. `npx tsx seed-polderfest.ts` runnen +5. Supabase Table Editor refresh → 500 records zichtbaar +6. Een paar voorbeelden tonen — "De Tigers", "Lost Mirrors", "Sanne Van Dijk & The Echoes" + +**Sleutel-inzicht:** dit zijn 500 namen die **niet bestaan**. Geen enkele LLM kan ze kennen. + +**Visual:** Terminal log van seed + Table Editor screenshot. + +--- + +## Slide 10: Pauze +### 15 minuten + +--- + +## Slide 11: LIVE DEMO 3 +### AI SDK installeren + chat-route (~30 min) + +**Wat ik laat zien:** +1. `npm i ai @ai-sdk/openai zod` +2. `OPENAI_API_KEY` toevoegen aan `.env.local` (schoolkey via Brightspace) +3. **`app/api/chat/route.ts`** schrijven: + - Haal alle bands op uit Supabase + - Format als context-string + - `streamText` aanroepen met system + user messages + - Return `result.toDataStreamResponse()` +4. **`app/chat/page.tsx`** schrijven: + - `"use client"` + `useChat` hook + - Simpele Tailwind chat UI (messages list + input) +5. Naar `/chat` browsen → werkt +6. Eerste prompt: "Hoeveel bands spelen vrijdag?" + +**Belangrijke uitleg-momenten:** +- Waarom we **alle bands meesturen** als context (volgende les: Tool Calling lost dit op) +- Hoe `streamText` zich aansluit op `useChat` +- System prompt — hoe je de AI 'rol' geeft + +**Visual:** Code-mock-up + chat preview. + +--- + +## Slide 12: LIVE DEMO 4 +### Vragen stellen aan onze data (~15 min) + +**Vragen die we live uitproberen:** +1. "Welke bands spelen zaterdag op de Beach Stage?" +2. "Geef me 3 headliners met de meeste popularity, en hun bio's" +3. "Hoeveel jazz fusion acts spelen er totaal?" +4. "Vat de electronic-scene op Polderfest samen — wat zou je aanraden voor iemand die houdt van techno?" +5. **Slechte vraag:** "Wie was de hoofdacts van Polderfest 2025?" — AI antwoordt eerlijk dat hij dat niet weet (data alleen 2027) + +**Sleutel-inzicht:** +- AI is **slim**, maar pas écht nuttig met **jouw data** +- LLM weet niets van Polderfest 2027 — toch krijgen we precieze antwoorden +- Combinatie = `context (jouw data) + reasoning (AI)` + +**Visual:** Chat screenshots met antwoorden. + +--- + +## Slide 13: Waarom is dit krachtig? +### Data + AI > Data alleen, AI alleen + +**Data alleen:** +- Supabase query: filter + sort + select +- Geen interpretatie, geen samenvatting, geen taal +- Gebruiker moet zelf SQL-denken + +**AI alleen:** +- Gebrekkige kennis over jouw domein +- Verzint vaak (hallucinatie) +- Geen toegang tot live data + +**Data + AI:** +- AI interpreteert en vat samen +- Antwoorden in natuurlijke taal +- Filtert + reasoneert + presenteert +- Schaalbaar — voeg data toe = nieuwe antwoorden mogelijk + +**Quote om mee weg te lopen:** +> "Een LLM zonder jouw data is een gewone chatbot. +> Een LLM mét jouw data is een product." + +--- + +## Slide 14: Lesopdracht +### Jouw eigen thema-app + +**Voor thuis (niet in de les) — bouw je eigen versie:** + +1. Bedenk een **eigen thema** met data die LLM's niet kunnen weten +2. Maak een nieuw Next.js project + nieuwe Supabase +3. Schrijf eigen `seed-XXX.ts` script (mag AI je bij helpen!) +4. Seed minstens **100 records** in Supabase +5. Implementeer chat-route + chat-pagina (zelfde flow als Polderfest) +6. Stel 3 vragen aan je AI die alleen kunnen door jouw data + +**Voorbeeld eigen thema's:** +- Fictief restaurant-aggregator in een verzonnen stad +- Galactische bestuurders archief (sci-fi) +- Verzonnen scriptie-archief NOVI +- Fictieve museumcollectie +- Fictief NPO-programma overzicht +- ... + +**Beperking:** **GEEN echte/openbare data** (geen Spotify, geen TheCocktailDB). Het moet fictief zijn zodat de demo-kracht zichtbaar wordt. + +**Visual:** 4 voorbeeld-thema's als cards. + +--- + +## Slide 15: Huiswerk +### Polderfest seed-script aanpassen + uitbreiden + +**Voor volgende week (Les 12):** + +**Verplicht — onderdeel A:** +- Pas het seed-script aan voor **jouw eigen thema** (gebruik gerust AI om te helpen) +- Run het tegen je eigen Supabase +- Push naar GitHub repo + +**Verplicht — onderdeel B:** +- Voeg minstens **1 extra veld** toe waarvan je denkt dat het interessante vragen mogelijk maakt +- Update schema + seed script +- Stel een vraag aan AI die alleen kan dankzij dat nieuwe veld + +**Verplicht — onderdeel C:** +- Schrijf `AI-CHAT.md` in je repo met: + - Jouw thema (wat is het, waarom kun je dit niet aan een gewone LLM vragen?) + - 3 leuke vragen die werken op jouw data + - 1 vraag waar de AI moeite mee had — wat veranderde toen je de prompt aanpaste? + +**Bonus:** Deploy op Vercel — preview URL meesturen. + +**Visual:** Workflow-diagram + checklist. + +--- + +## Slide 16: Volgende les — Tool Calling +### Hoe schaalt dit? + +**Probleem dat we vandaag introduceren:** +- We sturen **alle 500 bands** mee als context bij elke vraag +- 500 bands ≈ 30.000 tokens — dat is veel, kost geld, traagt +- 5.000 bands? 50.000 bands? Werkt niet meer + +**Oplossing (volgende les):** +- **Tool Calling** — AI besluit zelf welke query te runnen +- Voorbeeld: AI ziet vraag "Welke bands spelen vrijdag?" → roept tool `searchBands(day: "Vrijdag")` aan → krijgt 60 bands terug → antwoordt +- Schaalbaar, slim, multi-step + +**Daarna in deze leerlijn:** +- Les 13: Agents + `maxSteps` (multi-step autonoom) +- Les 14: RAG + embeddings (semantic search op groot corpus) +- Les 15-16: Testing + Deployment +- Les 17-18: Eindopdracht-werkdagen + Pitch + +--- + +## Slide 17: Afsluiting +### Vragen? + +**Wat we vandaag gezien hebben:** +- Vercel AI SDK basics +- Modellen + 4 kern-functies +- Next.js + Supabase + AI SDK end-to-end gekoppeld +- Live demo met seed-script (500 records procedureel) +- Vragen stellen aan een dataset die geen LLM kent + +**Vragen? Feedback?** + +**Visual:** Cream achtergrond, blauw rondje met "→ Tool Calling". + +--- + +## Slide Summary + +| # | Title | Type | +|---|-------|------| +| 1 | Title | Opening | +| 2 | Terugblik | Recap (Supabase + RLS, kort) | +| 3 | Planning | 180-min schedule | +| 4 | Wat is de AI SDK | Theorie | +| 5 | Modellen + kosten | Theorie | +| 6 | 4 kern-functies | Theorie | +| 7 | Vandaag bouwen we Polderfest | Intro demo | +| 8 | **LIVE DEMO 1** — Next.js + Supabase | Demo | +| 9 | **LIVE DEMO 2** — Seed 500 records | Demo | +| 10 | Pauze | Break | +| 11 | **LIVE DEMO 3** — AI SDK + chat | Demo | +| 12 | **LIVE DEMO 4** — Vragen stellen | Demo | +| 13 | Data + AI = kracht | Reflectie | +| 14 | Lesopdracht — eigen thema | Praktijk | +| 15 | Huiswerk — seed aanpassen | Praktijk | +| 16 | Volgende les: Tool Calling | Preview | +| 17 | Afsluiting | Closing | + +--- + +## Bronnen + +- Vercel AI SDK docs — https://ai-sdk.dev/docs/introduction +- generateText / streamText / useChat / generateObject reference — https://ai-sdk.dev/docs/reference +- Supabase JS client — https://supabase.com/docs/reference/javascript +- Next.js App Router — https://nextjs.org/docs/app +- OpenAI pricing — https://openai.com/api/pricing +- Tokenizer — https://platform.openai.com/tokenizer +- Vercel templates met AI — https://vercel.com/templates?type=ai diff --git a/Les11-AI-SDK/Les11-Slides.pdf b/Les11-AI-SDK/Les11-Slides.pdf new file mode 100644 index 0000000..38ec2e3 Binary files /dev/null and b/Les11-AI-SDK/Les11-Slides.pdf differ diff --git a/Les11-AI-SDK/Les11-Slides.pptx b/Les11-AI-SDK/Les11-Slides.pptx new file mode 100644 index 0000000..3ef9d5e Binary files /dev/null and b/Les11-AI-SDK/Les11-Slides.pptx differ diff --git a/Les11-AI-SDK/schema.sql b/Les11-AI-SDK/schema.sql new file mode 100644 index 0000000..0a3af2d --- /dev/null +++ b/Les11-AI-SDK/schema.sql @@ -0,0 +1,42 @@ +-- 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) +); diff --git a/Les11-AI-SDK/seed-polderfest.ts b/Les11-AI-SDK/seed-polderfest.ts new file mode 100644 index 0000000..c9eae74 --- /dev/null +++ b/Les11-AI-SDK/seed-polderfest.ts @@ -0,0 +1,258 @@ +/** + * 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://.supabase.co + * SUPABASE_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/config"; + +const supabase = createClient( + process.env.SUPABASE_URL!, + process.env.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(arr: readonly T[]): T { + return arr[Math.floor(rand() * arr.length)]; +} +function pickN(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 = { + "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 seed() { + 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(); + + 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."); +} + +seed().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/v2-klasB/Curriculum-Overzicht-KlasB.md b/v2-klasB/Curriculum-Overzicht-KlasB.md new file mode 100644 index 0000000..339d227 --- /dev/null +++ b/v2-klasB/Curriculum-Overzicht-KlasB.md @@ -0,0 +1,109 @@ +# Curriculum Klas B — 18 Lessen + +**Versie:** 1.1 (concept) +**Doelgroep:** Studenten met al kennis van terminal/git, JS/TS, React/Next.js en AI tools. +**Focus:** Code kwaliteit · Veiligheid · Mooie ontwerpen +**Eindopdracht:** ongewijzigd (Next.js 14 + Supabase + Vercel AI SDK + externe API) +**Lesvorm:** Online, 2 uur per les + +--- + +## Lesformat (elke les) + +| Tijd | Onderdeel | +|------|-----------| +| 0:00 – 1:00 | Uitleg / live demo (60 min) | +| 1:00 – 1:15 | Pauze (15 min) | +| 1:15 – 2:00 | Praktijk / hands-on (45 min) | + +Plus: **kleine huiswerkopdracht** elke week (~30–60 min) — reinforced wat in de les is behandeld en bereidt voor op volgende les. + +--- + +## Filosofie + +Klas A had veel tijd nodig voor setup, basics en klassikaal volgen. Klas B niet. Daarom: + +- **Geen setup-tijd:** terminal, git, npm, Cursor staan al. Controle in Les 2 in 10 min. +- **Theorie kort, demo-driven:** binnen de 60 min talk zo veel mogelijk live coden. +- **Standaarden vanaf dag 1:** elke les bevat één "professional habit" (types, validatie, a11y, security). +- **Huiswerk is verplicht klein:** geen weekend-projecten — wel iedere week een concrete oefening. +- **Eindopdracht parallel** vanaf Les 4 (eigen project naast lessen, niet pas na les 18). + +--- + +## Roadmap — 4 Blokken + +### Blok 1 — Solid Foundation (Les 1–4) + +| Les | Titel | Kern-leerdoel | Huiswerk | +|-----|-------|---------------|----------| +| 1 | Introductie AI & LLMs | (Reeds gegeven 2026-05-12) | — | +| 2 | AI-Dev Workflow Mastery | Cursor + Skills.sh + .cursorrules + MCPs effectief inzetten | Maak een eigen .cursorrules voor je stack | +| 3 | Next.js 14 Architectuur | App Router, Server vs Client Components, folder conventies | Bouw een mini-app skeleton met juiste structuur | +| 4 | TypeScript voor Professionals | Generics, discriminated unions, Zod schemas, type-safe contracts | Refactor 1 functie naar volledig type-safe + Zod | + +### Blok 2 — Design & Craft (Les 5–9) + +| Les | Titel | Kern-leerdoel | Huiswerk | +|-----|-------|---------------|----------| +| 5 | Design Systems met Tailwind + Shadcn | Design tokens, theming, dark mode, componenten library | Setup eigen design tokens + 3 componenten | +| 6 | Mooie UI in de Praktijk | Smaak ontwikkelen: typografie, spacing, kleur, hiërarchie | Herontwerp 1 bestaande pagina, screenshot voor/na | +| 7 | Motion & Micro-interactions | Framer Motion, loading states, transitions, feedback patterns | Voeg 3 micro-interactions toe aan je app | +| 8 | Accessibility & Responsive | ARIA, keyboard nav, contrast, mobile-first | a11y audit op eigen project + 3 fixes | +| 9 | Clean Code & Refactor met AI | Naming, code smells, AI-assisted refactoring | Refactor 1 component van "werkt" naar "mooi" | + +### Blok 3 — Data & Security (Les 10–13) + +| Les | Titel | Kern-leerdoel | Huiswerk | +|-----|-------|---------------|----------| +| 10 | Supabase + Database Design | Schema modelleren, relaties, indexes, migraties | ERD + schema voor je eindopdracht | +| 11 | Auth & RLS vanaf de Grond | Auth flow, sessions, JWT, RLS policies (vanaf dag 1!) | RLS policies schrijven + testen met 2 users | +| 12 | Server Actions & Validatie | Zod, sanitization, error handling, never-trust-client | Alle forms in je app valideren met Zod | +| 13 | Web Security in de Praktijk | OWASP Top 10, XSS, CSRF, secrets management, .env hygiene | Security audit eigen project + fix top-3 issues | + +### Blok 4 — AI Features & Production (Les 14–18) + +| Les | Titel | Kern-leerdoel | Huiswerk | +|-----|-------|---------------|----------| +| 14 | Vercel AI SDK Basics | Streaming, useChat, generateText, structured outputs | Bouw een streaming chat in eigen project | +| 15 | Tool Calling & Externe APIs | Function calling, API integratie, error states | 1 AI tool koppelen aan een gratis externe API | +| 16 | AI Agents & maxSteps | Multi-step autonome agents, planning, tool orchestration | Agent maken die 2+ tools chained gebruikt | +| 17 | Testing & Deployment | Vitest + Playwright (AI-assisted), Vercel deploy, Lighthouse | Tests schrijven + deployen naar productie | +| 18 | Code Review & Pitch | Peer review, refactor sessie, eindopdracht presentatie | Pitch-deck voorbereiden (5 min) | + +--- + +## Mapping naar eindopdracht-leeruitkomsten + +| Leeruitkomst | Lessen die dit dekken | +|---|---| +| 1. TypeScript & Next.js Development | 3, 4, 5, 9 | +| 2. Database Design & Supabase | 10, 11, 12 | +| 3. AI-Assisted Development met Cursor | 2, 9, 17 | +| 4. Skills.sh voor Consistente Output | 2 (intro), terugkerend | +| 5. Prompt Engineering & Iteratief Werken | 2, 14, 16 | +| 6. AI Agents Bouwen met Vercel AI SDK | 14, 15, 16 | + +Bovenop eindopdracht-eisen (kwaliteit/security/design): 6, 7, 8, 13, 17, 18. + +--- + +## Verschillen met Klas A curriculum + +- Geen losse "Cursor Basics" — Klas B start direct op niveau (Les 2 = workflow mastery). +- Geen "TypeScript Fundamentals" als beginnersles — vervangen door pro-versie (Les 4). +- Design krijgt **5 lessen** i.p.v. impliciet meeliften. +- Security krijgt eigen volwaardige les (13) bovenop Auth/RLS (11, 12). +- AI Agents krijgt 3 lessen i.p.v. 2 (14–16). +- Testing + Deployment samengevoegd in 1 les (17) — past in 2-uur format. +- Eindopdracht-werk parallel vanaf Les 4. + +--- + +## Open vragen voor jou + +1. **Eindopdracht parallel vanaf Les 4:** akkoord, of pas later? +2. **Pair programming online:** breakout rooms in Zoom/Teams of laten studenten zelf afspreken? +3. **Huiswerk inleveren:** GitHub PR, Discord/Slack channel, of niet centraal verzamelen? +4. **Die ene gevorderde leerling** (recruitment-app): aparte uitdaging in huiswerk of normaal meeloopen? diff --git a/v2-klasB/Les02-OpenCode/Les02-Docenttekst.md b/v2-klasB/Les02-OpenCode/Les02-Docenttekst.md index 6e1e2e4..48885e8 100644 --- a/v2-klasB/Les02-OpenCode/Les02-Docenttekst.md +++ b/v2-klasB/Les02-OpenCode/Les02-Docenttekst.md @@ -60,8 +60,18 @@ npm install gsap @gsap/react lenis git init git add . git commit -m "init: next 16 + gsap + lenis" + +# GitHub remote (maak repo eerst via gh CLI of dashboard) +gh repo create scroll-demo --public --source=. --remote=origin --push + +# Vercel CLI + koppelen + eerste deploy +npm i -g vercel +vercel link # koppelt repo aan een Vercel project +vercel # eerste deploy naar productie URL ``` +**Check:** open Vercel dashboard → project bestaat → productie-URL werkt. Open GitHub → repo staat er → Vercel preview comment in PR werkt. + ### 4. Test je setup - Open OpenCode Desktop → File → Open Folder → `~/scroll-demo` @@ -228,7 +238,7 @@ Dat was de theorie. Nu zien jullie het live." --- -### Blok 4 — THEORIE 2: AGENTS.md + opencode.json + plugin + stack (15 min) +### Blok 4 — THEORIE 2: AGENTS.md + config + plugin + stack + Vercel (17 min) **Slides 8, 9, 10, 11, 12. Achter elkaar, zonder switchen.** @@ -290,10 +300,11 @@ Framer Motion is uitstekend — voor app UI. Modals, page transitions, micro-int Daarom staat in onze AGENTS.md: 'Geen Framer Motion'. Het is geen anti-Framer, het is pro-GSAP voor dit type werk." -#### Slide 12 — Onze AGENTS.md — 3 min +#### Slide 12 — Onze AGENTS.md — 2 min **Vertel:** "Hier zie je onze concrete regels voor dit project. Let op de structuur: +- Why this stack — context voor de agent - Stack — versies expliciet - Hard rules — niet onderhandelbaar - Patterns — hoe we organiseren @@ -307,9 +318,35 @@ Regels die opvallen: Deze regels heb ik uit ervaring. In de volgende demo zet ik dit in, en zie je dat de AI ze volgt." +#### Slide 13 — Vercel + Preview Deployments — 3 min + +**Vertel:** +"Laatste theorie-stuk voor we gaan demoen. Hoe sluiten we onze worktree-workflow goed af? Met **Vercel preview deployments**. + +Het idee: je hoeft niet handmatig te deployen. Elke `git push` triggert Vercel om automatisch een deploy te maken. En het mooie — voor élke branch krijg je een eigen unieke URL. + +- Push naar `main` → productie URL (`jouw-app.vercel.app`) +- Push naar `feature-hero` → preview URL (`jouw-app-git-feature-hero-jij.vercel.app`) +- Push naar `feature-gallery` → andere preview URL + +Stel je voor: je werkt aan een hero-sectie. Je pusht naar je feature-branch. Twee minuten later heb je een live URL die je naar je designer kunt sturen voor feedback. Geen 'het werkt op mijn machine'. Geen screenshots. Live. + +**Voor scroll-storytelling is dit goud** — animaties moet je zien om over te oordelen. Preview URLs maken die feedback-loop razend snel." + +**Setup (kort uitleggen — komt zo in demo):** +- `npx vercel link` koppelt je repo aan een Vercel project +- Vercel installeert eigen GitHub app +- Vanaf nu: elke push = deploy + +**Combinatie met onze workflow:** +"En hier komt 't mooi samen: 1 worktree = 1 branch = 1 Vercel preview. Drie features parallel? Drie live preview URLs. Verschillende stakeholders kunnen verschillende features tegelijk reviewen." + +**Demo aankondiging:** +"In Live Demo 2 ga ik niet alleen onze SmoothScroll bouwen, maar ook pushen naar de feature-branch en zien dat Vercel automatisch een preview URL maakt." + --- -### Blok 5 — LIVE DEMO 2: Setup + Worktree + SmoothScroll (15 min) +### Blok 5 — LIVE DEMO 2: Setup + Worktree + SmoothScroll + Vercel preview (20 min) **Slide 13.** @@ -411,6 +448,45 @@ scroll storytelling. Kies GSAP voor timing-precisie + GPU-perf. **Zeg:** "Zie je? Met goede AGENTS.md krijg je consistente output. Zonder zou de AI waarschijnlijk Framer Motion pakken of useEffect gebruiken. Schrijf je regels op, krijg je betrouwbare resultaten." +#### Stap 6 — Push naar feature-branch + Vercel preview (4 min) + +**Pre-setup check:** repo is al gekoppeld aan Vercel (deed je vóór de les). Open Vercel dashboard naast OpenCode op je scherm. + +**In de feature-hero Session:** +- Type in chat: `Commit deze wijzigingen en push naar de feature-hero branch.` +- Of typ zelf in ingebouwde terminal: + ```bash + git add . + git commit -m "feat: SmoothScroll wrapper" + git push origin feature-hero + ``` +- Wacht 5-10 seconden + +**Switch naar Vercel dashboard:** +- Refresh — zie je nieuwe deploy verschijnen voor `feature-hero` branch +- Status: "Building" → "Ready" (duurt ~30-60 sec) +- Klik op de deploy → toon **preview URL** (iets als `jouw-app-git-feature-hero-jij.vercel.app`) + +**Open de preview URL:** +- Laat zien: ja, het werkt live +- Scroll erdoorheen — smooth scroll werkt +- "Deze URL is publiek, je kunt 'm nu doorsturen naar een designer of stakeholder." + +**Vertel:** +"Zie wat hier gebeurde: +- Lokaal: ik bouwde een feature met OpenCode +- Push: één commando +- Vercel: automatische deploy, eigen URL voor deze branch +- Designer/PM: kan reviewen zonder Git of localhost +- Iedere nieuwe push naar deze branch overschrijft deze URL + +En als ik nu een tweede worktree maak voor 'feature-gallery'? Krijgt die zijn eigen preview URL. Drie features parallel = drie live previews. Dat is professionele scroll-storytelling workflow." + +**Optioneel — toon git workflow visueel:** +- Open GitHub in browser +- Toon de feature-hero branch op repo +- Toon Vercel comment in PR (als er een PR is) met preview URL + --- ### Blok 6 — Pauze (15 min) diff --git a/v2-klasB/Les02-OpenCode/Les02-Huiswerk.md b/v2-klasB/Les02-OpenCode/Les02-Huiswerk.md index 1c7f351..ef35649 100644 --- a/v2-klasB/Les02-OpenCode/Les02-Huiswerk.md +++ b/v2-klasB/Les02-OpenCode/Les02-Huiswerk.md @@ -10,15 +10,21 @@ ## Doel -Bouw een **kleine scroll-animatie landing page** met OpenCode, gebruikmakend van AGENTS.md, opencode.json en worktrees. Deploy hem op Vercel. +Bouw **verder in de repo van de les**. Maak van je eerste sectie een complete kleine landing page met 3-4 secties. Deploy + previews lopen al via Vercel. Dit is geen design-opdracht — focus is op de **workflow** en de **technologie**. Een simpele maar werkende site is genoeg. +> **Doorbouwen, niet herstarten.** Je werkt verder in dezelfde fork als in de les. +> AGENTS.md, opencode.json, Vercel — staat allemaal al. Je voegt secties toe. + --- -## Voorbeeld-thema (kies zelf!) +## Vertrek vanuit je les-werk -Suggesties: +Je begint **niet opnieuw**. Open je laptop, ga naar je `scroll-demo` fork, en bouw verder. + +### Thema kiezen (als nog niet gedaan) +Heb je in de les een type sectie gekozen? Bouw daaromheen je thema. Of kies nu: - **Mini portfolio** (over jou — projects, contact) - **Restaurant landing** (menu, story, reserveer) - **Product launch** (features, prijs, signup) @@ -31,24 +37,26 @@ Eigen idee mag ook — even checken met Tim als 't iets totaal anders is. ## Eisen (verplicht) -### Code -- [ ] Next.js 16 (App Router, TypeScript, Turbopack) -- [ ] Eigen **`AGENTS.md`** (mag onze als basis) -- [ ] Eigen **`opencode.json`** met permissions + (optioneel) plugin config -- [ ] **Minimaal 3 secties** met scroll-animaties -- [ ] **Minimaal 3 verschillende animatie-technieken** (bv. fade-in, parallax, SplitText, horizontaal, stagger) -- [ ] **Lenis** smooth scroll werkt (geen "harde" scroll) -- [ ] TailwindCSS voor styling +### Code (bouwt voort op les) +- [ ] **Doorgewerkt in dezelfde fork** als in de les (Next.js 16, TS, Tailwind) +- [ ] `AGENTS.md` aanwezig — uitgebreid waar nodig (regels die je tijdens werk leerde) +- [ ] `opencode.json` aanwezig (uit starter, mag aangepast) +- [ ] **Eerste sectie afgemaakt** (uit lesopdracht) + **2-3 extra secties** = totaal **3-4 scroll-secties** +- [ ] **Minimaal 3 verschillende animatie-technieken** (fade-in, parallax, SplitText, horizontaal, stagger…) +- [ ] **Lenis** smooth scroll werkt - [ ] Werkt zonder console-errors of hydration warnings ### Workflow -- [ ] **Minstens 2 worktrees** gebruikt (mag via plugin of handmatig) +- [ ] **Totaal minstens 2 worktrees gebruikt** (de lesopdracht-worktree telt mee) +- [ ] Elke feature-branch gepushed → Vercel preview URL bekeken - [ ] Branches gemerged naar `main` -- [ ] Worktrees opgeruimd +- [ ] Worktrees opgeruimd na merge - [ ] Code op GitHub gepushed -### Deployment -- [ ] **Vercel deploy** werkend, URL in README +### Deployment & previews +- [ ] **Vercel gekoppeld** aan repo (`vercel link` of via dashboard) +- [ ] **Production URL** werkt (deploy van `main`) +- [ ] **Minstens 2 preview URLs** (van twee feature-branches) — Vercel maakt deze automatisch bij elke push - [ ] Geen broken pages / 500's ### Documentatie @@ -57,6 +65,7 @@ Eigen idee mag ook — even checken met Tim als 't iets totaal anders is. - Welke regels heb je later toegevoegd aan AGENTS.md (en waarom)? - Hoe heb je worktrees georganiseerd? - Wat zou je anders doen volgende keer? + - **Screenshots van minstens 2 preview URLs** (Vercel dashboard view + browser view) - Screenshot van `git worktree list` tijdens het werk - Max 400 woorden — concreet, geen fluff @@ -76,13 +85,15 @@ Eigen idee mag ook — even checken met Tim als 't iets totaal anders is. ## Tijd-indicatie -- Setup (Next.js + deps + AGENTS.md + opencode.json): 30 min -- 3 scroll-secties bouwen via opencode (3× ~30 min): ~90 min -- Polish + responsiveness: 30 min -- Deploy op Vercel: 15 min +Setup is al klaar (gedaan in de les). Je begint bij sectie 2. + +- Sectie 1 afmaken (als niet klaar in les): 15-30 min +- 2 extra secties bouwen via opencode (2× ~30 min): ~60 min +- Polish + responsiveness: 20 min +- Vercel previews checken + screenshots: 10 min - `WORKFLOW.md`: 15 min -**Totaal: ~3 uur.** Loop je vast? Vraag in Brightspace of plan een korte 1-op-1 met Tim. +**Totaal: ~2 uur.** Loop je vast? Vraag in Brightspace of plan een korte 1-op-1 met Tim. --- @@ -114,11 +125,12 @@ Eigen idee mag ook — even checken met Tim als 't iets totaal anders is. ## Inleveren 1. **GitHub repo** — push alle code naar `main` -2. **Vercel deploy URL** in README van je repo -3. **`WORKFLOW.md`** in root +2. **Vercel production URL** in README van je repo +3. **`WORKFLOW.md`** in root met preview URL screenshots 4. **Brightspace inlevering**: - GitHub URL - - Vercel URL + - Production URL + - Minstens **2 preview URLs** (van feature-branches) - Korte note (1-2 zinnen): wat is je trots-punt? --- diff --git a/v2-klasB/Les02-OpenCode/Les02-Huiswerk.pdf b/v2-klasB/Les02-OpenCode/Les02-Huiswerk.pdf index 10fe2cb..4ad1277 100644 --- a/v2-klasB/Les02-OpenCode/Les02-Huiswerk.pdf +++ b/v2-klasB/Les02-OpenCode/Les02-Huiswerk.pdf @@ -2,7 +2,7 @@ %“Œ‹ž ReportLab Generated PDF document (opensource) 1 0 obj << -/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 6 0 R +/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 6 0 R >> endobj 2 0 obj @@ -17,6 +17,11 @@ endobj endobj 4 0 obj << +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< /Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -25,14 +30,9 @@ endobj /Type /Page >> endobj -5 0 obj -<< -/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font ->> -endobj 6 0 obj << -/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font +/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font >> endobj 7 0 obj @@ -72,42 +72,42 @@ endobj endobj 11 0 obj << -/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260518122502+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260518122502+00'00') /Producer (ReportLab PDF Library - \(opensource\)) +/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260518125917+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260518125917+00'00') /Producer (ReportLab PDF Library - \(opensource\)) /Subject (\(unspecified\)) /Title (Les 2 Huiswerk) /Trapped /False >> endobj 12 0 obj << -/Count 4 /Kids [ 4 0 R 7 0 R 8 0 R 9 0 R ] /Type /Pages +/Count 4 /Kids [ 5 0 R 7 0 R 8 0 R 9 0 R ] /Type /Pages >> endobj 13 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1631 +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1980 >> stream -GauHL939k-']/^ggg+VAW0[@EB@iG>3FtfU;n?Dt3_CT*>HTQql+`i@^Crp>9Gs"W+BeR/rU(UQf_D36B:K%9T>K=r$C&AK%b)dW9[D[BCAkFDo3(!t5TFZ'+Ti?s5&0Zpl(n[Q[$4dDVf=)j$p_DpoS_]MpA3n%')#;StA%!PCKUHrlW>e7&VPi`7i&+QD+i-T6^@DgfSf]j9;3lL_SC@2RY5&gWB6B=TMfUV5\ofKE9HCS*>Y^XV6d-O&#CtYaVJtMH+p<2f3$sH:3h+T6[bX(Y*6cH$WL8-X**-^NfU_E5PG8:aZgE%HHZ`lUC$gW!FpO:M2ms"+!boVQ:BC5uJ,9E\O%`DVh4;q\3F=Cg.9d[M95oIC.((;bFB$2TS0uI_`0F%YYdC+[H'Z"qMG%&%T!-O-kFbt'\_M0=He0ojGmo=K82ZeY;L^_G9:e>[EAMT7P'Tb`99P+[RUP3qL9+XF$`'p7'BmfD0Q$jrp:jJ`)m\%G`B2LZ%d4CEJGaumi!]HEN.94/W^o^3_R.6LG;s(Yd*2cAuZV20M1GBFXB+U-I.gB%WeI+o.pnurAS3Q%p9_sa^5%iVO/.OC5_m6!r3S2H-6f\7Ne0mHtL"kL^/-=\BGQ:XW$L5T;I*u%3&OXO:7jV(aPT1(&-BsbauK\Io-GGiKRVr,j"P>;&YL"BeH7W(h`!Qr#Q`7/_k:83u@>f?#)P)''=H6FNRI:`e9oN@Qm-&sYsmAtkNJlqlfM,3gP^\VdH9*'p-WDQV6H&-k])H(s`\rD(?5e9L_JCFhT99%61bbo6eG9n/=?/eYO2'GY":TLnb3I&5>e;)_E_Z%m<=mesCM1r\XU&BX98UQ;-Njb)RiL<+Tn'#/WXjO!SHI5TkfVlj_F4K'N#-I"g)agr#871S&f3WI/o6EUYY=,BoNr-s-fYXH?fWgYoi$[!\'>tuMCR>taiUFu.8dscTASZF40^D*N)WZoT-XZ.i\eB1t]YQ7"=K6`rmpRJdH/>*n-h@80ZFO]HVB`_W*!+c=E1MBE$g[nJ@3%#?X@e.crPFMV]04L>7ftl3s,#6n-qfBe*"+USB"^@mQ_I:i(kP"_WlS.N1.o\@(3je9@=G^^:Z"?'*S5f0aI+a,A-#<1rt@)("*@4qXefps;jd\:#-Y6E$=/6a3f4QcS44l8\(PRR@/UJlcQC[~>endstream +GauHLfll+.&:N_CbY(a&<(7.4moq[tDOib,'^*Fg0Ek%T8:oeETChG2+MgdAAN+>i,FT<#1O!g$c7fr+r1WdrQiOdf_M^2WPehGOe0[(I0Au6IphgZ*1=(,o=MAJQ$qOd*Iul=B'HEIL!=L=NVFQ$[#2?&opB)KSmdiR+5qZREgn6npfCXG0Lef&CGIR9M`+9?_gVe*f?us)CcWB'/`7<7DR*@9&_\H.'lE"/I0ra1n0MUKdj5oqo]U'"'KM*b(pfcf(>aB?HQj5:j:L=l+<0UhrDo>ht1.t#=5(i)?9V3hB"8fMA+B[M?gV4:'0R-qn`P'>oNS.*T&Z$K2^s6T:Bkte$3`+G&`![Omcf/?L%2\abL@$uqQ;*2*-6UNSeMi-I]=u8\HP]#D]:m^uVW1L\[t!NQbG,)[egLurYCAF;C\\aS8(a=>]6e;ken51L/:l^LOTf_0c7XJV$'dk[8r.P)eFYH,S#=6W^c-p.a\Ee85n;^Au6U?*HIP>eS%1pU)F'OFZ^&=q;d20=q\IIaArp37,LMr_46[:QUt8FR>-4E4?N6"ddq`/t-NlVP^8`!Bi!PUAE'PR]*f^AIL"2cTFVbZfJoth;+A\GS"[V+h@eg+e4j1f*'))R&%&#O"_`HeG).[Z+qROfl-KhjBJQ!2Gr&.Tc_@j^Fb1(O3)F!k&[qkb]Mfp,a+#eRZ\c"*`*Ai8G2Ka5D+ks[BD"X?Ar&_j1G_riIHE>It9P#YPXT?0pon)>TXQ'^t$/_=ACA`fQY_@c6B&K.L@;Li3G&.dJbgX8]*JAp"2bcY(\mZbI.,.$E*$PSTA%HgOB>iK;eYf2=.lc0k^+.CMgX=,eccqe=c7&S!Vtl`Q,fG1P=`V"Vb^&A@No<;E1p?k2HO.^rBP5":t!V7PPbM+^j_.SQG!17Q(22a\HHdYk5NE=q=gN_a:3OdgOZF.E<"]'Y9drAr:kB#,ngZq9rQFak(+WMg:,;a4P31LLq@ePbbMMI`\k1b;sh)<^78cJ'c]1[-$VUFX]HG02q55J'#Q%J@BI6/j0[.&e>K?ACXXaQHM?@W+(A'+t\(B*lss<(i+Uarmo[GnQuS:OXfNr,eDh!a8le$eHFDlk!(2k_hH1`fKBa3n&O?n&&%Z/8Ec\7+V\otBt2q!HS0PG+l!AFHZY%0,$LdBFqAj^2#hmWXfk7SIP'$Z)N<5!o)`l%eo+5ln3n]!$6("[%[UX*`BkUD6G71U5'cid-K%Jjc_<0070,No+Q<,gPe1(ZD-fu1-UETc&g];m:e#Y>?uI]pUVZA\D]*I~>endstream endobj 14 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1724 +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2005 >> stream -Gb"/'>BA7S&:XAWfXFeHe6);?]UUEI-prPQ)sBq$<*QW`.$B^XQLa@fg5b!r\M.I;\9*HPPGGLkg2AX:G"'3_(MQ's3P`fm]Qceo:`)uZGd[k;;`Gh&h^k6$2"Z1"h8jSG$?kdeJ"SSjJfiW29Oe-d'A?=IJJSn[MW+miXp[T0K'"b8D@s+j^^24rm"kF&'WeE\m=8*ig9^`#L``d,l5/"9`HJK8%b5CQ-k'aM-)b.Mle)d<6Qa6]qmgbDYWi->%iAJC46BuUEbqg2D_>&;hf8=t%N=ft@hNr`7/"gje.&n5hB#LVN.2oGQU$2L<#1pLh"ni:_sD[FX'+c>Xhcl@[#Be&&23,#EAZ6VX*e9CAT9r\@j9i\>ZoR,N4n[q3EGm_po:14po#,:@l!M^4t!N:]&B&ejh\:[;0s(cAJo@j]]>%;_,4HpO`bY_g=GIC5H9h$2SI\1MGZMUm(](5P"9_^8bp(:(E<,'$f"DE'8gCD9:*;?'GPC0D)(j=YcI/gStS>--h$c]?SnQ[W:etY#qKCIYQ-djPT?PE:il!j7qE$Voo>$dK;>KS7SAS%h;94E643%g=Rib,4&'fgF]qVDo(KoV_Vqh2/s@,9YkqA&IgXc't_0Y(F,rCb6/Ic_b%s-jE#43jEb,TTds:50E<.rK,L?:CV'W2-3JWDA2.^j+TJhjXZ#1@'!6gHAG,#4dUpjT0HB:oY?qN_br)*EKb.LdQl&JDLB`fW=n!;9GQ6`3b#3[ja:XY11EkEF&Y,2^0OnXd&`cP#5EmuQIoPT+Q>o']Nk[AMg]7_igM9T[ST7l7#=gXQb2gb@J@[Kj4(-J;H1c'Y]oJ40/&Y.*doY_qE35?o(Z($9B"LkK#jRa1^#_e*$$):Dr/b]rF9QCdq/*,dg6I=1VBo\V:[?=;'^7,?T4/sM(Kc.oaoMlB3a=srU%E.NeBQGtm1VKp0M"Za>N.h7.KY/)M+]Z-!H(mEsYL;IFh)%c+Vj?1%'YgHPI,3];5$$]dBd"M<^PO[I,Fn9^c0s7:k&[c4j>oh/)6/1o^G<.uY#*4*D,E,$$bl:6\/gg.#\-ToEP\Z'\:q<#fendstream +Gb"/(968fX&AI`dqQu.o;"U(+Wi`C6j2@p>SgNu]b9/ZOCe6u`"!mm=gS5Q%0F9b$:_dIB*bfr3^Zk"^\n-Fa0<9J]14X")Gk/]QbD+sY/D"iLGRB*T.=>)-UT&j-D$TS&1(+o%EC1*g5EXpG8pZ2FBm_r+m[dP;13<%dZ,=bVWihN@s1u3jVh1XgZp(jF&1;"a68Er>*]rS%["J=klSi%Y*]dNiM(RS9qn?7W[7bt>:0,6$kO:&o7;Ck:M%ld)F`Em5ecLJGTQBrPmZt6(7G'dVdq'#!32jh\k+,:E5'.Ab.&m6CO.\IU-HZH--tT\:uC/_K`F(N.a;-%K(AQqq4$?.Oi?&\Y^D:O4K5u.c;fI+M*-M=@hNeolUtN?#ng2l8%:i]r$KFrJ2/aRLbcu`!)X_#)F2PN#6H/_Qq)7oB_+[,#G`sq"GL;_U$P(b&q)@J)(X3C#X&-3&2\/C#f/Mn8lnhsUhH7N,f,GE>I=W(K`R_\0oI?QN&NeMa&([E8FG2[#O!j5)g*MZ`]h^q0Op9B.JqPGX'*TqOPoNB<(MHcEXcPT4(aYK*8.3uf,O*%`c:mr_Y>G>I+r:k9F*MX]q^NNC7gna['Ei72.fgRS.B>oUGUrOndVM,C\7_@j\0WIZ*uf#&tUA3?cCQ0To68YAoU73#(OQTL2aIRXB'0UeN0[mY;"WO]tH=L(&X&fe6'0r6)$`l$WZa7fE?V!9K"$"\c=bTnf#0&biG9,LT^C3%fQA7KDAo/nOS.6StEWYAp`2:J[)E#O5J(uJuiccDaa]#/Z;-H(j6_45/l<%*_ijeQ$[^/XF4jQ^2UiX0gq&F%Hpo59Nn-?V,,$=B;K]tI&lP9_:bS-rBl1??p(,+GR3ThU(J&qTR;^KAPVLCC6\p/;ss^4"-<8l1*D(\AK0=hfAA5iE[RZL'endstream endobj 15 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1334 +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1415 >> stream -GauHK9j?@A&AJ$CbgB#B79Y,AEbHOcJ>@==J4_H.#\blo.r1DUaj;+(m@BMQ@r,Z,Zk"!-fJE!?]^ohV/:?*pmplWA'#Dt`%5roR%(8f6Lk]jeomZN=okE?15TFAr+U&K>jpP/>/&:b5+4AL?Ad*hk0"B+PN.hZgl32_ZJDr83hm7k:^(i=<#dZ[dq&B/?nL6oU0(FhAHBA&c3DpmlWmB)@Qq-t.2Doh/EaJYT(!VHcj//NB4S+7:I/F\YTUGHG_iIs0]l^u7mehHC4Z4aJ$4XH&@PsPFLA@qnJh[SR]Ok$A>qN19Q.)a3s*//F-JB&mtaC6^+,?aR]OXWM@d5qOLm@-tWY]V&j^ONfrc./>bNSl>/kE6d97fBqZ!=ZX#%oiUN.2C@oTTXpaXuN44d5u*"6#^61rYHUk[LVBWr9!b]icH4X#uQ]+6%)E)YnnMNFXr?!YEPee,Nb(sd[n@MZB^,j&%-.li5*08,('FQa0[pdLgVI";Gaq'Tb.Q.VqMo/tAa2,XDJK,(e2Q8%/egC2jnW!a'O]'"oN'qM)nM^2>i8oW'd%Ln#HCcBZq[WCX88q#4:]>40/3>o5_Kj^oI)`PoB"*X`jeX=Z1Q1j>Glld),&kjJZbkUUUX[MP:uNi>$Rkk\/_OpN4;A^*qnS5`rM.P>&;"J#P=HSU1mt'o6Ci:,3fZ8HT]`.;7Gkp0+A)![/"s7_Q2nf,>r_.[WArJe'ZkP_Z3R)1aL2@@Nk<7h@rN[qd:N^RSc/IgHOlV0ZEEqBW@puu7^m5$,&(*pKEL_N37gJ<_Oc:5>PfWff_`ZN#06!uO3&>dPM)$S_QZ9mag15im59UqYmJ~>endstream +GauHK>Bf'r%"@A@k`O[R'75*GmmDH-gV#L^Z3edin1rVtL,C#r`[3]oaD\80JZqD4CjUcE*'hq"IF:Zl$P$H[o:@K5h@/B0]aHHJi<3DXq(TI3GJi9E4iTi1q#N0F3*sg[`ErKC1[X98KIi(-m"01#-6aOZiA)Qc!M&eVaGmtX9O@Et?r?,U_?VFC+;Edp^;iRc$/0#X(rk>,SU[AW04nYU*P'ieaV[UZ>u5=;T$JB8L]SDGT`<'om3h\-VeD9^ef"Bg)GF`hN&ZmVr6sTZ>%L?#iE'8nVMbW?jU2pN@JI.Jg9C$m0a0)%n*OJT"V)V)AadunCX$2-WQHN3)g8q,V^k+Xmi7=3a@+t]P`*GDDg:5+=Q?!d-e92BGa#N6)kC%G7X\R,9';#`kf%c8RguNf$=^a.JCFGP_DPo`1hFMT7Bt8Wi4@3J:A\h)>GJ=L,<(LOo[7e`i4?dR"P^?:g3L'KGkX$nkeC%pbe[??JXimKqA<9U;Oj[jNP*%_,c>[-3+Ifj-Ydt)$d99Tin9OP$(tt(NIhnrM%'=]p'N@>-(:C_98uT(2*n#:GfbI;0Ccc8@&^PbWYgtHONJ1ZeVQFc9*1h$lhA0`YeiG`LCZ?,=!5tgbS*ZWmr1#17f?'Ob7)367V$aA!#$>J;L!g=tG45l7sKu"'64#!ebTN+%?pLiiP5Lpb-iLlB].'e`MohO/H0,QQRG(a[Xn7Be,ofNb:s)4J>eN=kN4B2\.1457=7r6mO2R1l-mNpTcT'.+EWrf4'a:,do;Ar1('N6HMZ'Z_G82Ge4B7quP=XB$WS0glqY0kO^ttB!:JDcFn&T2p"'UiSo19/,adbM6/=$a$\Zf/i8h=2e>8D_CO"c6PIZCXs[ep;HNKho'U>M>a8[*nBE4ajjmiTA1R=G~>endstream endobj 16 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1876 +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1916 >> stream -Gatm;=``=U&:W67fU%Pf___DNSa!M)i](YCUQ,u%p][C"$8Mo9+D'+!oj**D7+#K*]!8>h_o9+Z[n?cT^52_]$\6\\*5c:BNi@.i#:O+R'[\^F5GTEKnV2nP+nJik6-G`3NY0F(o2)5;qm>u<#PB7?3hsIBshN.%#:I1lG?K>Xp>C1mqSUPJ8qE(8%K1gE7\]2%:J/NQJUXH"O3b8m)F^845YI1==(<^0[MVHXint28Sij'fq>00eoJRhk1M'@l:]nA/k.aO0C'EtN?042O9f@Y-gcafR_pEbG!)c$fi]>q-5k\`E1)L,<@jS\B)pkr'EJMF^p5=>]/+]U;JS7@0n$CU#d>g;\t8tMbOf?a2*DLfj%Asug&[GWf?;sqk&-)!S@$a'kg<2*oK]Z?Gls8-mq:rBm"RUA;bM>SV.bH-rB7J]?janS7W`NA\$LHmni0W%-,a@$3Bb4^29J.HCUM7rqKl]gQdjbmC"K4285Ebpl-K7gs*S_U8AQ[c`_piL)Q[n`?n,*'C_e35`!$lr7+eX8Ni1kT/8S-.AHkn9"bG!FMZUmS_.dh3P)gnW-K;\sR+a/WT"hZMD>haF%,H%g+O@3Xi1Xi1-lk8D$EL'sNfn[,PL,`_1[8`/URKVuXii;;`kKO_b_`?ps_SF.,f\QG4^"&BZ_58u'g"6E%_7g*-,mafhGF_1XYU(^kW6JfEJ0IQi(L7"1,!csL`H1RRcYi`F,53H+b'Yaa7E#Y5Ae$\nH0S?DB+@j`dD>&CpP"6c8p^&QPVG];SYNs#e\Z.#JT?MWqA.r0V.&i6ieY:\!u-Cbt"97*io8#)tO+!W,d%kB`OC?PtqdbmDS7Z`$5mYb"C'#BOg2gpL_2,3*D=E'gNa;Il8PfO@EUfsFn(nOgE0LWuSl&;UIO*&47.+(NkC5ILHYk45)ROAqDUIhS9Xlfr"O[&')/5(G8F(8t+A-L8nS:%d(ffk?mp5[PoHWIGkZ0!U>qHtE@h\D/7gtdLGpG,141b20Sm5&(V(9tLbF.CE(-6=:3:>R$C'fR?Im]O7M1N/73fG&P_`^Za@R,NQDg0$s#r0]FMice#`@[05GoE[X&KS_WYLbt"?!rmHrIkPkjY0^A\f"b0YihW@Cd>])4p`D*\f9UTJS.$(;mkt\=Xepkm*I_u)9mh1&';>J9:b3kpSa3'bpf(Ikr.Gjl]nEt#9SSF;3WY.endstream +Gatm;>?BQ=&:Vs/R$Sk-!Fs7G4_N(n[$8!l02'db_MdCZ8&:Khfh%M/qAm\$ie4gM]-TC`4Sn4>c'X$>04M]F!oSG3VlpC<$H"qJ^>m-#$8.'cDZbcL2"C`f]\)li$;TTK5LA(K64c1d65'F*)`_p!K-(aSNft;.Sr4d*J`^qG@R._c68sdME+0H5EVSb1kCf(oC>T?70\JYIfG>Lu&gqLs6Sn$eJtW<]S6DV4^>kA!$=HuOiJ3J$hng=tnM6X`Q,Aq\o5mmH/Ys&L]feA+92S`;%'TGXkA"%:=ZqQ!'S\9"Xq&27-0T.bD;npm&h(D\MfQr4I1?(1AO80A4#h_"9O"0O2MO[XFQ1+/0c*%aH,g7.6DO;I2+Gj1L"`=CSo^b#R--eJ)f!!BDsg>&,u,=CtQ%N-WK8bZWi[lkJGqiQ=,B99+)(HL15efNa_Vk4pT!1O1r/H&=N952W9K!@GLKBjq7qN6>1[\MGh&KEAR+\1Rn^M3?ub/>$GPn'ffPp$6X6JpB!pnrkm]ZW-cBkmJ@Vj!-]'pVtDF!pOO>Qj+]W',BRXg[AhYhQ&ddpAS-r;kCmocZ8[YL>i!MYsiO?p&YJAEc4-Js7Q6Rj,M1;.V6&G3;P[5qp[GVZqlZ_BCZ\_%">0r%UhnWf"'/%oea8[j,JjqAk$^",FfLooP"Zc:lPZcPX21XtL#T!6Z,-(2L30QT9H%9YYYipkeHaIM`p)h^oCn.fhF7TRLg?*PWXWUa;^\m!Mh/=CHi'p`73r#L8U)Qe:PJKl+Y7,c4Bi`Uo2R).,)8"]"N2>$D[Ra*".fgZ8$\Vnl!`gYgb@A%t=K7[R_VmM2P.R$g)>:DMGF)_)X+j[ZgN"C;N)`mWjhAcW+1'4/mLhS&P8-iP$=R$4WRF-5I-o:%\Ck'qStcF&0OrWXe02DXs7C[Fi)!]Mbt%\=Qlh"D%I,)L!k-b*!@8m)\;Xp6?:Wh(?fAA0oef!-NUP3R:0Un,$(U$HgT)+4mb6g>\#qc@Eb1jHaMD<,"<[PBQ*pN(nb8%+j26#hpC$C[;q`K<\1Z81#,(4'8;@-V9>p>N-A%;X\$$oZ;oV`U1mj6$a)8q3C=./')A5P<&:lr_fH4p6\MP*:7*N?Ofl2.-2K#UbqY;9QJODc'?*XZ1o[mdp&#pknd!nto9*J9h3f0:=b4cBB:j!b2@lRI]jnGJR?_T3W]<_QD6OQKT*iU[mV]h_#FV;`RMDZep)1&;Mca;B.F.$$'9<\U-Z9K\MOP1_Md%k,bX^\&2TB%2Yn8Dh-9*-*Fqt]!IqDEWpd44NXO9at*l3Vl0_'GqO[d_ji;]tADe4sT'maB+l<28$'Qhhg\T),pR/SI+rKX3b-7Ac1m(:LE:TN_7>_aEVm3Y$0'\/]K#lAM:Ye4mGK@T;'C5L$q?#UrS*6WrV>!Pd`7bendstream endobj xref 0 17 @@ -116,22 +116,22 @@ xref 0000000122 00000 n 0000000229 00000 n 0000000341 00000 n -0000000546 00000 n -0000000661 00000 n -0000000766 00000 n -0000000971 00000 n -0000001176 00000 n -0000001381 00000 n -0000001451 00000 n -0000001743 00000 n -0000001821 00000 n -0000003544 00000 n -0000005360 00000 n -0000006786 00000 n +0000000446 00000 n +0000000651 00000 n +0000000728 00000 n +0000000933 00000 n +0000001138 00000 n +0000001343 00000 n +0000001413 00000 n +0000001705 00000 n +0000001783 00000 n +0000003855 00000 n +0000005952 00000 n +0000007459 00000 n trailer << /ID -[<51c9ef4a08589d21f13f527085c33787><51c9ef4a08589d21f13f527085c33787>] +[<095815a8cc0df89fff07917977950c37><095815a8cc0df89fff07917977950c37>] % ReportLab generated PDF document -- digest (opensource) /Info 11 0 R @@ -139,5 +139,5 @@ trailer /Size 17 >> startxref -8754 +9467 %%EOF diff --git a/v2-klasB/Les02-OpenCode/Les02-Lesopdracht.md b/v2-klasB/Les02-OpenCode/Les02-Lesopdracht.md index fd106ef..f5ca0a8 100644 --- a/v2-klasB/Les02-OpenCode/Les02-Lesopdracht.md +++ b/v2-klasB/Les02-OpenCode/Les02-Lesopdracht.md @@ -3,8 +3,8 @@ **Vak:** AI-Assisted Development **Opleiding:** NOVI Hogeschool Utrecht -**Duur:** 35 minuten (in de les) -**Inleveren:** niet — je code uit deze opdracht is je basis voor het huiswerk +**Duur:** 28 minuten (in de les) +**Inleveren:** preview URL in chat — code is basis voor het huiswerk --- @@ -19,6 +19,10 @@ Bouw één scroll-animatie sectie in een Next.js 16 project, met: Het hoeft niet groot — een werkende sectie van 1-2 schermen is genoeg. +> **Belangrijk:** dit project is **jouw repo** vanaf nu. In het huiswerk +> bouw je in deze zelfde repo verder met meer secties. Niet klaar in de les? +> Geen probleem — thuis ga je verder waar je bleef. + --- ## Vereisten (al opgezet vóór de les) @@ -46,6 +50,26 @@ Geen scroll-demo repo? Vraag in chat, of clone: `git clone /scroll-demo + cd scroll-demo + npm install + ``` +3. Koppel Vercel (eenmalig): + ```bash + vercel link # kiest of maakt Vercel project + vercel --prod # eerste productie deploy + ``` +4. Open in OpenCode Desktop: **File → Open Folder → scroll-demo** + +Klaar. Productie-URL staat live. Vanaf nu = elke push = automatische preview. + ### Stap 1 — Worktree aanmaken (3 min) **Met opencode-worktree plugin** (in main Session): @@ -120,6 +144,14 @@ git commit -m "feat: [jouw sectie]" git push origin [jouw branch] ``` +### Stap 7 — Vercel preview ophalen (2 min) + +- Open Vercel dashboard (link in Brightspace) +- Vind de deploy voor `[jouw branch]` — status "Building" → "Ready" (~30-60 sec) +- Klik → preview URL openen +- Test scroll-animatie op de live URL +- **Plak de preview URL in chat** zodat anderen 'm kunnen zien + ### Stap 7 — (Optioneel) Cleanup Klaar met deze worktree? In de main Session: @@ -156,12 +188,18 @@ Sluit ook de feature-Session in Desktop (right-click → Close). ## Klaar zijn = je hebt: -1. Een werkende **scroll-animatie sectie** in je app -2. Gebruik gemaakt van **OpenCode + worktrees** -3. De **regels uit AGENTS.md** gevolgd -4. **Smooth scroll** werkend via Lenis +1. Een **eigen fork** op GitHub + Vercel gekoppeld +2. Een werkende **scroll-animatie sectie** in jouw app +3. Gebruik gemaakt van **OpenCode + worktrees** +4. De **regels uit AGENTS.md** gevolgd +5. **Smooth scroll** werkend via Lenis +6. Minstens 1 **preview URL** live op Vercel -Deze code is je basis voor het huiswerk — daar bouw je 'm uit naar een complete landing page. +## Aansluiting op het huiswerk + +**Niet helemaal klaar in de les?** Geen stress. Je staat al goed — Vercel is gekoppeld, AGENTS.md staat klaar, één sectie werkt (of bijna). In het huiswerk maak je de eerste sectie af én voeg je 2-3 secties toe naar een complete landing page. + +**Wel klaar?** Stop hier en zet je laptop dicht. Thuis bouw je gewoon door in dezelfde repo. --- diff --git a/v2-klasB/Les02-OpenCode/Les02-Lesopdracht.pdf b/v2-klasB/Les02-OpenCode/Les02-Lesopdracht.pdf index 1317176..ba03c3b 100644 --- a/v2-klasB/Les02-OpenCode/Les02-Lesopdracht.pdf +++ b/v2-klasB/Les02-OpenCode/Les02-Lesopdracht.pdf @@ -2,7 +2,7 @@ %“Œ‹ž ReportLab Generated PDF document (opensource) 1 0 obj << -/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 6 0 R /F5 7 0 R +/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R /F5 7 0 R >> endobj 2 0 obj @@ -17,12 +17,17 @@ endobj endobj 4 0 obj << -/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font >> endobj 5 0 obj << -/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources << +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font +>> +endobj +6 0 obj +<< +/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -30,11 +35,6 @@ endobj /Type /Page >> endobj -6 0 obj -<< -/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font ->> -endobj 7 0 obj << /BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font @@ -42,7 +42,7 @@ endobj endobj 8 0 obj << -/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources << +/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -52,7 +52,7 @@ endobj endobj 9 0 obj << -/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources << +/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -62,7 +62,7 @@ endobj endobj 10 0 obj << -/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources << +/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -72,78 +72,116 @@ endobj endobj 11 0 obj << -/PageMode /UseNone /Pages 13 0 R /Type /Catalog +/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page >> endobj 12 0 obj << -/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260518122502+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260518122502+00'00') /Producer (ReportLab PDF Library - \(opensource\)) - /Subject (\(unspecified\)) /Title (Les 2 Lesopdracht) /Trapped /False +/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page >> endobj 13 0 obj << -/Count 4 /Kids [ 5 0 R 8 0 R 9 0 R 10 0 R ] /Type /Pages +/PageMode /UseNone /Pages 15 0 R /Type /Catalog >> endobj 14 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1679 +/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260518125917+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260518125917+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Les 2 Lesopdracht) /Trapped /False >> -stream -Gb!;c>BcPr&:WeDbbNq&$5,;FQeaKb2Wo2:[.^@MISm>,M#gJhFgo,77t:V%[c;VemV2%GK90^-S'*_m"VX,-\crJVX"g,N;4n?;T7P_7L@JKX*#i5n_Hc6V%M4PfQHt*VQMuM!1fA9tiqgls^eKl/cN]k;_R\!DiAo5Z*omqt5;su%cS[k'"6%aL\bkI9"kmGPR='H;'4/e.p4K?Ciq=%4Q!Ct*.**O\9KV\CsT($D*j1?5%1=hTC`3CEf5)7/d+Xj6`ELJa.`?G\)n=HUHZ'5..un3Xg3*r0RAdiUJ]UFR%6(?;\g2sRM"Sfg#)_k*qYs8ic9hfn;B8a)Y<_RJt^TE-d4!>[9"M=KEs=?4pb1;pSngG\EgcH@@N(X2OC:np20Q6pW>tYkC]_M9i-/!(%9eVh=/B;X2c`?TOh_H2/CHpDL6"\^<5Sp)*Se73$8[`pEj8a-O/"[6p]_),bTods,pqthJ=/Ka&BePRC`eV2:gBnQ`]KOO!Dnf77jOStF>"e6#UCnX:11BD=/Br)iT&T8.[Di^5;;'6L+-t^3nOr\7k\^YB=@mZCpi6\7.B_(/hSZOZQTA(oc''H(e36`g>$TMmN#Pqg4i5BZ7rbo>DUerDn@U?'N=Da@$f]1B<$HPIs9-^uL8f1-1'9s>0MRt%E*hD-&$*nps'dt8[Z&%CNPYTOp,BA;V/E[R/Ceqf)3k^dU>\__[#]li2FKCo3-Cmi.XZO7S'^,pZhO;P+),TKGJlOQg^eW)QE>`eT-)p-2QbS>BAYK$^k$toY;WO[upJ=.7PFAjkd(p`[_lB[VMIATS9W7.A1_M0iHGJ`d4cNLRN,:\/SiY%Mpo'93o-'(malFkkUT>HDq6n=X][o2o53QM2o]s\Eit4k[6]>SD7P]CSiD,Q%7q#j)2$Od?Up!r;F`rH"E#&R\e^RLo5Pqh=NS<=e3A;GOEs7VWGu$j)]).`F]Y3#s;9XKtdSAOosOX&O91(r.mC=]SPe4;Jdf\NjI/jW1e/D03b\hLodCGd-n]e.:dMguT^hB([9jY6U*!O8duegJ0p,7Ok.4ktZ4V=LeGg\3Jj:m4&&?q*$OqT2m2`B%XRgN5%:lhl8XeS"9ooN,@0D-\G5kKNG]TO:D1e,mJT"MaH^nFbg\SG,`0[rq1++7.B@h8J(G!A;an$W=ZUh-*'[`#QKDG0=[&endstream endobj 15 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1751 +/Count 6 /Kids [ 6 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R ] /Type /Pages >> -stream -Gb"/'94Q^7%)2%/i95=t6W0.:mFJC4gTB^Mn^^>cN'uEXp$a4QC&@3$l9WO8&uAmC;rbfZ-j';HHj%5=T_Ms_GFSd5]S)JI#)pH?"\]I.bLc7LdeVW@4Q)q&JU!?(&I/qojp.oo;h,sO+QSsaX.LiGV%Kd=J1mA&tA:>YR,C,=QSE&%Lpd#H3b4k\,JJXHA7u&e1?W;b9=DsGSl3TeF+8W4c@I`LI?a&C!\^ko9.E`/!CapHD)S"]fKP.LDB6BcrNDAEB^d_(%<8[81!dg)IbZIoXX"UDkn`^&O>c'T6.Pc/9?&,-l$bXL3UVb^J6bT)`AK*KbM$ma_Rh)\9rB[JK,5bQXi5B7?;Kkq:LU,VXd537OjD(`RNYNVrBe`,7+JP/7Ag9Y:]2*VBQW^9C]sQP-#2A-22c/pa&XS>tD[-ugll,*o(CU/`'n2]hUjZBN4S`\%c/!V45]@6X'P.u<`GKIq*E?=$@NHZ)F%5SR0^M\h*QoEGW=^,*Cu2rB":_7REgRM%G0K!KP2Q_t0MGc)lEBH5?Z.&oknnHSt^\;ec[YBjjAN;jJ0"Ztc`omH3Y5iRU&-;GCcfJid]QcPP'l>t9hniA;=.].1-TWDH5-DKl!T*%p@6=-8#O)D&BsFcnb8UjH3\f%"`#@ntd?glE(G(m#DrM<,#_2?IoLFe8n'E.p1&&ItbF^"toE/PEikNO0_>D7I1tdhO)U]bQ[u=u=>(&!jU8TP$u3'7b"&YYdiPX!V+^h,%6;'TjS.R(!t9nYk^r,#$*@OPd+Z\#US9FA18YOou%NWT1#&1,mU+l#(TCJpV4TM0Y[C,JqKeNeZnI,Po/5UAR5n7*jU7!k,*-4>`c0JL5J$6Q%I=*G/'!e/[AcXlj_5:Og'%g,Y\[22=T^=GXC,>^NFuQrP-sYL"f\XBtn19g_>B)k'>\O?^C9_=H&MhgM;2!@&3LI67+S]'&SI"oY-&R!D`t1eY$\)kA;hOi)o(H2o#R:9ZBG[P5,PI$^\S&k`_gHD=IuLUiaYl>>?BJh5)ee*+q3XfU:5_i:hgi/R-jXBhC*P>csgGu9MpeR\GG9`U?a?r"=u=2VUA';ukX?LROnG8\heHLA5cb.JDd&KC/(1K*doJXPIuC_^FQB-;K<;S1_6GPgAeiXbhMX-$]"LrCX[5d-8tI__0Op:K(BLp>aIM~>endstream endobj 16 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1729 +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2059 >> stream -Gb"/'968iG&AIa;m,V%lD'6IsK"c>s1i_Qm6-m\1:rUjHX,"O/+ChG*;Toh/U\H)=(I/%kN"r+hRqbKk8B60HRAAC1Kc[eY3@K%0Ob_(rWiJL,\"P1a4uD+>kBI`u6ptM]rp&D;b=`^jnh17Tol@/6#f$0jYF13LZ6Z<%?`G^\1GbpL9lJhP$G;&!^Q6[LUGq'p;B/7NouKfR*5`mj!'g(T'cjee[aV>FT`f_C=X[*#q+)BUZSJ9a6*7CK&kcBr^k]bV*B'`7^UCQX#_Fii!=bY>#*=nDCCS3q8Z"P=8e(+==,FiNs*+'OU_U;,)_:&MD+*k<';82d/`u'pJ_08Up&;7DL-7WRMS!qV0mu_BQssP&TWK#%!$C9Jbg1Gu9P!iXib?_>6@PJO,BlK>W*L0Pk<1,P?qm[upr;oV0,+n)Sf!%q?5t)D$LNdC3R3gEd-Y#jmono'hGFH"AZE*n\_"2[h*PGab55Vn@@j!?/YP&k.;p,'R-O>6re=K5qo/G;8,WMpgrMEGf*67%Vg&j>kY""t#o0T8lcW>]<9:lZV7P_\e?PP;jc:t>@!:`=%2Br7X\3neU>eRK#0oQ<cjDT1g63/pMR)+V%A&dciR(kFCV]Pg$<$oB;JF7TE1>HVdr6j6_2i7\s72(^*+(DjY@$WAl@;qPK2j5b>UlAQiZ%LujYR@fQr,;?U8K$qFI2*1Br_^AAf+rf\K10p:nQZ-A^l\/\)10gB0_io^_3&j*RKfQ3;nm9Ab@S!,l'Hb\2M^2Kfc0ojo'.Q-b+1tUmL01N=gmW-3X:2\p$d&niKeEZ`Ca[-JZl67Ga_o#:qtSP`D(lrNhi2ZfRF,C>H0Q#0ra.":cGaOUoU1b3V:4>\ZRAR&V6(k#.;.L*<%L\Q"5:'erMH10Sl?4W#H)bl*/AF"a5^1-4.C0putLlna"]aV"0^3:b/q3[]G/T/1cOl(rrM5\U^2+?iucSe@\WqAdhl_6XT*qla[2qd-A39D!q3*16_1A#F2E%F\:;m[N5mta%GC>RP.<$0nu\.uU?GW*EP$u72tt8AK/3*kBl1J8VOJ4"Ja5rkmdG:@lfmG6XWm!$mQqTEn2jA6l8pBgkBJDr>PJq6=3\%soAVrZSn"AT6Lc.ikPob0HPX+t8X9C&^tTee-LI/rBaKjG:"&IIq[:I,ldnRdmXKt1au[%7jF99]oQngJUe6sq2LknGJ#`<@$3~>endstream +Gb!;d96\iE']/^gg^WEL86&<3ZXF3;V<;qm+e`_XE?-?FCJ2Zj)MVT`=10>8Bp+2Z0S_i^b*MJmIeRn4R.sCVCQnn5)8bn]"j144!GW8eL6qCIBRDZCF.$Z&5V1g"!X9pSe/.1E6\+202dcXiht2`nfjO]ApjV-'_fMD>4ifWW?5OB4kF.u\"7`ANqX1-M(3(3ZeM_DP2cr<%f]OdV!oc,hTpbQa$H!mkECrm3Cgp;_F'tA%n*7g8"h^1#?:6Ru'DPhp4IVNh@a'IgMkcK1Ll7FWiKQRr6tRkaj`;9q_=30,eWFc+T6N8c>*4pf79>1n6Ym>o(7"*"X/nMSpZ.661nH-bJFR4ssiSqDtYW\loZj@$k!l@W44m=6-oe"at?&/p&T[Y0_j%sm,^#p$OUB,%BPQpe5X\@W/N6^J%BU%aCAh2,BPspFaAgDV31k)%bbX.GXkBD>e*\i)lnQXmRd-T3'oH!&*?>/6h*OD#C[`\TUmn/s7*(kY53bB&fciDg3fk'H\DMI1-eD[EV_89YU_fUbQS"+d?ZbSpA:"'J279gRFVik-9TE5I(!JRp[E^0[qPmo/:9]l(GaZ2PkerKJob]0+bl_8a1^m!ENr34X=Pfk=4.X4q4#E$TCrXeZ)I/\6`A.ta,T#*d0ipI#18<1seL9H>74^R7Y24KkCVSRJ%hF%\C&N$Ne=bSshIMHI[oL;B*i4YWLrmcnR?)n%.Fd8SIA^aZ4:LiOe3YO(E+=B?`$'3-3Qp0o[^)GR-feOLi\AHIB>WJA$o;B:S=884#b_J"jo9'IuojRr3%LFdXcZcp>O;A]W_0kENm,?)QSm8`V#O"]8$_(iK8TZF7i&8NjK*NiHR.1f$o=.olr;Mg=+4n`bSa,/"Aj0\k>,SeJ&8daQ*j_2/kBM&FTs>+VfP]%91*eR+/C=(m/T<)>>)A<@'2WZjes)liGD\e_!et-R/7L>_3uF&Bia=eD`*m^9^/sDBq4puePq^IZChch8X,;pqYn"fA."E?nN7NsNLk_p1d94Shg[/hAWnXrQfe#_VVui'"SF7KhcJCo%'1a);=iiH4.nk&UhQ!o^H,%$9FCuIU*,EgnG3u6s@hRlro4J/PruqmH^=_V/?_nMEB?oFX'Qo28[3:-QqOS0#S*mJPlo#&3[W+!E`^Ye83L*9uLB8#)3mD=3'P%c6H^@2iWK+pnnCeui3;AS0D=32BX@?H=Jg*<+endstream endobj 17 0 obj << +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2366 +>> +stream +Gb"/(>BAOW(4Ol=3&&ge`UoOq8TkVfn(6?l2F3^u;kl;>cZ=+8QJgGIsY,J!?\C)^f12fVpUQ4IrTd?!*M%lhtIo-@EE"NJQdCAE#K3'.,-f5S9?@:ip%8-[m5gRX+XUJff.1$"<`+!@&a^0QaMWT=(tB<=K=AY>kakPVl_0m1m:VgXk2$)^??F]K-`L;<#1fq.$uj>0LeO]//34@c'_51>9Kh#q%Ct(,E#M6:&(VVbB&:7&d2N7DA-/LZKm+AHaU7_R[Mh-L_X:a$@@:p"I4BQ.87`9@U"C)f9`FH4B7VRl>;cFkm(c40slN;fSh540ani9pG:^NsG7eP#:A)e$*.D`9i^LNm>A\7OqU$S^?+[%/g++%m/Ur"0\MCI\re&A&9/OUr]UE1$A:R^0J(j7],J2PJKuk\ptl*)!s;_rrgnA^>HZMA+'r!@rCVF/^m[;YNs+MBgacNYlt\B"ek'8"1S-?)dfG.OaATeJiaj%R@K;Ki5oZ)c^IL8l=t;1hD`B.6g-tp99YU8T-MeJ.JYTug%]_^L;1^$OnhBP4N35$nc+O(LnM%[Vbo.hb3Lo5c])U1An4n+gWHqA$?Znh]WBB7/apm/][@[4s238ojGAFFceg$k4bZ:!L(DAOq,6'(OV?j5nEqh6]fVs"la\gGTBH>H.W#X@[25'OIY/I5CI6+?:f(B?Xh^),(g_2r?m..>N:k2\&3=)kPgC9[4mT5QBqFD[UjlV3L;S5KeRin3Wt\d*i#CGb8BudlUrIY!^'A^p7h2k/ns`6@V$a6pf%$o\TKCrD[1a-1HB9e,/Oa2l7_?()=S`"?sn!/tgq8^H`NLP]#6Wn60^6?-5&qnno^RQas]=4\7V9K*\Gf^r!:VqMVXoqO(LZmmq5uI8blqQI6?1j*>_X`%E>'*PQHSc>$&K.@(J4C62n'ClondQ,Aqdildb,Al;\PT0,=^7'ZCo'0l?n(B_s[AH83(7ZhHsW&u!i5EED"c_E5Xj0e@,p]g$lVtk&)2Wsqb;E?(ErPSo/4B(E`T9;>qHU"B'Gpn"u:"QO2G3S/HC?m?pM9S+?4@:;4MgSYeN9"&'+X&f20hoafug0'S41VoVnD%r'ne`jFM=hmX1R:.pFX;eHYL*BOW.&M8ndN2CO=;[LD2=OYHf&3+2EanGIPn=-HJr8V>&cJFlP^R-fHIe2ae#!"#OAL**$$BfA)5H0+="Lk?q;]Tg=Q$nkl&B38,gf=b87K;J5tuR[qaE5$L`;-Ti3M99gqhXp\]Vk?(`Ijif2(P$2X9EOQU[2bIMfYq^WTj8@35*fc%h^*[E=F*@_G\8oKh%\QV)-3:XBU8[E/NJWgmp`]U`Oe5ufS']VXk:,59:DC5o?DL>c(0[T0_J33$Vp!?62uFciRCd`R/)3t8(hT2[%lmSHW)h>?Ks"!Klj0Wr%BVhnt76(eZX`3Kc6QDf]JiJDCXtR3M6N<2`ou#23L^p=6B="rMeOo,AXrUIictP/pso`=/s@4b)dPU:r@#Yn'CT`?E40#,kh`nPLoelrth&+KjYlV(lLhu].\L7jtul+eVo0#25I0kbR8Ys^faIA@.lfeKE,N~>endstream +endobj +18 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 550 +>> +stream +GasIda`c,u&A7u11Gg=mUdi@P;!^\*'6YN?Z?fOSHrQ(A5dV/f\4BFh#"Z8DU(KlErbQ\pf4Eo"2%IF>j5'Y/%F&"^%K_]=/^msN3CG7E#p%ADm0e6hH=u0Z*\<#P3o7O]klqUiL\U>,ol4CS$%]i8Y/rVU-f/S'Qmdh0"2%/\]bGM9/WGcLZm."(JLPr4>Mls+lSk;,C?W8tR29#P$neL`EiNS0P*7CMS";sRtdrS>('aUj@KiYta#*Z8Et3kmLMI4$b0W-bhZ,X^:he$mJen6W+/X%]Re`hi/RBRG2q0n\5/E9kUeC"0Xa^W]NqIKMBAg4]~>endstream +endobj +19 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1956 +>> +stream +Gb"/(968iG&AIa;m.=a7XW[ZI6-tsYSL1=?:ZB;b/o_@X$&c/]66U'Tm\.D/$W;B)nOo:T12/=S,SC7RKb*&n+9iP;XSIlsJDDYJ?85h-VC0.^&MW$rQW*(*6b)3kn:8$+\cE$K\Fp*fbUL>k6j>b]TVtYN00gpTPR8+D*9[4CLJl%3F,'ed-2!N'U6mF]i8&EaunR6Bb$VNhL=74hp"hr;.<3'+6l`o8I\1[qlmE9[8U(>qrA4aMBeYe;70(j3FmYn^:@Vh8ZUl^["g`u2thj-:X-^4rqC.!@&$UL.5q7+E0qF"*9L)3NYQ'XDb=6qGpt,'?-QWX,\l[r7A7;a.Rom.5n/tMbqk,&6g-`\/5\NA`6&orj$CXsfgJr$+g>"3o/9DI].Q=Ch/8Ica=S-d(':=Np?ui2'>bkAr1L1t`gV..Ic$&W#ao[D'4]u5^46,g_%9X;F99,3C6S)E+nN^6P:lI5Aj>(T?-9O49Q(&PW,>J2FT%7(NdO-@+hNgTPm+n_i7*aYLMbj*JDg+QNaB-I%Z+obOL3D_rZkC'bE2X7W6.X5Q]4jU>7-0bP;,_d(4a'p#SM-V5$*%T-5uc_A#O?&>pGl9bn[R6\p/1U.[Yu=`lNSF\4UtYF/:h"C\V-qW442+j"3q;e$0U2'aTtU1YDtF>cWI:,?Fl4:LClWde_%moIm8=rjdWG7J$D?k6lG\3*Dg)2UYG/rL!R:h,J1>,9]PpF[+/H8r(?9_L>)dCDa)Ygs\^Qf/??4@.`o$(PZL+,A76gqPO*cc:^3Su@"CCEDJ3Z>T3dcek?.K/S:+Uo!!qp"3=^qQZrOrhk/,H9*4C(CeWtX'#p`nH86<9ZI<2If4)k7^ZikQDkVe;3p9a'R:"q+?#t0t$$rI#q8=;E'0qioCJ.=2:"_\?%+2q-1`VF%*49mOcM.a;0@>lI4[Jk13.GhBUM)[$gk-b#:SSkK8?,>hRJW.gL[ACYFiC$br`e+9!oPA0:BiJ#e^Agc^CMH_bGR9RW9I;^YC!+i,),SE"dBZ`./iT&$C]6)W;sRepqja<*<%KghGcR$40qe(IE]3lYH:;_Oc7fi&O)c*d/*l^i..5#2Dh.s_!FDQ"aX<$p(ebL2X*>n4!aT5Sp$\Vs1O34j<+/"FI)q+BJTgIadbOk#&65:-*4-@#@-$2t*G$ITiAL;d;\[!CMV8=o"3kIE,=pWrI2]AoJ#c;tk'5ZTkXZ"'MOWjd$DksU="2LIQnCq`"aqWZsc#V3!-Ma@?_D+#o;\."9Dq0X9'QotpfdX0u9[IT.Ra?9#jQ]]R$;%o@6ERAKN*,LJ*Q&+B7^^NO_h,?Ll<\7)pdg?Nl"\@4^gAa-b6?J\SbK(CXbhmm+~>endstream +endobj +20 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 470 +>> +stream +Gas2E:QO3`&B4,6'^o)6;?r9CIlVDsY.>6\GH9((Q\ZJL(E>?T;Me1VP5ktZC2@%=SG/L3o,&Z(ma(\5pmbjl^gN"H!EQ"X:[g5;?5&Jk>,=Na'58dZ=aKki/<^'NaCI&93+9p3"m),Ab8b'B=j%S?a5S^[O>Xm9"`g](1H5/pN0LTS,&L$IYQ!b3*UI?JNp`Xd@7OGtK9&`tWMq)Xg1o[fN5#>eon[PZd^SgJ?*?!M)F%SOQq;"3s7kimD6(,:-,0/F?e=%l&)Bb:^QICoC0g=oFC\BN?1B;VKg\&hD:oV9nl)fp632LcHB)LKnS1uo:(%fsLsMfF*non3&UcA(*6Unq-TrmWPLG5.!Hg(XIZPpfTCcg:em:ALoU%CJB#gfI\%I:_:*k2@bn?eLFn((3A)$cajo8`bF+PlL^++m5^@sS'^+Pendstream +endobj +21 0 obj +<< /Filter [ /ASCII85Decode /FlateDecode ] /Length 1412 >> stream -Gat=*gN)%,&:N/3lsqG!Ei+Qtn-2'&DX0:(A8F2*m2*H$'L^=Q!=#@$\6n+s8DS)RmaRCGP/s3*!Dha_YZR0$qRRK8C="JbF)0cKs0&=0OWMLgFC#VuZ1$f)oW@\=l>8ik`(f`9&]"\%K?VDW4Hu=mp^KW5[Gme-%;E4]&HJUJie&j-;.A*CkFX;0W!i#u;I;lp/n0>qE)PP[krcd#q"8k"Ke;?-SN@7CN+PMGD$e)](8m'CsAI7=8&E.dPch\E>!#"Jkln^!6VhY"q)r=/cSrBq8#(r:8W3)I9M:Xg7[V;gA(B7GSg=bG[oo>Z=o@7Hbf4n$spT5U,X+CmhI]oiIk\XiGr"%j>GnW0n)q>n;dG?R$E7scKI#[/]#/1=F?fPAFSH)C_L(A5#L<=7\IF9Yh/8"4uf8mGEMr%^j'pQ:DKi7E>BLI8\V8s1S(j]b]2uE0)h;l46.7A8kp8'8TQtFXa/AVD>$>6fSc=1[*)$]g_)sQ<)Kmo(Um9C;@G;-(`AU-#N8>&:q'dUO1KJP\td#3YaT"+s0\CNg&[$]>u^D\06Q*@Z$je2-ea2s'\_Or=:gj];eG`T9'.Me0N)98.815:RJ%)9DofBY?PShkW?l1RaRIA,Db][FkaosrkGh3"$/l:89!Id-nCO'EP$O>DTd9DN*eJ[IQ\;X>S*>?A>2?Dm;PbJ'^d8je>qa^+lTlh2r7OiW1:dUdT%]-'7Xn'o29ZAVYf&V"'J]XWc;j]#?">/?H_Q*1cn[5-=+Y7Y9^k7;O0*Ont=I5+U8mPZQD?4K(?K:S=!Rpc4e`iuI&K(ER,O`Gq~>endstream +Gat=*gN)%,&:N/3lsqG!Ei+Qtn-2'&DX0:(A8F2*m2*H$'L^=Q!=#@$\6n+s8DS)RmaRCGP/s3*!Dha_YZR0$qRRK8C="JbF)0cKs0&=0OWMLgFC#VuZ1$f)oW@\=l>8ik`(f`9&]"\%K?VDW4Hu=mp^KW5[Gme-%;E4]&HJUJie&j-;.A*CkFX;0W!i#u;I;lp/n0>qE)PP[krcd#q"8k"Ke;?-SN@7CN+PDAC$e)](8m'CsAI7=8&E.dPch\E>!#"Jkln^!6VhY"q)r=/cSrBq8#(r:8W3)I9M:Xg7[V;gA(B7GSg=bG[oo>Z=o@7Hbf4n$spT5U,X+CmhI]oiIk\XiGr"%j>GnW0n)q>n;dG?R$E7scKI#[/]#/1=F?fPAFSH)C_L(A5#L<=7\IF9Yh/8"4uf8mGEMr%^j'pQ:DKi7E>BLI8\V8s1S(j]b]2uE0)h;l46.7A8kp8'8TQtFXa/AVD>$>6fSc=1[*)$]g_)sQ<)Kmo(Um9C;@G;-(`AU-#N8>&:q'dUO1KJP\td#3YaT"+s0\CNg&[$]>u^D\06Q*@Z$je2-ea2s'\_Or=:gj];eG`T9'.Me0N)98.815:RJ%)9DofBY?PShkW?l1RaRIA,Db][FkaosrkGh3"$/l:89!Id-nCO'EP$O>DTd9DN*eJ[IQ\;X>S*>?A>2?Dm;PbJ'^d8je>qa^+lTlh2r7OiW1:dUdT%]-'7Y4C#39ZAVYf&V"'J]XWc;j]#?">/?H_Q*1cn[5-=+Y7Y9^k7;O0*Ont=I5+U8mPZQD?4K(?K:S=!Rpc4e`iuI&K(ER2MAmo~>endstream endobj xref -0 18 +0 22 0000000000 65535 f 0000000061 00000 n 0000000132 00000 n 0000000239 00000 n 0000000351 00000 n -0000000456 00000 n -0000000661 00000 n +0000000428 00000 n +0000000533 00000 n 0000000738 00000 n 0000000821 00000 n 0000001026 00000 n 0000001231 00000 n 0000001437 00000 n -0000001507 00000 n -0000001802 00000 n -0000001881 00000 n -0000003652 00000 n -0000005495 00000 n -0000007316 00000 n +0000001643 00000 n +0000001849 00000 n +0000001919 00000 n +0000002214 00000 n +0000002307 00000 n +0000004458 00000 n +0000006916 00000 n +0000007557 00000 n +0000009605 00000 n +0000010166 00000 n trailer << /ID -[] +[<9ede7c3cd6e0a19e0d40b08da394d3dc><9ede7c3cd6e0a19e0d40b08da394d3dc>] % ReportLab generated PDF document -- digest (opensource) -/Info 12 0 R -/Root 11 0 R -/Size 18 +/Info 14 0 R +/Root 13 0 R +/Size 22 >> startxref -8820 +11670 %%EOF diff --git a/v2-klasB/Les02-OpenCode/Les02-Lesstof.md b/v2-klasB/Les02-OpenCode/Les02-Lesstof.md index 69bd53b..684564c 100644 --- a/v2-klasB/Les02-OpenCode/Les02-Lesstof.md +++ b/v2-klasB/Les02-OpenCode/Les02-Lesstof.md @@ -16,9 +16,10 @@ 5. [`opencode.json` — config & permissies](#5-opencodejson--config--permissies) 6. [Plugins & `opencode-worktree`](#6-plugins--opencode-worktree) 7. [De scroll-animatie stack (Next.js 16)](#7-de-scroll-animatie-stack) -8. [Project setup van A tot Z](#8-project-setup-van-a-tot-z) -9. [Workflow patterns](#9-workflow-patterns) -10. [Bronnen](#10-bronnen) +8. [Vercel & Preview Deployments](#8-vercel--preview-deployments) +9. [Project setup van A tot Z](#9-project-setup-van-a-tot-z) +10. [Workflow patterns](#10-workflow-patterns) +11. [Bronnen](#11-bronnen) --- @@ -479,7 +480,88 @@ export default function RootLayout({ children }: { children: React.ReactNode }) --- -## 8. Project setup van A tot Z +## 8. Vercel & Preview Deployments + +Vercel maakt **automatisch een unieke URL** voor elke branch die je naar GitHub pusht. Voor scroll-storytelling is dit goud — animaties moet je *zien* om over te oordelen. + +### Hoe het werkt + +| Branch | Type deploy | URL pattern | +|--------|-------------|-------------| +| `main` | Production | `jouw-app.vercel.app` | +| `feature-hero` | Preview | `jouw-app-git-feature-hero-jij.vercel.app` | +| `feature-gallery` | Preview | `jouw-app-git-feature-gallery-jij.vercel.app` | +| Pull request | Preview met PR-comment | Unieke URL per PR | + +Iedere nieuwe push naar dezelfde branch overschrijft de bestaande preview deploy. + +### Setup (eenmalig) + +```bash +# 1. Vercel CLI installeren +npm i -g vercel + +# 2. Inloggen +vercel login + +# 3. Koppel project (in repo root) +vercel link + +# 4. Eerste productie deploy +vercel --prod +``` + +Of via dashboard: vercel.com → New Project → Import van GitHub. + +Vercel installeert een eigen GitHub App. Vanaf nu detecteert het automatisch elke push. + +### Combinatie met worktrees + +Je worktree-workflow + Vercel preview deployments past perfect samen: + +``` +1 worktree = 1 branch = 1 preview URL +``` + +Drie features parallel ontwikkelen? +- `feature-hero` worktree → eigen preview URL +- `feature-gallery` worktree → eigen preview URL +- `feature-footer` worktree → eigen preview URL + +Drie verschillende stakeholders kunnen tegelijk reviewen. + +### Pull request flow + +``` +1. Push feature-branch +2. Vercel deploy (preview URL) +3. Open PR op GitHub +4. Vercel bot plakt preview URL in PR comment +5. Reviewer klikt URL, ziet live versie, geeft feedback +6. Merge naar main → automatische productie deploy +``` + +Voor je eindopdracht: gebruik deze flow. Het scheelt enorm in feedback-rondes. + +### Environment variables + +Heeft je app secrets nodig (API keys etc.)? +- Vercel dashboard → Project → Settings → Environment Variables +- Voor scroll-animatie sites meestal niet nodig +- Maar handig om te weten voor latere lessen + +### Wat NIET commiten + +```gitignore +.env*.local +.vercel +``` + +`.vercel` folder bevat link-info — staat al standaard in `.gitignore` van Next.js starter. + +--- + +## 9. Project setup van A tot Z ### Stap 1 — Next.js 16 project aanmaken @@ -545,7 +627,7 @@ Plugin maakt worktree → open nieuwe Sessions tab op die folder → agent bouwt --- -## 9. Workflow patterns +## 10. Workflow patterns ### Pattern 1 — Plan eerst, dan Build @@ -573,7 +655,7 @@ Eén Session = orchestrator (in main). Worktrees met aparte Sessions = workers. --- -## 10. Bronnen +## 11. Bronnen ### OpenCode officieel - Hoofdsite: https://opencode.ai @@ -611,6 +693,11 @@ Eén Session = orchestrator (in main). Worktrees met aparte Sessions = workers. - React README: https://github.com/darkroomengineering/lenis/blob/main/packages/react/README.md - Homepage: https://lenis.darkroom.engineering/ +### Vercel +- Preview Deployments: https://vercel.com/docs/deployments/preview-deployments +- Git Integration: https://vercel.com/docs/git +- Vercel CLI docs: https://vercel.com/docs/cli + ### Combinaties / patterns - GSAP forum: Next 15/16 best practices: https://gsap.com/community/forums/topic/43831-what-are-the-best-practices-for-using-gsap-with-next-15-clientserver-components/ - Tutorial: Next.js + Lenis + GSAP: https://devdreaming.com/blogs/nextjs-smooth-scrolling-with-lenis-gsap diff --git a/v2-klasB/Les02-OpenCode/Les02-Lesstof.pdf b/v2-klasB/Les02-OpenCode/Les02-Lesstof.pdf index 5808844..fe52eec 100644 --- a/v2-klasB/Les02-OpenCode/Les02-Lesstof.pdf +++ b/v2-klasB/Les02-OpenCode/Les02-Lesstof.pdf @@ -2,7 +2,7 @@ %“Œ‹ž ReportLab Generated PDF document (opensource) 1 0 obj << -/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 8 0 R +/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 8 0 R /F5 14 0 R >> endobj 2 0 obj @@ -17,7 +17,7 @@ endobj endobj 4 0 obj << -/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << +/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -32,7 +32,7 @@ endobj endobj 6 0 obj << -/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << +/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -42,7 +42,7 @@ endobj endobj 7 0 obj << -/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << +/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -57,7 +57,7 @@ endobj endobj 9 0 obj << -/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << +/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -67,7 +67,7 @@ endobj endobj 10 0 obj << -/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << +/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -77,7 +77,7 @@ endobj endobj 11 0 obj << -/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << +/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -87,7 +87,7 @@ endobj endobj 12 0 obj << -/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << +/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -97,7 +97,7 @@ endobj endobj 13 0 obj << -/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << +/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -107,17 +107,12 @@ endobj endobj 14 0 obj << -/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << -/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] ->> /Rotate 0 /Trans << - ->> - /Type /Page +/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F5 /Subtype /Type1 /Type /Font >> endobj 15 0 obj << -/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << +/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -127,7 +122,7 @@ endobj endobj 16 0 obj << -/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources << +/Contents 33 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] >> /Rotate 0 /Trans << @@ -137,141 +132,209 @@ endobj endobj 17 0 obj << -/PageMode /UseNone /Pages 19 0 R /Type /Catalog +/Contents 34 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page >> endobj 18 0 obj << -/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260518122502+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260518122502+00'00') /Producer (ReportLab PDF Library - \(opensource\)) - /Subject (\(unspecified\)) /Title (Les 2 Lesstof) /Trapped /False +/Contents 35 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page >> endobj 19 0 obj << -/Count 11 /Kids [ 4 0 R 6 0 R 7 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R - 16 0 R ] /Type /Pages +/Contents 36 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page >> endobj 20 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1817 +/Contents 37 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 23 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page >> -stream -Gatm<>B?8n'Ro4HSm7,J,K\2XIUt+4EWF.Nrq\bZ$okC1f'>;J,GB2S`;.+l_LUX$f";:%9[u\N!*dehuZ5=OjYrK'l[0_TB81]A]G]AEkBVJ8gRT9&n7d)Ld[cD,gc.LKgFTtaCt?do/e5SCA:82:aES/juCZ7=^7gBMR:lPD&5j94\6V%(HUAPb4s\(eBOeB`2%Hu&95quoj5)OV.p5j&8JnhE$;h:HL1nF7GIHi:B(4;%PC'nm:bT8EDhH?BX=SMdOO,WDW(C"aLl.H)fK+.&WILD]gEYIYY1(V5#4$6K_9=)]2@BR#"p4B%Vpr=XhYcbY/KVBW[bRE(>Fm-Y4:_'Cpe!LhM(W=]uAERAK9lV##!cpY.I[5`&fE9P6V/m(3s?[b.[CGgL$=-4#pN@AdgJ.K$42B5N]uF0[AJU!u7&p>'6h1.+i.PqDs)<-Qi#B&sEDEiFPGtr2N<%C=?1,[e&@Q$t?pV6PB3D[$CI(BC1;AD]NWD-[jgF^"mF6$U2Bk[q:.=jZu`6+XlH$-q@XjiM#JC/Vd2m]8HUlU>1UO56sG-"J%r9h-Y\la5:ds3ILood&7BlM@/[D85-l1Za]^g16m/L!2V$qnUV^TsM2Y!i6'):lR8^U:)F2pY;@`kuJL)urk'L[_&'7g7o*kZ']P*Ca;N!O2:IQIgHSe[1:!47)QEQeZT7a&hnc5Z@h.mOKs$]lb^B6C3Tj4N&,-6>9*fB*YBQBM1/^e=P+Jrl$QGFp[aWcR=(IMd(sNj)b9grNMgR2TrP#ZbZGSrgWN;~>endstream endobj 21 0 obj << +/PageMode /UseNone /Pages 23 0 R /Type /Catalog +>> +endobj +22 0 obj +<< +/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260518125917+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260518125917+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Les 2 Lesstof) /Trapped /False +>> +endobj +23 0 obj +<< +/Count 14 /Kids [ 4 0 R 6 0 R 7 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 15 0 R 16 0 R + 17 0 R 18 0 R 19 0 R 20 0 R ] /Type /Pages +>> +endobj +24 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1862 +>> +stream +Gatmg`7*5S^j9TVig-b68D_96"X@Fi#X"!8qs?u)RsBH"e=0,T_aQsQQ0q4^o;*)4>>%N"G/9Og1^KJtmI!Oi65Dk&NNoFJC7VbV'!M$ho,;9/*SOYkS[680(e0837_\\=G;097)\5`40Vu9Ell7fM<&p^m2\(V6$T';!e]5s&659r/LRgqp<9pX!j06"N(41C`G1L?im!iMh`W?c,VH=)I-TkcKQHH5EgnucXP;h\m\luqgi_?]M&f;"HKn9[<0gnXOW0b;"idR81o?rXV>i^(A"tGpH'ha@RVBZ^nk(5kAT,&b^Y;X/5_HSU!CBI"ggFPFH9fe3U<'ue6)QrI:WQh2lKC2(H4'4IjJX-<P6j976L2>1!5H,0V^L,suOUFk_sue@I^K6L=s9_`ZE43&T1*Ta^V`!6Q'0h>r>Z]qG:o`PpjcL)O]o`o.?/Ni^?#UOU2H=Y0-EB=hR>J.MJpe\d8Hl:I6Lf=eL5:6H)U=S6]n&_uWNXTKHSS@00UkC!dkACiNbm!oL[`tX`#;Qj*]t&)*]-UYJ((VS:J.h?7`SlZ4c:'5b`.Qq0Ui7<.dXL'-cT,c^$aVOt(VNaoPugW3c[`6;XaZE5DiUWfQ:SQJl==5@?_=Q:<\cGrsu%Gs7VI]_'G0t@/Hi]4SZ@0gLlR]!W^]!..Rgs0<,]?YPcl6#dH-$+*&i8l.9CgNNRQ>6AV/2ekgM_lBjbc.91TDJFIZa^ahi]2TK^dE'^C60t[X;Ok[_k8?A5T;jce^juT-2aR7Un>?$Vao`t))iNbF#72]a+cI)<5)M:bu56Mhd:*>/r8l8/clIlWSfs/8O+7@6trOp/-J4Xu_PrO4m7DpHk&o"j8g`Z/^C![3[7j=oP*o3d;u];V.6BAMOgFZm$;XJ\W6K/X0Gjt"TR*m!Kr[.#YI8\A^Am[TcAJEL\mgt[N%c-h,X,T+Ng8Mj?`9l+o(,Ipc=9nBrU>#S>7Lf4tIj2ruf,T+i+$SJG!O_4L5u#adP>q6m/L!2V$Kg7YW_R$Q5)tK-1VX+C2P3['sdn.uaGHFgOp>(#"E:T=1i)2eT'1`l!mt*.j=@L[h,(8-L3kkZ0cq*JRn;!P%i6QWJM)jg9u147)TFTA20i.l[sTVu[IkIOFkYk6VV(:[q.dhktdWendstream +endobj +25 0 obj +<< /Filter [ /ASCII85Decode /FlateDecode ] /Length 1814 >> stream Gb!;d>>s99'Roe[3;^(i9nXhMj?>.'D932bg*,k/m-l2=`JDT5C-,k5^OD71fJ-*nd+Mh387Gf3Nm@ImSXV)'peRC:X8r$:$A\(!:^5B/d#`o74bIrWn!cgLb`XX]&o^'+)3?[oIo,:!JLDjbb#/T%:C'`_#WX3$V]6Q!9+m[Y.:N50UHk!p:O!.udjXAKcMW42N8`2.M;gMnEW/ElmaOm$][75>Cd0ji,][H!Y^r1f#\bY6AfElHT9CDe'ASHMnb*%&Ka2$+`_^n2,40)/VEL4Vh[fNjN:G.k+e_KOL*KRi9]l"XGX=Ee:/jHBicAf:^$L9Xch%Qd!uWT@SSO;G?+R`6pO`%kS4Q/#($bnsQSH.Q@i.JM`fr8DumSVFa2i>ZqKG)Z9NEQS?U-o^&rDg%'Lp4^j/jr]Wdq/IWD-#?Y3a@J]tV!T2uk=]NLm7`*e^jHptN!=87pG?iL#OU\,\aeEFDqrl]h:3r2=KMWR%9U7a=*@SU''TjnSLKb`-R\?g`'3UbrrtZcqK-i`<@u@N^^"!JZ)D)CD$_!p;psoWn>Wd123[-(jMk6.64[f;IOe']:rjR1l:7$M*1?Z7ZqH[Di.JGJQ?XtJ[h3;c&6_DO)JY8sXd+EXX[OC&JR=7O+=<;q)MG9OGZ@dX2#?UE(+[>M!BC5"UO7El;LOph3#a:-2n(m*oI@(/U3?,g9'GWOfB;mr`U91;8X&4A-.2T6MS@ZF[4AHrZP>Pe[:L)[/8Ebg>'/+h+Eq'p9/As5[*9@rAaWQQ6bo>?ibkkG>25*]>(IXV$,8$1,3KsiB:LS=+OI'aEG2Z5MTn"A.qB^'VC$SrG?#A_%HBNVr[G0>[,DZ\7(GSpEBFQG`N%!Za%2Jl\T_5Y:rOXA\8uWZndQ>Fo*\G,m+>U")hRb2$Q_/dSf2Yof1DM>AW:b<\+!-n%XDFGG=5@,cARIB*XMbbo?iq,&:2\UstKmh/[S]?7HWlo_c#?!W-m:/nb?;;YS9Rl*Af>bga\!;8r#nboQ9PbGC=;kG$>qui$_j.N`Kg_S+J[I+ZTQ9Y/6,[0t>iXCEh2G*0rUrK%IK@Q1@WHa*=1>]]?-:o/-oC;+>%rXehGji%_k9M2`ZY6[1?21%oBtm0N(n$5!(3=iBAASiAgG[ZcB01,Lim=j>>'=U@-gQIs!Tq;``GkXnooj_Dq;Ep%P,H;\Mj_10Wcq\hkH"a6E2j-09P?nG>$0Qrqr`5E$?-BY>Hu"ALe[:n(>I-KKgS+q72B1WDld/Rn1D0OT~>endstream endobj -22 0 obj +26 0 obj << /Filter [ /ASCII85Decode /FlateDecode ] /Length 1690 >> stream GauHK>BcPr&:WeDbfcTAp=+sF,UAt)HeE;ce'lL[:%oea(s?pZ+4isY+qOck4>)K&m%:*q=+jJF#LBntV$(3Vi56qJPDR!P).##AR"afIKd[a0kcoQuA9n=5K,=o$-+Q@aOSQTNr>_MjBLe(Kp]*8;[hZuKn[@5@.=/,oASmU[Pce%UBo3(1QUGe.^+JoS>Y\.7;6J@2p9m?A-.US6oX$)Ok+QiT=fj@`]>WK!EG[SiVbrUCD8+S_A8.3dppatgl8>$EY:GJEsP2-sLS`3a(KX7Z6S&A#roJ?H-PSf[8[gfr\.GX9hY\O&9q,cV.col%,r'0!FgF1U]0C\W:T\j6Ykd;jbSE!rr8[&IEZ0T@iMrUV0C\&t@C+e'j2]ZQ;UcCfDbcKU0@F5ac"2`Y`WDSg<$V?)::N,P_TS(q8ijds]jgo`HApK\kK?@Si03oL`h)]UGG\p+Ys5>_Fq(c*6P0r4RhreullR+iEIW>Ab"VaO:Csa>E\E_qS,hqE8^6T!-pt0@WJ"P>`Xe56sWRn1!)tCCn.bb0d1KGSJ=daS?4QQ$NFSM4#mAkDm@tU@iX3o^?b'M[T>^_B5PeduTdk,IHmSQ*LA,G)U<.dO-a24qFKFof?-0*:TL,_m7]15:A/<;9"jcO"l'FOT-dap_p2SB#s^m,K"DfDbE%dlZb_I=$"q&UQ[A(8Irg*5N&m8U#QMX_t^BA*^&,Ah\FE!)^R6g">UZK45VYl'969+g/1gieC#JsO~>endstream endobj -23 0 obj +27 0 obj << /Filter [ /ASCII85Decode /FlateDecode ] /Length 1726 >> stream Gatm;bBDVu&Dcq.As6^`[:'%<5qneXDA"<[3I+b%]#Y)M+Ghuu$45C&^V0g>+\J&poX'oX'&[0[%WLSS"r';G?df)7C'#3T(bcG`3A<`%9]!Y08an;S>k1ub5XIoWKEFe4pBU@8-m=aA-9P_0BM;AV5U]*d8ec5L(WS(]g;*W#^js@d`_$5./cG0MT-[WZ6Js[7Q]#^2b:4jdYkbVTB`lB(3[mS%Mo>VR/S(j;'(^k_Bs1%t%VJHM#t#Wk;1*T&K+RcGKgH+Q7DL.r4q;XZ^gf^1:np%R3[m?s/GXN;l=JCOR)90+(iu^3WmQTZD+,JF"$^DN$\RK4WsijZ`(R?bCF/n_BBtFmKs9(o)a7`^=.42cPq1-Ph`fKs5`iGOHdTdd'[7J#m!r'!VRR];>NpY02a(FdC?+IPH>Tnd;r*1L+I%AreZ5.QT9CgC\C@Lh5PhNqU?YKSKU&%a"kNeD8G0+#4O'P%;$G?40Qg)0*2e\%*^RmEs@QVXRMZ[dkc5&e%)aJP/+I[-(%hI)gph?S#+5JS?FVhgfIJbdopB1-@m)EVK_D!hUHbH/@0Be,L/&jaBh6qq"c-9hV1M$o"3g%-kQ^&['$AHQJoifajI;3"$!%kI;%iDYG'm&)/`-%n,j0W#TrMsJ@-X^<&fsQVQmA*F_\7)6OU0B*I#(>^0LL*e%*_@B7Nc.N1tA`/-9at[M[lP&L05m"ieAG2fl0,8S"g?(e4^/'D-Y)Pb4G4+01ToSF"ik$C_g"U-%BWi\)D/Ep^Fg8\[8f0jl_ml'2U(=V14QOGiH\5^/)H2]MGG?KF$nLo=ESB%",LAL9:ONt.9r^C/[9IqI7I(j1lU'Tlk9kdilVhY4T:bIOJc/9p@RNtZ>3AC0G1'I"_5`BImTM@e_:Pf8dAHiM38s_N?oDt=d*"fGOqq>e>(cJ'm.Mo@:"[Wq7!rk-Cq#7J#XOZiQ+I$BjLRS=QY8MR`\e@l+g-4g9DV3.kiULTgN?X=+,5e`@O*X&EJYC2$;I*J9F_,PmSREKTa0jV5oF14?4i9"?mDXB/e&W(7]tAgiDK8+%4mDmLs&c9kHiNPendstream endobj -24 0 obj +28 0 obj << /Filter [ /ASCII85Decode /FlateDecode ] /Length 2485 >> stream Gau`T>BAQ-&q8H9fGRJ_:*RLS#_JjbEi*a6>WQTYA*Gg'IXt']U.pb$8QenOm;MFp]ZQA5SE\6XQ6h,*G-T]up-o6#5Ks+].tF7)E;#"ej"Qr)'1BY&"'j,@^Q/Yeg_2:Oa/;\JJHV#/K46Q-_3ICm4CI)GO8,:HiA9KjJI`ab4HFbaBUD"+0d*'`ShGWhba#p?L>F):F50q1=3BGUI3YA&`!_B;UdHm&Iq49a=ECF$X!7m]Y33Z<"fEG,rUq;hAIDdub:HN#c5B[b]1C#,ma)f+3qC`RW-d$iIL`_)-5QmJEm7Q3o1@If5q)$'-R_8Nft'mVlYA=4%?U3,ok<=M?IiY+WKG/(k\$-UZKQ`b'[sIT=lu+Z.P>[DpU5C(o)c]N'"h:YN*7/[?qf`Ks4b*O08ZbdddnSs-CFX`+8tdG)-+:gWPYa.9T(s)O@sdCd*otpr7J6TcFm67$hFS(&O7>r1PkUR]b8%W)_:i`T#s#[a?"V/AuVpo#\#GOQ(S.`sr?+,6a/&!HGtF(CM2?RO,i.[tFl27cI8d>oOt?%e=H;>9dMd>g`5@7Hg"op4kV&0aDZa[gPF$1tB/S/>dT%],QC\l-3$JL'Ka)@!H52L>YZ?/NVkQ'X:U#4<)0>j)\);R1UjYH+'?31]eh]^Wm*OFri<^15'e+Yl8MhC'JU?lrL(g*jVjIPpDp`8MX.WCiX8,7ZP[1:iD_@f`0j438u!sUGqfq/h%CV%6D8@PZTU(,j8j!?Ze;u?Ze03B1Dd3rl9K,*D'FnT>4HjI]gH'YrM0sWGMhL\K<1/X#l'"'DT5q6FFb+X>Kp'A-7_3Tn*C%l+;2FSEMeE@"n5UZ)r+Wr"tXZ`FcGR79#klq0W3Wr1MU:jr$2'*toH1&P[E%RpK@9$Z'HR'^hdu/(cm_+kNT&b4A#^1%5lsN`Y86([oC7[_OBD%K-&.(L`#L_/-)JoJ(a7_JGL*WA2'*&f'`['jRWg6(ngYk]DKc'\6V$X.QpB60!f&s3p"CB*.V_ZW\popk[HR\LUB9J83AN\B`Zm1<2*'k'@r?1+F9IMq]dQek_PY*HUS#!`+].q+ScL-;L=;djZbLH=5"U&E^^*mq[8XPO[W'W"P0D--bh[\cSKhA0DOSR1&QT)a!0s,'IE5rc+=V3%F8H[Wgm%.GcqLLU<*$UV?+8/o5g;"Ust0&],i[3?iuc=7a=;:'Zuam<)5Mlm_UNea)8d7mP%Rur6k-5_cGQp"A[l4C"Wm2-07e\DpYLB+c>pkN(5E<%J2CYNG0aC,_EjolAq@4@1E`F^$GQ*`eSkrQP\Zhq4/NpL[i#R@>i)5?QEe?GZ[,%](@a!&mj-##?uOL?FaE!f<.LlZ8VT`1cgc4H<2q)P7G+&(I=Hkm@A^o%6m;I.LS,j7%riB7a%Ji@hqpUCNVeFN9Y`PMajXY+fIQQ!&MNF`J7Af*Ydf#$ZeD3EHQlTS7cT_jD]3fL=2Z5,;aLmgb6hHB(9dUDFh`')PC/6cA'2/Y.%72Rs-""2X.]'.d=N5QPT6*ssQ?TMVFM+jH*9&G+("XEea2``endstream endobj -25 0 obj +29 0 obj << /Filter [ /ASCII85Decode /FlateDecode ] /Length 1620 >> stream Gau`T95iQS&AIa;%#goWK4q[]?2Bj0:"ge.h+#X<(Rh(03M\'eXFm6fTYtb:#OsP**o?I.u1SFMRr]Mh5?7o[VRK;HQ]E0F:K]QG5IMps2KPPJ$![9010aA=!pOI!!D*_m1@idG1e.?C@8BZgsYm$/8`\7Kp#$9+p#/k1iX9<"#dT/Tj(IW>)U*WZ?a8>e*Ib(J7'A[AXC:5bu6OMB^'i9T0PUWA8=hsBj?f1kh5EtK8:9VhrLHh4W97Z9D`4'GnVi2t)6Vn?p;t$%Iaa3Dh?I[(V6%m]p\5Xtk#rQeODCdW0]B=i7_QbGo$()4/lPae:9qa`0D9-OXK;SH^=JGc/dBQ>+e6b8a.aV5#I]90Lf.-l>,^rg_/.o3HkdM6r%/8F1X'>-[hT0l_IO[gro?(*3_hVRZ>^\4BYU9:8`FF^4I.n6Lo;)j+#of?7V7*uBY!E!Tuo0C0?]4/Rq;JmDC5AEf5BURbp@r+A=L'FI_Z-*@DsPF%V-P)j9^!rR_$2jdt'Ee0A8fW9dFgD490^V>V]JkpeGM]jd!Q,>\U7gJWe`I_3pXimNX"Bkt\Y>+MQ#rZe5/'I35bOX324:f<*7I?s-[cSjO+lbugK]Ii[uP/_U`pLeasYtgF$3!p_TKM$"!-aopEUOgQ!_mGRJW]uD5b`/6q/\#40;:$GU+OkAh]!&A^oJ7N'kTJ&+?p!"+#@0Fj_4'?K_$2I%g7X5tYER0ic6,Q_0Pu%3qf&_3Ud>V;3,`L3cG%-"SJB9"3LjA6c)s6;%%3`((2[(H)E<(m)OWCJDT;=i"[_8[fARn&HIpAP3A3;Fc.Qg,rmLmrT'G@aV0SDA[g-rAq45]I@D8GoS7u?\)J22q*T=$"MBlo]_96j_Db4To475#-XBKjQk*o@@cst<+]^KV[gR_3MIpqB"ZMRpHYN$,jf(IFiM/f>M>>4L=f`u-5;'g4U:^ks2Of[K?KAc)[pqnd-'kPc):M7urlc\ZU@1&o.B?D@_;5PVnk.3$dRrjeTae,VMNodpud&p;Z4eYk_ACS1?D\m\j'ZjSk0fS`FI[=%l/n0,"5j$HK.Pr)EjO[,JMo3a$6+NNGqWVnBrq?H8n-4P=ai4FGiB;lo8q[5e0NanpBFmqoJ.*6&5_]q5JC-[6'+[W'(%HqP5>4W'X14%hm,qK#97[cG*%#pXImV7pqZM;:>pf7;1`u,s;WlVB&8UGe,T52ZMNTiXP1+`2RqOn%mmNQ>[^R3a^/2&k[-:j#h/~>endstream endobj -26 0 obj +30 0 obj << /Filter [ /ASCII85Decode /FlateDecode ] /Length 2009 >> stream GauHK=c_;q&:W67(na.S,W./O'M0m6(296pS#2[u8RE5]'U_/9ZKOib2Z\g8p'u4lII;Zh]p;%qLNCd#'O:R_i\t$@r%`rA$OW-7E"%sE%`?ofI->rt#STmP+,Hh"047$Hci-3kE>%aE&HN7f-s%$-3H.ED;Lnn(qkU##;@VY6%L"&1.hr;`W%XCfg@#Wi(`pqY019c#IjL_miWV_Ld\3&i(RZEMXd!S1HCVLpQTbfod(=Poko=#uM9_ip0en(";]SSR\7A:MZh+1&/=U4]*l2c@X^e\BkTkWd_^cQfZ"J4SF"U!+\_Q9G>%bVNJ$[ph9]nVbWi.nN;R#SH?dhD1,5/e-ciq=Pq./`OE4&kr+LUk*3r,>:F$/BSRVGpm`rbC(ddGH%D7?m_8c1"LM.SQfDs7u;67ZG[G2D[h;0!GpW1FjC3.EB$M0ZLWtWs_`VapFm(7M(9uBp@l9lT@g'roK]cYG@1,W,o.bdnjVt.,b;0mNn_8/P4'tZi-[QZ?;E)D.4k=LtpEGX0ZodmHrjL>1p.DgL1G!bJ'9h"-i>g(JE3[$g!_TJ=ZE4D,Yl=eaXLF^toCmQ&R,)FEJfj.H4:#n-H19c;Ioa=eX*F:miq+rkK?:E@7d&\@jp;&XSWDr&ggG3j)ZYVj*XU]5!CULq-VS1g=%m:1*?b!`%@6al`)$0e0I-u9ekZ6TWF.Fl!GW%Q4b5JF6T2'O7)[=ht\5.c*bE`sHdMVi@m)*V+k4pbo)[pQ++X=ZA+O`WqP[m_f#I-(gT;)Q,DkdAg%A$X]f]@SMQN^dC[DXn;6rGcQfNAdc-+A,>BO]nksSX`?^M.g*_S#W\KLZi@+I':q&Bb#'c*;k]U!"_lU-9NH>&=.Eu^/m4BdOF2<=cDQ(S*+AilO8Lic]:"mO)O<5@%)$X&8b>=)/k7b.^CX_A@kB'@##(glGg6c`77`!N#s=E6/X(tkSR(Q$r_i,ZNr)tI48bb&77LO$`'?&KEE&=(&S-Q@Q8:M,ubJtV=2KN3`rtX[q;U!#m@5old`SdgOC#JsY%e8Mh:4bnRY4r^KL],R'.m2qreko,Wh#o]cf*(h"'Gftm@rmWP$F[iq:Q[nefFd$W.%Rs+6f:nS^RAX-.VJ\AW*<`Go)tr1N(P'fqUbDUP$S^Wp)Q[@o=.RSutuITBapf7gC`ZH4V=*'9(;0FK7T+2L15@br/2:`ok1e_38J1),]NTbL8b@-EBTt)83G^!p&bXdLq_jOIIF%NOV%=endstream endobj -27 0 obj +31 0 obj << /Filter [ /ASCII85Decode /FlateDecode ] /Length 1975 >> stream Gb!;d>BA7Q'Roe[3%m@hd&<+TPBh^5"h$3`b*$XLqP)\YU;f-.VZD8F*r-R!Bj/Y5h4a)cQ>D!4IZYIDr3?):-3.t]E:f.kj"S.Njq%cp+5lP9qJ$W1ESkbefRhfX#85\O%uQd?K'O"%huNpGI3O%D*5RFBJH1_rI%U42.(F_D0d+2#IGk/3A`3uY+VRT^bQAp2&[c9@h,SkSqo@VC.(AA-3NYou[d=10gk'MJ8j*'=5<^2?M9JC(OqPaJ0$XDo#D:X*`1XJjLaC+`^>7BDpoh4!@d@NsDd5;^p%XKJV`P!)ja[9i]eLkE^NTH5X):tj9f-@$_Ne,.d-!W-A"%-$F!5HkN(NLI?F"+7;l%g_(3G;sSFJ8VuMJ#o:OM_'n/1=_5_1la((/fo$<:XIr=^-9=MlN>fp1^Ao:eM'$ah^WYF)X]7KM:00gpFZt7=Lah,DhA@<`;4roc&?q:mk,M*uZA$_6$C^\M*\4f*A`7S,m]DujY^orp%^ZV=8bkLY@<-P^XFseXjK[*,UQ`*),7(VlTiH@u\i_s*\WW()g-I>!R?WJ'.TqGrZ[)a=oM#\W&nHu.B."QuqBc9SP.K?tNk!e;FYX?0t]o?N;)FMZj*-e0f>(Nn.\J5b?#2a.mFNT\Z>R=CI(G[51+d[1,TV4faOHGn%*eg2($',Pr!"JR>EDkBg/^a0*:8D_h-C3r\IYp*j)-jCCdA6l$/1RAbO)`rDfK4k?l>.kFh##tOLQ@KJ'NVO1]4]BsuMkaRip].V&Jdm'X5Z+FO2.l'esb=H:-9@t`;UIeQr15>$u@@BB5%p>8GR@-*hIoMgW_D/VfO^Ut0S)TH+W<,6B_:gg""qQpZ?WRRhTTWB]_VI)(:DiEQCrUHp!fl_,4*V)e%WJcAg1E%og"YA0kX)d#i)VfYN-sFQ4)5BnfBDuLi`]ch1l0"fh(GB*.E%^lU[8<2Q.C>;0SbSLOBR.51tn(GU#CFf3WU?XDS,:M"g'fK\/XJ$3F)@DnpqQgGFIFFnpOQnIr?"J[g8t4^$i0k`spHe[!7nNlD)2Kp*!r46'uI>1W=jCX0PV2UQE(\b<1ROP9u`]/MX(;mE@ohd9qcl^`[b%?%\(aW5d-d9m5"AnCGu'fn8qS`lG]Opf=:Voam;Mr.CcT#Aa+`iYhC1TS*U4/J7/'DholE$p6+RZF0_ENueO4%?e2G>nNk(SL(gi)FMY:ha_(qoB@a,p!F3uUCo=9=_o+]3!TN3YXlOYBc!GC3Rn)fEObnJGQG+.W:ZVorTg0B[G,r(]qdlBX7=MeA#>NO6Il&CR33l=Rrf\KruEe1&'\(5eZ2.mkNCK/>qK)'\)`TnlHO0endstream endobj -28 0 obj +32 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1636 +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2229 >> stream -Gb!#[gMYb*&:N/3%#dX&D.B"nO]7q+7@S1GU@"]3I9>Q!OVqA_DO$GmYirjj:m*7fbsi!NUh^p4392&#EXkTL"r+hR]-]PRDeiIG0OPmOkJ/@*Y^Q[-f>4A8E5f3nJI"V3`.:=nIA#F$"b_c3Ldqj^e\l,-dP,-(A:UGIekAGJiVZ9_J7qBZO['<_Pu0d@$_fW$W?hk9MQZddr/@Be&N-d[Rf*c5E!.*qj\^^WP_D/OFUjJi^AI4Rr*F8XPO-FIK0S,E-G($g3S3cup%!CWUePV`nA9X_=]sjpBRba/7p%uqF%u/#<$]mi+6(&?%<0*ue!B/[lMGgq(,82jQp5O_pGe$@4>eua#c]Tn2stl4E*nQc8h.ma*g6NMgTl?F6E)dT)`PY_8XNP+C'jWEP/,iAJJlHIPW)#j/dfF)$eCC/"SRQQ$1=FlJ71)M4/iIZI[27P,DusLT&ehrQSDWg''t3F;XW:0IWaYGdqMkq1T4dr0h)I.N!j$L.njWfLm^h>#j\tql@"8f3rFg'+7UX)+cB1*#PdcRntSo_BP`#u_<8=YkKoMHr&pq_AZ44uV\s6nT4I15Sa8bR&!&r^EJ<5>*oG\T/c"&/V,TUe"")W@EI3I2Z-t%p\4Sm,rn_Ftdj?,b_Y[W["0$F8#J;Vr,d%Ip8!?^aMGQn;e7/,]P/,g=NXU+dD!lp>B@9%.Xh>"eiqjPG^o"9fAR&6]12JfrWMD/QCtYVdL""N:e'oG&j'Hj?FI3'OAc&UH,ZpsElPac3I7]>+4sE0sD3LfMlRBtf;^]a_>.\40oiYG:ai2MGmI+qmA2C&BVtn\YYHrT'4RY0-_H++tfHNn(1U$LKW%OqHI'nk7S`?2o1H*B\W);J>Fj_%?'K&'QmmA,H"7(33K;-L3/XeTC1>"K%R:ks!6=FAAb\WD"ZeE4hj$"seU(,Lcj&OeWZh,#9:eFCpTb0Lr(K-?NL*<%d"7$Q=P\+5C;@eCL#()8lMJmaUGRGP.kW%/rX61U8D1;uBN8U3r]\*:97utGCOttAR[`i1=OH./R;th&Tg'dIc0>kc!%](C.W8M!(.#7*Ze]YGf%j^$t*#EmJ@>e>kESUi',MOMs%=_]tKoTP_SEkaP.qAA+5guYnIK/)M:`0]K;kY22Oi9!KGKf4>iYWHC[]Q"Ye]Pon:01"iS>Y*olAMF*'Hh?@On$LQ6=^SXi??^ZC6u52k*Q54TJ01dPUP#^Wj6c=ir;@kE==W2<"Y8@(d@_hpK?;GR&r%KDU+qU2sr=1ja_EbJGgirbUIbMG\eZH&".pRI5cG0&!TlK)2d'ea;W(V@PZB/K:=XR>#O=eVQET3p!%RHEK7#FAL%qA*^`g:C;.EY7!mE0tnF8`El:0&GupW-3P[~>endstream +Gatm<>B?;m'Ro4HS=*-@D*"^g63r^A:+'mrDDR-=39a(B0IuUt#"4!L-iN^$![4e]lZFkpm".3`:HIZd3e@CO?e0^=-,;0HnFRFt`o67cQllIUhSRIBi+kTfjKXMkKSkKj#Ejc6#F]d35pV:\Geu_nR&F,;:lHff.,_$?a"2s7_o7gi!Jr+^PlRO\,aH$;*tl[U\eZQS1:aOUn0n_u+k!&OR):!a6ODm%jfO8h:_q"^S<^='p\saj]J43XAfG4eps_/^gN;q=o,+'k/>'CghKF2K0D(M^9O6q`qZ5!NE;%6HRb8KDOsCh5:&AjO$QSZBmEEOWoe]t;<=6jlbTF?jUE.GscBp'oL,?nHgq2E/M!Ld%+bbS)i"6hZrE'[$Qr#9^ClBkl,m]DTT8uC89G[CX]O6UOCW[/6.$m?[D22qhnGpR[$iuC?7DSCARiYIGLjC^W5UR6LhKY,WfhZ*9o.8MsI7i2M+nAE9g!o8-^?'oOgZ.`\:+_0b8ip5A7rqFuH?_Vtr;@Z@d8NR54/Mb^7^JJmFZ-9+@LW]S.kG)UJZESMt8:cW@Z3XbJ<")gIotDg(;?n(LK+i%p7NTGMIrR%d\lVDmBn>nhRlL:ftB%b>7$)R;T0EpFLaU,9HfApaKSY9F3snQ2kD2`=E@!N$!@V&SVTTZ`TQF@C)Kf)G=TC:7IC7rqj+@97K84FQKGhA?\@7!u,+bO2$=cS?*qF4^0V-0fJ2OlVZ5c]9]HX\9r5M3,[H-I1`([o81[;7f0n$bK[!CNh=i5'Um>Bj`C=I[M"eoI75@;A2N=[DLQ(T7*atXSR7rYJD[U;EWrF_rc$2G+ENNR0N&t8"f)00M]T54(Xj^7mK,We&JJ4=dPT[nfFNhhjWBo19G>4eX%Z"bPd;hc#Kq,U!mo2kiG8mjp3<3iMblE+=B8Q>-@I73VTr:k1^-6`3c,2>c1#aRfi3O5b6?@J3)Z&5b5Ll@SI'1];!(TrjpSGJ^*$29]e%f7l^IObY3;l/+Ao2`D7/jf#VI$+(e]B7"3&R/J==H4@HX9fGfNmQ_]^-i5=l26uY<;5rmI*MD5BpaI/W/^j%LJ6C,a>0fjPT(9q8Zl*_PAfCr\9)]=$%OojGO`Iaff,5=7)V]RYdi*1p7^4a5G>5lb]m;K#766:&-:<>F(uVq8N$\"77LK,Qr<:T8,6c&_gC%nmFD^9-nkN1MNt)!adQGH:RJs2B_:!WPf8cb!2]20QWC''\7@o^3fk)H\$=dIZZ)`,fBqL\VJVAp@H:H2:%$F1Q-GA!S[lDkA=oqGQgtb_A>i2ZIYMN[Y3f`("4%bBnR!+k\"5)\X\D&-~>endstream endobj -29 0 obj +33 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1324 +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1044 >> stream -Gatm:gN)%,&:N_Cm%`Ul0EL%Gb]MQ`C+#Eh*Of)/(V`5r8qKJD$33YgIXRP!1UQ163/K5H%"dEsm+J]Mj:Kap\%fldi+rUq(EBr8f`HEB@[LH/63h[KZN)rqi`taC$2aS5B2aLfl"%fY@)MaZf>Z$;>`Bh/0T8!j'SM_!VG]j=`dNEePb!+GW:TP2f\[*-l"nl/@A4lp&*#.tFI.Kf)%M57@@&/D^S%Oh@K>(G4JX^S]EhP(X/Y71Q2^EMd7u4KPf[5QUQY//$->U=SDm!nO_r52;-b%-BR]f-'L-Hpk=g7D^JP-[8[jc?h\s+5<9=0bjZV>OI8:*t(kW;^Ig6T.=u^7TCD$-?I2dQ(fA3%*U=3SJC-]/OrsC%]cZZlL+5qJd;%ep%OW<-E;[?WO(`K8A(s\3]!*&(QI0haa&r;a2.?VHC;BAF5[A6!=Sb1LO:<@Ld8GG9mjq5L`@'1]I^UN2B\-'r@TZVF=Vb"KUNFc=SB1.B^i-$5&\@CNDH0uD)#U5]CTR1,RUg9)+;Of@2SI^eTN-?@i;0&PlmDPOZQ&RTrq?8)G/H3ai8.Ep;SA]Q-i7ma:I<&i(cE083XLbu_EELk7aOQ2"%?rV8_J4$@-M&p73GPU/?'#+LJJ,@i!m-<#4N&X;3eM(afjo)W_4a7Z"jiHJ`!]bnJb%G02HWDOo,I`]<)KuAT*Op19,+q0./G*_Op=^.uh.C6:!4ZM=I]^,6F442RP,R2nPQS8V'g6#Qa]ntbju?2o\E)7@9)QC]1oEo[\+jYH)kW7]-cOGf%aco?NUnS@`Lb)7JD"7DQ#)NVu/mN5Mb5ei;~>endstream +Gatm99lo&K%)(t.i0_bG$qX;A[C:>5Cbh2KgPjn#oHHr4UKA:;M/bb/rBc\?@AiO*1U0'*"se`noKYYAVb=DkF:I_O#3Qd(KS8"[KOP:#fC3YnHjuImd\mFRBR(Ff?uaisTNVOG7%3@B`W@\MbSgZ-$>+-k6'@ip_E`gZ3"6!EUUdW:QkbalqN69K[$6eYYt43Ud!phs2Xk1QGN]Q;+&=4LeIrX24WI7s*P!:DM`L^k?J%[I"ggULG)ODG^X`.\utYY@g6jkMhToeZ1NO]llKc:9s.'8V'Ls)hL>BKWZ.:7a>oE:KNGq?4aGD-]HNcWToMFUQKIp5INiuiI\F?^fbiVL.q@21I)k/bXZ,%l[SYB$_Ngggg0;l"NJkDbYO)\;3#eQf6WJ=#41b)G!OkA<1.>-tBjA1t[[N=l),uVI>!h`YCF2Ms^87_-g1bMg6"n2H4VD9F,J`BHQ$r2N7ug9A2Wf6O^ei[?G/c#303rPU;&(3UdVrmm"#o?dmpcBErNuos5rt\5e-XcYg6d"H-D6L"%6%=#5fi.EA[JT!H508b)-*fR*;;enNksc6H8,PZ?C&euuh:SZ^Tae6VT?%`s0[nU-kB_A*s]Z8lfdVLt/7Zb0PQDEcZ9+X>gdN6ZnWj72S:S?QSmo-KqAHk^jqP]2944hIne(mW/,nE<.,0+b36Z2~>endstream endobj -30 0 obj +34 0 obj << -/Filter [ /ASCII85Decode /FlateDecode ] /Length 1582 +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1740 >> stream -Gb!;d8T3?E&AII3bbYhFdEj-b;,g=E9imuY2n/7=MaGgO&t*i13D'8L)c=$h-ZM$Y!Gi^@\BW5V)i%rhn]UjFRKL3j_"^%p`aLV+(6JkLTAeTOp`0nuGS7t\6:o(Z"%gDm"4\qV&.9[[ShcBa+YnlsP)Mr"9&R338QZ%ZL?Zm3!D*DgA-><+=@dFAI7OM(a>tNM&F&l\/,<^#?:r..3Y\q!+YfM'g8-39*?@1`9TE5kkae&8]DZH>uupTdMP%!+`$7_!j"Ht$FkO<",G.^%/A9.B*>jO8p,FGZ.aT[(4N\6IO0W^[DbfFcdV#/-`mQm*V/R2MX3BE'1GTBqYm9bN$8F$&13WZ%>h3OFd8e20@>(odk?S(=qdtTSp6fDti!<@^e1BWYlh`,hXXmH;Mi4?O"09rG<_'2Mo2Vb.S";RSg"$Oqm!sHcB:c8\e=!CnQ)\Ns'T>8t4qoJ[N18@#N"q@I&2%$JY;dM:qNGFR\8FdGtP#kJqqYfWT%%h.=Q%4s/4Ef;"EK=u.aB[t%D+i%B,A4s.A-f;$]@/XNsLhA1HrG2uDGcNru2kWT^Ra5tEYQkf;Ynq8DQ+62KJ?=U[bp=hIu?2fmPq:G1eX)cS0\j'X%@R;Ab$D:$h`/dL?ijkM%Y%l1D^q!\9CikJQf*MHo_RDWe1!MN)W(n*PEUMib_qjp)jS2k&Zg%Y\3`_Cg[%b9or8,cc;WXK:)fs8]l#"$)rAW+O[&%V5YcRd:VI%dSf<,Y.G>(>l:DnFc`s-SSm.U?/<>u['RqJTk!q%4'm8[\=X._Rp^:S`^m-<#Hfbk6]sRgJF;^HjB7c:QgKB1c9FLK2`b#p8nI("4lpQj=PZ'KAO)soNEK2Wq^MXam`GK>0oNk3HZI-0;NCG>Trb8JI+TfFh\.Xb1X95iC9J"-1ZpOU?:]jUib140P'9O6>!WTlgfiNdL4C@endstream +Gb!ksD3*1)&H88.1"oZa!pfG^*9SRmW$j;3!\1X+W;)P!qKBb'ECdA?W8bR9S7J0A>VCkS!i0F0M*8*cO(Y%%:Dcq+Z0n2!!5G_qj=pbV@92e=[h^g&DWm8$HE#*`Y`t=Vujs.cC#YQZ&J3;nFbY]!XGO^Y7D;gD8Xd*l5a,bD/fU26C$!,BI:6>8=#OItQ95=P[t4:%5"?,XhNMn0jcj5&>V837bL=&=;WLsNA8G]3MjT1DJV,C*">S!ST@?KPJ8U))9#%!#nVAXGO?52G03!>9o=?jeUE"JhDi1Z\H%,\A\-1sOQYEB*3e0;qn=)s\p[kZQ])Jk%d;sr8LroC?E:.JVZoK\Iob8;O11G7#jBP8FOc=QOa080"1rpi(cEONbem't.cmLk0*5C8E6gr/D(]XL3>a=-JL`*M??;<5';,#m!(.aa#+C#EPG15.I*4/dd*'?]is"*bXnK=hCpJl#std503snsYSHFBm!D<0)C!UTVN4EAqD!;MGO741ES[#TAno1QF:.,SHXAr$e\-OObamc*Ba`/X*2\OCPI[8u8r?o4=,n#2[)-4AQtO3SLM)TYgn70&'/:B#-O/>1Z?Or'OMfZ&/7#2bfm/mS.D^9qK5R0W//K#G[aR%#jHB"I@D[jU7mSq`.`*_N?_^k*NaTcD5$-\&d2]dC]:P,'XlSLLbY8:%'>(5grJ58JGh:g_t$MndDu*GeFX(Y7/M`oLpfUOfX'5$p89IYZCXEhX=mC27$/YEk!_c'g'I9k5u:Vflp]I'M;-%d0(t;#LNBS;EUe)67Ja15TS:U>!X<_as(tGn%F)`N/J&GkD\1K$eS?mi8NiiMSt0j"C3-\N3P)%1,t,*3,.?YP:7mMI^]6Mf%3Lc0ZUa.4A2s,fKHh<5/CiJ$JV$H[a$dUWjsk/p)BrXd;oCSO`iE`^C3eMHVNu8*uK*Jc4?kP9l8Je<+G1t\)BaP[*9#\9pOI/F5[^(cc$i[Ni8#!Q^C0H*Uq-,]@L$WT\c#SE_5p4_7sGmL$o~>endstream +endobj +35 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 414 +>> +stream +Gat$ra_nsL&A@6WhN9!:fT*H57b'ss6D[4e:L$^MO=DM4M>I0nQ<&[1C0P`s^Lc8@0Rn@LdkgKT?ljB.J`He`8Kq5Z*`U1QJC:5<&TLoba;PA!3E[;(#`gcr)cR<&Bu!#(1Lh`PEi#o;K+CN]iM@hLXeT+ui6Sk*,1;**DmOqbbl?V=:qs**,fZ*H8C!U8XP]@h/s!/1:M#sSXf5QOC9SGi@'Q4Rl8ksb\&Y9@Ed;pj]:ui'npt?*C0g@&.7e`2A,_ao+lI.lP_Yk'Fa)&eSPSp9BB(fH+jsgRC-kWq1$No07?7A,a't.4:q%h'n#=Q?&neaBV_F1n\@!&D&"OXNee_C10(ZcWrXZ0\Wm(';SMcG3/nm"s:46#l~>endstream +endobj +36 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1326 +>> +stream +Gatm:gN)%,&:N_Cm%`Ul0EL%Gb]MQ`C+#Eh*Of)/(V`5r8qKJD$33YgIXRP!1UQ163/K5H%"c[YZY.T:a<@>iDh!ZS^tB,k00!nOZN]ic`A"oWo3(QC?m6=gS,eIX)!(g$*]8FTQU)'Pb0jPU*+YEkRDf_1cD8Z1#^S,HGQnplE?&q*=W1m-"t"VYb4Mat,F/[&TjsCaC&[c#*hR9,jXffH&^9Z9cH(V38I]"$R"[ZE&t!Ookj$'`p[HHZd1n0A.er3Z(+To>6=fTJrQ-#A[.@D:T=DqaCc>Jn="q7Afl7[@j?JY.9*jBas-Mq7!<"rs(7j&m[FDSc*f^*q9q4C,8js7I$RL_*0V_g!POK\=@Up1NE=/1#,ck2[HW;1D(5B(6m0VHM`JeH/)"/^\2FT=dd=P$:)cspX:3LmaKC=V?2?SJbNIh-oXK-m#?2iqt3&h8#-q+\r5uH%K!4%*!87Rm0&ZgRb;pD3jrTQbGSQ>R1Y+r'iH(D97#^.Lb>XcVOS%!i0[`S;FEO1m6cW$27;I>@Z9CarFfi]LQ2L>l+`Q+CTTXsQ$ti*O!35Vd^'%VV$,js*PfVc\.1[_V>#M-3:AYBb["JNuYL(0nFlo>;K=?XE=:Q/WB%=7S6j-p"5eq\mI*/l7*-LpS(-,Eg`u@KeIZ"DcL7K\;mIY[RR7?b[$73R4,XbknOYTpT.S/:g,7BK(fjX*1Q/9=;7^@Y:?;-\i&=QV#[MX7\#M&]%/s/j2-0R&9sV3eP6uc43PF$h+KN1VE;e)cG*Djcfo>iJrq?8)G/H3ai8.Ep;SA]Q-i7ma:I<&i(cE083XUi!_EELk7aOQ2"%?rV8_J4&@-M&p73GPU/?'#+LJJ,@i!m,,"*b%=W(PdOjB$FQeKpHWfJP&*^i.jnr'jT+bEEoi,l`GqDC\(`fc+G5N9XTub6^k#n0_hjQ,_V\TqEhi7/)4h[U0.42RP,R2nPQ-Jt,d#JC0ib*-`:kk[N>bbt\_[e0J?Pp:2q,r\-4E@tUgjeO!QBK)Ad9harQ:OTLB/4j#G4'e3[aQ+VHT-"g8oWF%\XYoiTpt%caYLkRk]Tm9pYVkOFn/>(4<;tM:6m6qqMhPJN>gpendstream +endobj +37 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1681 +>> +stream +Gb!Sm>B?8p&:WeDlq>[#SQ[-:6KIC,kq+]Z2Jq9AQb3kQ4#C&[!@R9Lmn25_j#FNiAB)[`(j+NgDf8e[K`Jm;rQ?(oRka^,.8U6/$.+Jr0Kt58A+,aS(hZDS!sqC-V@6!6g(..]:IE>4:K^:U":5Z3"Hf?FLf=AlF\+n5W"_p3Gn`fdK1&*m"HBtM)s]l.)+2seM)eA8Y@#F"XEZ2Q9Bf9sp4pS*#$liGW.kljBUm!pT#'9]cTdHJURA,cSf:VFpSLjZ]Ra#$&i_ih*Ro4/2_'Tll;EmZ5#]%<$0.X3IV!d6kXQ400oEG8s&dge!<;B)9NnGDfsK*DJf<#3Ji[0LibU&W&dT,)6%ZPTSKk[S'^.j?h/CtKE)k")]A_]('R[A:C:'^);IFL4EhcKPI6F\!3cF^KG/7qk1)FG,7+MtDU-M@pl!ONYT`Mudt`SU8Y[m'ejcs5*U4F'_V[dQId]j/C4e0``!QL*@DYA<[Q46YNO`ldl.>mMMaQD)/=J5uVm\2$RtKUUANoR@fD11]OX?"JK,^7[D!n\:_KhH*6e5aVC?6RE;7dKi7$sb;BT\13BdQ;)aR[E5O3iW9:t$?sQrp\)Ma(G`P2cTkHVBcLt[M2!9cQ,0e?Pc/2l:h-g-A)Z)/ea.[a`r84LoPVi%T/8&R=BDs:kO3@G_&7-kkN35isJH_ZLZdqPtc%XUBO2!Df\)YcThk5EW9%SdE0["Pr%lVDPgOC"1/KR[@jTT4*LngS7tPZND9EI6Obg/AS0A,$/Och9MA3kIqn8rIp_Dkc''2.N>BH+lDRLiS(8>;m=j1HN2af[^_TN4#he@nFW`=qJNbN"84^`gk!o&Bd.a+L2!4'>nV.#]*rnre9&dGW_>S+)UJr&cf11mC?QA/"32WL#\LAKJ3K34a^#Q1:Bai_6E%&;5M[P0kDe7G>Vi2rH7k)XrALaK`UMWn&7h@KJSG/7k=\)[!]1iT60jT(Bu]7(4e-aPWdn]fib;I;OG[jf\AY`a&OoXbjkB-HiPHN;SqsCBk88HQk+K"N'[.H:@4dY'p=5?4V>tdl?Ft%/RHRG7D'(19*.aXi[)V'?oaqZ&"K.&cJcN>TY2.6k8RMo)PeR5d$BX14N2)O>PH8_E,r$j*+>Uj6ZDMdJEEQlL70QeY%W^(U5?IUBrc`WKhXQrq%_P;tgI2`3PaVV63'HAa*g/RI?"/Lh21./f*'Ij@*7-X;:j"ZRqk./N>E*5R&PjC7IRb?KGWd;@!YJ#EUQ("WYcT\@X%9Jt+)ZX-BncGd/;j&_m#i8nqD>@H~>endstream endobj xref -0 31 +0 38 0000000000 65535 f 0000000061 00000 n -0000000122 00000 n -0000000229 00000 n -0000000341 00000 n -0000000546 00000 n -0000000651 00000 n -0000000856 00000 n -0000001061 00000 n -0000001138 00000 n -0000001343 00000 n -0000001549 00000 n -0000001755 00000 n -0000001961 00000 n -0000002167 00000 n -0000002373 00000 n -0000002579 00000 n -0000002785 00000 n -0000002855 00000 n -0000003146 00000 n -0000003277 00000 n -0000005186 00000 n -0000007092 00000 n -0000008874 00000 n -0000010692 00000 n -0000013269 00000 n -0000014981 00000 n -0000017082 00000 n -0000019149 00000 n -0000020877 00000 n -0000022293 00000 n +0000000133 00000 n +0000000240 00000 n +0000000352 00000 n +0000000557 00000 n +0000000662 00000 n +0000000867 00000 n +0000001072 00000 n +0000001149 00000 n +0000001354 00000 n +0000001560 00000 n +0000001766 00000 n +0000001972 00000 n +0000002178 00000 n +0000002294 00000 n +0000002500 00000 n +0000002706 00000 n +0000002912 00000 n +0000003118 00000 n +0000003324 00000 n +0000003530 00000 n +0000003600 00000 n +0000003891 00000 n +0000004043 00000 n +0000005997 00000 n +0000007903 00000 n +0000009685 00000 n +0000011503 00000 n +0000014080 00000 n +0000015792 00000 n +0000017893 00000 n +0000019960 00000 n +0000022281 00000 n +0000023417 00000 n +0000025249 00000 n +0000025754 00000 n +0000027172 00000 n trailer << /ID -[] +[] % ReportLab generated PDF document -- digest (opensource) -/Info 18 0 R -/Root 17 0 R -/Size 31 +/Info 22 0 R +/Root 21 0 R +/Size 38 >> startxref -23967 +28945 %%EOF diff --git a/v2-klasB/Les02-OpenCode/Les02-Slide-Overzicht.md b/v2-klasB/Les02-OpenCode/Les02-Slide-Overzicht.md index 1fb937c..99ef937 100644 --- a/v2-klasB/Les02-OpenCode/Les02-Slide-Overzicht.md +++ b/v2-klasB/Les02-OpenCode/Les02-Slide-Overzicht.md @@ -43,10 +43,10 @@ | Welkom + Terugblik | 10 min | | **Theorie 1** — Waarom OpenCode + kern features | 20 min | | **Live Demo 1** — Desktop tour | 10 min | -| **Theorie 2** — AGENTS.md + opencode.json + plugin + stack | 15 min | -| **Live Demo 2** — Setup + worktree + bouw SmoothScroll | 15 min | +| **Theorie 2** — AGENTS.md + config + plugin + stack + Vercel | 17 min | +| **Live Demo 2** — Setup + worktree + bouw + Vercel preview | 20 min | | **Pauze** | 15 min | -| Lesopdracht: bouw scroll-sectie | 35 min | +| Lesopdracht: bouw scroll-sectie + preview | 28 min | | Huiswerk + Afsluiting | (eind) | **Visual:** Timeline met YELLOW pauze-rij + PINK demo-rijen @@ -298,32 +298,66 @@ scroll storytelling. Kies GSAP voor timing-precisie + GPU-perf. --- -## Slide 13: LIVE DEMO 2 — Setup + Worktree + SmoothScroll -### Wat je nu gaat zien (~15 min) +## Slide 13: Vercel + Preview Deployments +### Feature branches = automatische preview URLs + +**Het idee:** +Elke push naar GitHub → Vercel maakt automatisch een **unieke preview URL** voor die branch. Geen handmatige deploys, geen "stuur me een screenshot". + +**Setup (eenmalig per project):** +1. `npx vercel link` — koppelt repo aan Vercel project (of via dashboard) +2. Git remote staat → Vercel installeert eigen GitHub App +3. Klaar — elke `git push` triggert deploy + +**Hoe het werkt:** +| Branch | Wat krijgt Vercel? | URL | +|--------|-------------------|-----| +| `main` | Production deploy | `jouw-app.vercel.app` | +| `feature-hero` | Preview deploy | `jouw-app-git-feature-hero-jij.vercel.app` | +| `feature-gallery` | Preview deploy | `jouw-app-git-feature-gallery-jij.vercel.app` | + +**Waarom dit goud is voor scroll-storytelling:** +- Stuur preview URL naar designers/stakeholders per feature +- Vergelijk verschillende secties side-by-side +- Geen "het werkt op mijn machine" +- Comment in PR direct met working URL + +**Combinatie met onze worktrees:** +- 1 worktree = 1 branch = 1 Vercel preview +- 3 features parallel = 3 live previews + +**Bronnen:** [20] [21] + +--- + +## Slide 14: LIVE DEMO 2 — Setup + Worktree + Vercel preview +### Wat je nu gaat zien (~20 min) **Wat ik laat zien:** 1. **AGENTS.md** invullen met onze projectregels 2. **opencode.json** maken met permissions 3. Tonen dat `rm -rf` geblokkeerd wordt -4. **`ocx` + worktree plugin** installeren (was al klaar — kort tonen) +4. **`ocx` + worktree plugin** verifiëren (was al klaar) 5. Via prompt: **worktree feature-hero** aanmaken 6. **+ New Session** → openen op worktree-folder -7. In feature-hero: **SmoothScroll wrapper** laten bouwen -8. Bewijs dat **AGENTS.md regels gevolgd worden** (useGSAP, geen Framer Motion, etc.) +7. **SmoothScroll wrapper** laten bouwen — agent volgt AGENTS.md +8. **`git push origin feature-hero`** vanuit worktree +9. Open Vercel dashboard → toon **preview URL** verschijnt automatisch +10. Open preview URL → scroll-animatie werkt live -**Visual:** Mockup Sessions sidebar + diff-viewer. PINK badge "LIVE DEMO". +**Visual:** Mockup Sessions sidebar + Vercel preview URL. PINK badge "LIVE DEMO". --- -## Slide 14: Pauze +## Slide 15: Pauze ### Pauze! **Visual:** "Pauze" groot, "15 minuten" --- -## Slide 15: Lesopdracht -### Bouw een scroll-sectie — 35 minuten +## Slide 16: Lesopdracht +### Bouw een scroll-sectie + preview — 28 minuten **Setup (al klaar in starter):** - Next.js 16 starter @@ -332,22 +366,27 @@ scroll storytelling. Kies GSAP voor timing-precisie + GPU-perf. - SmoothScroll wrapper bestaat al **Jouw taak:** +0. Fork starter naar **jouw** GitHub + `vercel link` 1. `/init` runnen — check AGENTS.md 2. Vraag agent: maak worktree voor jouw feature 3. Open de worktree in nieuwe Sessions tab 4. Plan-mode → review → Tab → Build -5. Test op `npm run dev` -6. Commit + push +5. Test lokaal op `npm run dev` +6. `git push origin ` — Vercel deployt automatisch +7. Open preview URL → animatie werkt live → plak in chat + +> **Niet klaar in de les?** Geen probleem — je werkt thuis in dezelfde fork +> verder. Het huiswerk bouwt voort op dit punt. **Sectie keuze:** hero (SplitText) · features-grid (stagger) · testimonials (horizontaal) · gallery (parallax) --- -## Slide 16: Huiswerk -### Bouw je eigen scroll-animatie site +## Slide 17: Huiswerk +### Bouw verder in je les-repo **Voor volgende week:** -Bouw een **kleine landing page** met 3-4 scroll secties. +Doorbouwen in dezelfde fork als in de les — eerst sectie 1 afmaken (indien nog niet), dan 2-3 extra secties tot een **kleine landing page** met 3-4 scroll secties. **Eisen:** - [ ] Eigen `AGENTS.md` (onze mag als basis) @@ -355,16 +394,16 @@ Bouw een **kleine landing page** met 3-4 scroll secties. - [ ] **2+ worktrees** gebruikt (Sessions sidebar) - [ ] **3 verschillende scroll-animaties** - [ ] Lenis smooth scroll -- [ ] Deploy op Vercel -- [ ] `WORKFLOW.md` reflectie (max 400 woorden) +- [ ] **Vercel gekoppeld** — main = productie, branches = previews +- [ ] `WORKFLOW.md` reflectie (max 400 woorden) met **screenshots van preview URLs per feature** -**Lever in:** GitHub URL + Vercel URL + screenshot van `git worktree list` +**Lever in:** GitHub URL + Production URL + minstens **2 preview URLs** (per worktree-branch) + screenshot `git worktree list` **Bonus:** Eigen sub-agent · MCP server (context7) · Lighthouse 90+ --- -## Slide 17: Afsluiting +## Slide 18: Afsluiting ### Volgende les — Introductie Cursor **Vandaag gedaan:** @@ -374,6 +413,7 @@ Bouw een **kleine landing page** met 3-4 scroll secties. - opencode-worktree plugin via ocx - Live: Next.js 16 + GSAP + Lenis scroll-site - Parallel agents via Sessions sidebar +- Vercel preview deployments per branch **Volgende keer — Cursor:** - De commerciele tegenhanger van OpenCode @@ -401,11 +441,12 @@ Bouw een **kleine landing page** met 3-4 scroll secties. | 10 | Worktree plugin | Theorie | [12] [13] | | 11 | Demo stack | Theorie | [14]–[18] | | 12 | Onze AGENTS.md | Theorie | [14] [15] [19] | -| 13 | **LIVE DEMO 2** | Demo | — | -| 14 | Pauze | Break | — | -| 15 | Lesopdracht | Praktijk | alle | -| 16 | Huiswerk | Praktijk | alle | -| 17 | Afsluiting | Closing | — | +| 13 | Vercel preview deployments | Theorie | [20] [21] | +| 14 | **LIVE DEMO 2** | Demo | — | +| 15 | Pauze | Break | — | +| 16 | Lesopdracht | Praktijk | alle | +| 17 | Huiswerk | Praktijk | alle | +| 18 | Afsluiting | Closing | — | --- @@ -439,3 +480,7 @@ Bouw een **kleine landing page** met 3-4 scroll secties. **Lenis:** - [19] Lenis GitHub — https://github.com/darkroomengineering/lenis + +**Vercel:** +- [20] Vercel Preview Deployments — https://vercel.com/docs/deployments/preview-deployments +- [21] Vercel + Git Integration — https://vercel.com/docs/git diff --git a/v2-klasB/Les02-OpenCode/Les02-Slides.pdf b/v2-klasB/Les02-OpenCode/Les02-Slides.pdf index c289a70..d9f6012 100644 Binary files a/v2-klasB/Les02-OpenCode/Les02-Slides.pdf and b/v2-klasB/Les02-OpenCode/Les02-Slides.pdf differ diff --git a/v2-klasB/Les02-OpenCode/Les02-Slides.pptx b/v2-klasB/Les02-OpenCode/Les02-Slides.pptx index ed958f0..2a344c2 100644 Binary files a/v2-klasB/Les02-OpenCode/Les02-Slides.pptx and b/v2-klasB/Les02-OpenCode/Les02-Slides.pptx differ