fix
This commit is contained in:
179
Les07-Supabase/Les07-Docenttekst.md
Normal file
179
Les07-Supabase/Les07-Docenttekst.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Les 7 — Docenttekst
|
||||
## Van In-Memory naar Supabase
|
||||
|
||||
---
|
||||
|
||||
## Lesoverzicht
|
||||
|
||||
| Gegeven | Details |
|
||||
|---------|---------|
|
||||
| **Les** | 7 van 18 |
|
||||
| **Onderwerp** | Supabase: database koppelen aan Next.js |
|
||||
| **Duur** | 3 uur (09:00 – 12:00) |
|
||||
| **Voorbereiding** | Werkend QuickPoll project uit Les 6, Supabase account |
|
||||
| **Benodigdheden** | Laptop, Cursor/VS Code, browser, GitHub account |
|
||||
|
||||
---
|
||||
|
||||
## Leerdoelen
|
||||
|
||||
Na deze les kunnen studenten:
|
||||
1. Uitleggen wat Supabase is en waarvoor je het gebruikt
|
||||
2. Via de Supabase GUI tabellen aanmaken met relaties (foreign keys)
|
||||
3. RLS policies instellen voor publieke toegang
|
||||
4. De Supabase JavaScript client gebruiken in een Next.js project
|
||||
5. Data ophalen en muteren via de Supabase client (select, update, insert)
|
||||
6. Environment variables gebruiken voor API keys
|
||||
|
||||
---
|
||||
|
||||
## Tijdsplanning
|
||||
|
||||
### 09:00 – 09:10 | Welkom & Terugblik (10 min)
|
||||
|
||||
- Vraag: "Wie heeft het huiswerk gemaakt? Waar liep je tegenaan?"
|
||||
- Kort terugblikken: wat hebben we gebouwd in Les 6?
|
||||
- Aankondigen: "Vandaag maken we de app echt werkend én koppelen we een database"
|
||||
|
||||
### 09:10 – 09:30 | DEEL 1: Poll afmaken (20 min)
|
||||
|
||||
**Stap 1.1** — `votePoll()` in data.ts (5 min)
|
||||
- Leg uit: we missen een functie om stemmen te verwerken
|
||||
- Typ de functie, leg de logica uit
|
||||
|
||||
**Stap 1.2** — POST route fixen (5 min)
|
||||
- Laat zien dat de huidige route alleen logt
|
||||
- Vervang met werkende versie: body uitlezen, votePoll aanroepen, response sturen
|
||||
|
||||
**Stap 1.3** — Server Component + VoteForm split (10 min)
|
||||
- DIT IS HET KERNMOMENT: leg het patroon uit
|
||||
- Maak `components/VoteForm.tsx` — Client Component met useState + fetch
|
||||
- Herschrijf `app/poll/[id]/page.tsx` als Server Component (geen 'use client'!)
|
||||
- Server Component haalt data → geeft als prop door → Client Component doet interactie
|
||||
- Dit patroon verandert NIET meer als we Supabase koppelen
|
||||
|
||||
**Stap 1.4** — GET route + visuele feedback (5 min)
|
||||
- Voeg GET route toe (nodig voor VoteForm na het stemmen)
|
||||
- Update PollItem met percentage bars
|
||||
|
||||
**✅ Check:** Demo op localhost — stem, zie de bar groeien
|
||||
|
||||
> **Tip:** Als studenten vastlopen, laat ze de browser console openen. Fouten in fetch calls verschijnen daar.
|
||||
|
||||
### 09:30 – 10:15 | DEEL 2: Supabase Introductie — No Code (45 min)
|
||||
|
||||
Dit is voor veel studenten hun EERSTE database-ervaring. Neem de tijd!
|
||||
|
||||
**Stap 2.1** — Wat is Supabase? (5 min)
|
||||
- Open-source Firebase alternatief
|
||||
- PostgreSQL = echte database, 30+ jaar oud
|
||||
- Gratis tier voor leren
|
||||
|
||||
**Stap 2.2** — Project aanmaken (5 min)
|
||||
- Iedereen logt in op supabase.com (GitHub)
|
||||
- Maak samen een project aan
|
||||
- Wacht tot het klaar is (~30 sec)
|
||||
|
||||
> **Tip:** Zorg dat iedereen een GitHub account heeft! Dit is een veelvoorkomend blokpunt.
|
||||
|
||||
**Stap 2.3** — polls tabel (10 min)
|
||||
- Table Editor → New Table
|
||||
- Leg uit: id (uuid), created_at (timestamp), question (text)
|
||||
- Leg uit: NOT NULL — het veld mag niet leeg zijn
|
||||
|
||||
**Stap 2.4** — options tabel + foreign key (10 min)
|
||||
- Vier kolommen: poll_id, text, votes
|
||||
- Foreign key: leg uit wat dat is en waarom
|
||||
- CASCADE: als poll weg is, opties ook
|
||||
|
||||
**Stap 2.5** — RLS policies (5 min)
|
||||
- Leg uit: standaard mag niemand iets → we geven toestemming
|
||||
- Maak SELECT policy op polls, SELECT + UPDATE op options
|
||||
|
||||
**Stap 2.6** — Testdata invoeren (5 min)
|
||||
- Voeg 2 polls + 8 opties toe via de GUI
|
||||
|
||||
**Stap 2.7** — SQL Editor (5 min)
|
||||
- Laat SELECT, WHERE en JOIN zien
|
||||
- Dit is een mooi "aha-moment" — de data die ze net hebben ingevoerd komt terug
|
||||
|
||||
> **Tip:** Laat studenten ook zelf een query proberen. Bijv: "Hoeveel opties heeft poll X?"
|
||||
|
||||
### 10:15 – 10:30 | PAUZE (15 min)
|
||||
|
||||
### 10:30 – 11:30 | DEEL 3: Supabase koppelen aan Next.js (60 min)
|
||||
|
||||
**Stap 3.1** — npm install (2 min)
|
||||
- `npm install @supabase/supabase-js`
|
||||
|
||||
**Stap 3.2** — .env.local (5 min)
|
||||
- Kopieer URL + anon key uit Supabase dashboard
|
||||
- Leg uit: NEXT_PUBLIC_ prefix, .gitignore
|
||||
- ⚠️ Check: staat .env.local in .gitignore?
|
||||
|
||||
**Stap 3.3** — supabase.ts client (3 min)
|
||||
- Drie regels code, simpel
|
||||
|
||||
**Stap 3.4** — Types updaten (5 min)
|
||||
- Poll + Option interfaces
|
||||
- Leg uit: waarom twee types? Matcht de twee tabellen
|
||||
|
||||
**Stap 3.5** — data.ts herschrijven (15 min)
|
||||
- Dit is het kernstuk. Neem de tijd.
|
||||
- `getPolls()`: select met options(*)
|
||||
- `getPollById()`: select met eq + single
|
||||
- `votePoll()`: twee stappen (fetch → update)
|
||||
- Leg de Supabase syntax stap voor stap uit
|
||||
|
||||
> **Tip:** Als studenten TypeScript errors krijgen, check of ze de types correct hebben geüpdatet.
|
||||
|
||||
**Stap 3.6** — Homepage async maken (5 min)
|
||||
- `async function Home()` + `await getPolls()`
|
||||
- Leg uit: Server Component mag async zijn
|
||||
|
||||
**Stap 3.7** — PollItem aanpassen (5 min)
|
||||
- option.text, option.votes, option.id
|
||||
|
||||
**Stap 3.8** — VoteForm + detail pagina + API routes (10 min)
|
||||
- VoteForm: optionId (uuid) i.p.v. optionIndex (number)
|
||||
- Page.tsx: alleen `await` toevoegen — structuur verandert niet!
|
||||
- Benadruk: "Dát is de kracht van het Server Component + Client Component patroon"
|
||||
|
||||
**Stap 3.9** — Testen! (10 min)
|
||||
- Samen testen op localhost
|
||||
- Stem, refresh, check Supabase Table Editor
|
||||
- Het "aha-moment": data overleeft een server restart!
|
||||
|
||||
> **Tip:** Open het Supabase dashboard naast de app. Stem in de app, refresh de Table Editor — studenten zien het live veranderen.
|
||||
|
||||
### 11:30 – 11:45 | Vragen & Reflectie (15 min)
|
||||
|
||||
Mogelijke vragen:
|
||||
- "Kan iedereen nu stemmen?" → Ja, via de anon key + RLS policies
|
||||
- "Is dit veilig?" → Voor een leerproject ja. In productie: auth + strakkere RLS
|
||||
- "Hoe voorkom je dubbel stemmen?" → Met authenticatie (volgende les!)
|
||||
- "Wat als de database offline is?" → Error handling in onze functies
|
||||
|
||||
### 11:45 – 12:00 | Huiswerk & Afsluiting (15 min)
|
||||
|
||||
**Opdracht:**
|
||||
1. Maak een /create pagina (formulier → INSERT in Supabase)
|
||||
2. Voeg navbar link toe
|
||||
3. Extra: SQL queries schrijven
|
||||
|
||||
**Vooruitblik:** "Volgende les: Supabase Auth — inloggen, registreren, en bijhouden wie er stemt."
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende problemen
|
||||
|
||||
| Probleem | Oplossing |
|
||||
|----------|-----------|
|
||||
| "supabase.com laadt niet" | Probeer een andere browser of incognito |
|
||||
| "Kan geen project aanmaken" | Check of ze een org hebben, anders aanmaken |
|
||||
| Foreign key lukt niet | Check of de polls tabel al bestaat en data heeft |
|
||||
| RLS blokkeert alles | Check of de policies correct zijn (anon, SELECT, true) |
|
||||
| .env.local werkt niet | Server herstarten na aanmaken .env.local! |
|
||||
| TypeScript errors na type change | Check imports, rebuild, herstart TS server |
|
||||
| "Data verschijnt niet" | Check browser console voor Supabase errors |
|
||||
| Votes updaten niet | Check of UPDATE RLS policy op options staat |
|
||||
334
Les07-Supabase/Les07-Lesopdracht.pdf
Normal file
334
Les07-Supabase/Les07-Lesopdracht.pdf
Normal file
@@ -0,0 +1,334 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 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
|
||||
<<
|
||||
/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
6 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
|
||||
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 (\(anonymous\)) /CreationDate (D:20260324182754+01'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260324182754+01'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Count 14 /Kids [ 6 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 779
|
||||
>>
|
||||
stream
|
||||
Gatn$968f@&BF8='RNM7K4;NjgtG=AFOHsA*dZf)Jsp/aFpp4l\*qHm0S<;9$8r0rX53eO=tLFT]P.+$>lhTri9CCJarpNe<"K)X/\I-rVh!?iM@jEf`@jgr6Rbb<7Ruj/2tj/".&cRn0u+d99fp-aX"("qF#'AME^aeX/NQ9U\@B-m&)gI\kgn3i=giti@iF26iRYPK3D\'rL_>O#^n<b"FcUjU0hI$2WaC.p>6e-R'[oAMi?mWO!O:FpNkhTNL4M%ML/g(!'<+u!F6hDMGEP&-I`^#WJg9`L//<"B:biKZ<>5q`6cia]R'E2i/Vl=%Qs/u=:19M"^:70Dr.-\b$[C=dnW.q^>)_m6="kKZO:\iB"08SE5R[\DI:D#/7?fXR9&IRV;C*m"4EjGb6B!=LK0/h5+itY^4Pt!DB5Tqb?l!NOb!Q5G$):T&=;\1=?_ud!*fijm+1(B4.PT@NqMJutU;uS(>:rKc84kH`Of!u:iA[nF2>cA49<luXgmd]>34Is01^AsKPDddoLKFd@#KOsJfa,sPVn#T9Y'>+YIVt**IWL+D@<T"MlOl?"Q7]G'?/ar3ND%Sl<JCJ@MM4]90\XgMMm(fS$$hXZDRtDdHiq+QWP-7Lm!Qfs&eJP9'-*%i4pFElF-'idc!U_1.Ulp:r6l9"8cuNU4Tdl*"t'$QpuDmZnuBNBaV>FePP):%WTph9"UV0XZ8n2CX8en-3b3Hj,W;,g$/LS3i<n>m$!Y,JQWBQ:KM-m)'hE4u1aoF'c%ACQ(O_^e\c~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1029
|
||||
>>
|
||||
stream
|
||||
Gau1/gN&c;&;KZP'RT)O'7qJ>VDk%eEpubkPF;%i2_rH4%Nls+Vg8/3**fD$-Z[6_*gcu(rE._P?U3PL"Z>VA!;IJMaVb/5+eA`**"V%%3/n/="4WkZ?mgbLKHTnS#=UqS`i6ic%WLY-i/j'o+q.47oFJlfT]SXH@H'V-^jqmcH@6+;EOk#.[-#o/Y1XE*98GgrYd\@m.l8E0Mb0R/^O6/%D,,>%%H(q#jQj#g"!NZ/Ri[=A)9W&/C8dTVZm1[5/fstteKa",kP6j7.\O,"5-jbX.gi"i(U1X`XMVSDGd%!'nXB:2hFo2s!'(<+0FhO9-.?fD_hTJ(Xia%D#3cW7^a^=AiGPeG0aPLV%%`k`BhQ9!HbOD5OL0NO-Itl+2dTIrA>Os?E3Bn#`^O=O83Er3?H[SBg?2:Q[cVCDPWqR;ZM?*,I9*ORc="Xo3i0sYqd=l+%pR)!Y<!_k;Xee)WrscMMG@m1g.T!9l7kg=C<$jI`RNn]Y^]OW=hTCaC]NV0<l+X,(4=4:$'^a1`U)X+.TfcrQ'/s%,b;6rJB?X3-O4)FMO.C"j/"Jq2mR-9\1cUKbDFMFQ=R'TJ-p+]Ko4;F`N5@SQ.,fTZY9p%#s34<<K0nF8M1F5n4kY35?T4XakB5,*dLa75q[;&_A2qoSYaFKG&=eokp:1Oero/U2/6"0BW"]IKW.nVdrLuc?5D8mf2Q:&R[_e0$#H%1g%6ZjI809+&K5V62EcT_Z512SXG0`OlIEfQ#dS.EUmEs@*fd=1_*jhF7(-?i^u<A3'9HPW/pP2^3bQ#Z.pCOoBVA-^+MH.dpP6"a`Em5l9iE1[W,"*!DHo'].?cU3600dN%86g:RjLFkW!3*>1N'gG"4=pJE$fS0b%glM[?jTm=/hk7ibok%4qU3-(BR[7^AUC&Q'#ocFlRhNEEqR[9f@cn8e8MRl!@N=\30c,$8,YXUf;/PCb,#KN(?*)JJZ%l&]4ntDem:oFPNDtPQErXb!0R>5p3n>NYUK9OlMK:en/8!P5u)@"%>E0`W~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1346
|
||||
>>
|
||||
stream
|
||||
Gau0C>B^uE&:WeD9`9VX/67+))KL.Eh"KeK[rH4Zq[i>&Ad?tUPr=!Z,rY_h*D$WF*+MO.)N6r0b^B#AoP):\U)2FRP76Bo#l;;Ui&6Ok^g6*>kQH>TFZt"o1Xih$+5%6]^^D!?r.s]QR+]1o\/m=O[%,2A(-1XjMHP^^nlD,T7NlTPFU7Yc!o7A)"n2P_b/N?gc?"f>=aZ(c&4W:"oKj^G:TpZm3-H5E\*+'Pa6d@NY!G@1Eh0/,;>cEHUV\!$-nELsqOS%UiX<X#3h9M*I%]t7@mLbA#01E]8R3,Ir8Un@V4t+hYhchf./XS1p52$$CYYW'I9slQE5l=&gAtR$$5Lj+GC0\F>"kq1TosXHEOHCQIc.VqI8-1Sl[c_9fF=Ti=V?]bs!ocY>I.NW>[_'nAFM4L/r`]jW82Y6ks[gnX-cmu1UN%p"e)UBU'`2K`3E-a%X+c6"WQNXO]o*\\39`&_`YHsYfO;*d"TLl'h?&:W8;FUZ&m1[>6,EDDdMYC)ru^aGhb%?3=tR%%rZJ[lcQFY7B<Y,1*?dZ<6TY2F#E`Y#e1b&W3Zj_`eS.R_*-;t5>;;.E2RDI]Q=V%X(EPE=[B4Q%s:f+Z:J[)FE:VO6Sn$cj=F#53#k8RN":Epjt3.@\GMJ16ad/!FWhek4E&cm"C;[Kp\4(9i3etf0JYF^Z3?+g2?/tEg0AW#IV?:lq"!NIMoN.[Cs?,NZ-GK#LPaJ7Y4p6YM3k#!BSn-XW^mXIF.?(1S>q->1H*->P0P1%HYSbhV4;6MSU40"*L#.0eK2$Xg&+@`Q>L0uo\K<K$;'g!&m*`W#]M_uoY1^`P6s^0hiIB9Y;\uO6^iDhCS9Dr$&bidbY0&>Z;1*i,T\uGP9N#OrY@r.,O69p.F#4Fh8,_.;k7?bdhY>R_R(F[57l>t0.D6!%U'f_D6sq[[D=NpQ+4<c)]MX_0o[OdB8Ys&\;7J3]8#EnS5bR)VBq\V=/NN\#J#M!ULpD!C,`?V2qf4;[pnp$^hSjh8Idr9=B5M`1LGHmRK3E35oVJVdLOf-fSGM1`7H_,8/O!aFi`3?aQ.M]d9pCT8h&8@^VdL^VaKj.[CRF0i$enR:=[5Be=7<A'5?JPC'3.88@3b-a4-;Vgg5J)o)%*Y2Ks[?+c?@@k_qo%%t6<\;PG.aP#<`fL8%EQ%q+)f^+a+5=#DR+f<SepT8R?>\U9Fmm0mhCrldsb>QV8NE/TFp*IoaEk.N54T&44Kj"cO>JYt?$H\*OH7bRg9kH_SVkRYmJE=3tN$TP?]p`aT6WX7JSaF.[rDQrBQJNhm\=r,kq*%R9g&("RB3M&lR)0eot]T?O'q\F/-J^2#Jgh*\~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1616
|
||||
>>
|
||||
stream
|
||||
Gau0CD/\/e&H;*)EAIeukn;.%-!Br-@I&8p[`K;[?`F0=/372`OfCt8"bH^NNg-B]Na995Q37dN+7C9-4ac\]5nAH.qgXb2]GQ:u`WOPBnJ&Wd">G98YK*2P*H,@(q'6mT6OsB`8%`7e`-(i4nTsuHK@OU[hEdc?'*]K?(r?M2Z6<Zj^cBrP!0?aqnbiKYZLXHLn5AD&'MN38"[5N&hT.GW<FfA'_>"Y_cOI+nnTQ!5^Y]+%KYSNF#[V0GgV%n;aCbA.]]'j>Jd\I7N+GtYQi-DHBa+akV3D%kJ<0%tP=mcbP_"O$pLmho"[2%#0&G.[*TN;;nT_MlB96"c5o(9$At%6(FoKT?Zbc_$6F2hM1hYDKPb)/HiZP$ZB-Q#])d0&lHD^ouocljX1N"Bo0itWBm2:q=K8gbW#nEs<A'Fq\"P;b5<W,-YH3KKNRr[iS'<Q.lS#bJ2^!V#5JJYfK)@tk7IgE+$J)5?>#WEbZN-^+9JCQ$Q=hm=>rdm*T0eNPm*^\!,`H)uCRI!dY;phdHd>-\h9K\dn"r"VbB6I3c=17fBGDha!LD-(e0Sa?kF6NV^lFVYIm]VW?ZJcBnU+n!4rG&GtL;lP&X_[fs+i.?ZestQ"oBApkq2kJrE>%FU$?SL!C3%R5<oG#arOViajLftu>nODl9VbK'4ra*A:e[>n?f8-hQ8gJJakdB/FhXY*nB_>=G@H0ZObEg!_@ojgEK4E:/+_XWUK[!8\iCBP]1(le0WAA-_Tg9aBcX2_EsK`3;b1Y7+l?,GSLN$OMo>GVdXo(XOE,ib[fLb8s15rqm,^a_oS#t/cojBQ*I%7Mio5R;Pa0CU\,0't(^YUE7OiT/.kOSn-e)ti]i7mO@K*&>B/7.>_-a+\+%]Hc5.ra/CM+^q[A33dcDBGtA4g,6,-MDfCHa(?<L;i`ZbaROp<YnF2u0(qQ`DF]ZVnSWeYhQ^ROTE5>0i>Pa*,q1I?U_Lqhj!HE#7lC*j+hS[jtp;kl9fIIT?dMEFCq)@0u7Kk33ZuHQZPVpM.V#8\[DQqS2ZL&8fqcd/---DT)1[U<AJ,UU2>u)*`1:CL=-Wc&D7%^SV=?>q2Y!,A6!ZPe_:W(%dregLI`Cic]X8dKtO>XNO0a$aj,/E.X`CSocg4\RO+4"^1;FVmVsGkeE7>X">T.;``+o6CXI!@a\jbfnNs'RkE.Igtk:)-;6h0TTtRt8&&5;Ll*]d<PrF4IRc,o7f66<7b"6kkeJeu4`PWYU.(5DIZ6,a<U(gRfjXX;?ogZ^`Bhjf-i=4gFlE57?Rf70>/&7K1:<K&RC^SM:Q*hd]WH)>&,E?d?s:"?>H6QjOr2pk0b1\L)Z1Un%I][e0*miW[:fM06bf2al`kp]<bGZ.jjMLOi%7^uoo:#^OBt_\CngZd_Pk/9Uo?A:b0G([;O6HO@]--"+8.L&/I9#K%7WTF'O*%.9YiG!bnSs#X$oU`e4T!A[m[9O?B<$_9-W4.X2!ah?Pq<6-QkQ**/#Z&c?lEr:pl3W<5=VUpqUoQhLV?,K[X'icfd]3Wk`J4?:9*:0Ne#cJ;[.&[=\1956sRuIFB0`liMV,drYod!%eIJ57I%n*5[.u_AH]=mkQli5j)J_>mb9~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 837
|
||||
>>
|
||||
stream
|
||||
Gat$u>u03/'RfGR39sM#^kq[5U^1[$!eeKM7O.W2m(mPo&npZo\-7W`he0QtZtomtET4q1\bFn>lV$4$S(dUU$63\t:FW6'C]kZ>&D74^ZIXG^Y#`P+M,QX8kf+d>Bq/q.T%60<[\Zi"d)]Us2/93)lKRF&YJ)'ql-Ch;?k(/IX=?EmJom+1,24of^[(c(rW@)Vs)./We9jX.4nV*N)e*8I3dnss:`XE@qd#__A>nT`E@ArlP)m0KR#ZXaP(TY3X2ba2)a5*X!Nr%r5!g%H(r'Xu'FM"4@c[_-)jc/EJP&Za\75TG\m\&t>\j0_%$^&pD"O$F_lCjj<TtE,j8LTf?`nN><+B"EZ?;6j"$+tL[%#6Wr@IF04r,H%ilsVYd_lMk]^;oJ=sUsPMkmW09gZM[oc=G[,c68e6kTsX\J_1)38V!%\)m=_eN6mu*?b;OmW)LQ>2^?/XtI*ra%tm;53>a_o7mK=[@Euifq"?0=)6.9LGj;h]QkLrBFV3.8IR>BM4lDqh7)1=q>H72We[Y:a1c^qZ'nB+/G,T4qpPK+*B^O-3j>50h,'!Kn1+l@jp:=)Q"jmln9p8?Lni51Ei1JZc;%J%dQ@EL^tp<Ch:5cTC4WSd;-i^hdZ->uD5>7DBCNKuFUr>iUaWe%>inou>dUai@PitA=job%KikuTCp@bb\+aGJ)\s6.;Kc(.Ck>KE,cb[X$c0,c<tCkGbHqaGimF\DR5At;G_>FiE*V^mG:jB>n>WL]+;h0L;V<rJDd&qkn`$dbD)$XCo6UR*r7LIm&P-<TMm!X\@'91=&[0^$L]gSRkGF&eKLCZo[2tg1ha6W>aej[S~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1134
|
||||
>>
|
||||
stream
|
||||
Gatm:>E@K-'Ro4HS>''S7bDs5_^;4^'"(T4Zq7m.:HW0l&)^`?m*(FA-&DTXisVqS]A<+2&P(L:npTDScPZpF?**j]F+Dk`)#qI'3A=e@o.1F7n)<I*i:DJASq1ATOV9I:"O(=P&5aE1[[9d=(gL7T*`,UT.RT&6.cC@8RM'%7^pqt."D7J6E1@74RApTMKG@t%jRFh_W1cTsUX4CJYiJ\f_>FN"05GC=e+FPA2sfKb"6%cQQsZ1tfWlF<dgX%e:niK;A-9O82rZ#7$UL00pg]Ftmkj0m<qi0>0s(Hj\ja0k@LWW1.Q0_\^pYX:CceAK67&W]dp/Z850qKC3.:23.(%e90'sa!#:/Qi^kr2?&peM2Bl+!?@K#jch[1e@S4N\niUtBknG1N.XO5:0F`S^P:::t(6AVt41j[Q2W`^2gaX/n3TiMU4Y`_Eb_k4qp=/$V)r#4Jh*ZW`/;!PD!+(\;!R)/bFA%OQf;`&Aj;H9T'rJIE%W2:];I#7DoD^1h@4demSajTtXa!iPlZ7`:ff)#;0q9u.+ic*0Cmi/]T+dB]K66m>d*nni5(?_WODZh'\31T\qnVA$10FQni1>fPf8[laIZi.c)`uaJK8%+>V_c,769q64"IcEH6L(Z;Fdk*o#1E@l<(RBbK:kC5JO^J61,ul,5Z:K\HgREe$Np1S$XsMtn^-1i0HsP\PW?)8X<(0i%"C77h%2l<*R>nbWBZ!]HAQGMHC`BbB6DZP>P@rS<lY<"1V0<g&p3X;B'>9gNVf2;?.sUjW'-%K9rZ=+jFBrI&%l]d%l"Xi`;;WOSIjWmEj]'>j5DudbpV>b3COGC?mu-k\BtW'<%<0#`N.(;$*9oJZYNt355N4^.P0D7jrLIR+j-afP4-*/])RF9RFkW.-*%dHs#0t-l.rZ_g#,UELF]3.'lC/7&[riYLe5\o)K<,4#Pn)LRef4hU&nk?38\JT_9UBq,hB72;BpL+5$jA93*$pJ\/ga>;$>B8tZ`UGn"OLkUrLOOB5Nm@'Sn0^ME"aPX4Xuh@o:-TnSM5Q4]TB`@h/u`g-!/_Lgs=%Cb7;2,H,]j(F<p*>=:V=kO\BXUmMq[2DZ)T?4n/Nso.ASgkZJBZSI>@W@%H^sE'8`f?`/`dX8~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1711
|
||||
>>
|
||||
stream
|
||||
Gau0C8U&oI&AII3bY^qPL&cF)qGP@Wdl=DYjPoq)+:4?J;1LubG25N8HNtZa;%aboWU!95B#-F\2\Z9/^PVbJ>64X[JX:!P#_gAX&E!"dEWN)%dJ"6XRNZgjKI-hr+EC!c->80DLP@YV")tp*ob%U,ior,;(F/QD>1ZGKGQe7cY0_YO@/QJb%-TFY&Ls.=Ei)_EQfXZ1=i''SdKn]&30A:]$mrG;H*/<=_XB*Ze8)rG/fk;b^@&k42(TB/aC>><;R&)ULK"i!1'MC>i;.Qr(>&Kk/:N9VX$@)ZTYaU)"!MV95d%YB;91MbEB$U4DIb:s4;SFTGO<37>er8YF*2jIBb2XDUg%dNK;V%E11X5S#6X"IZ;?IL?[fS:3E$-i6*Q2[>\/#JbA1DuW>%J4K"(C2C@&LG^b''leMC^:nSbQP^FIbC(Fl!?@(X*bEfl5(aS@#3<3GnSi.JI*kN>_q1-D2,lM#>QIDr%G3Eu,8TCP<'0ulVFlV')3Vp!%lNYP,eI/q_XNoh070<fPPM&u)0XrZ]*bMh]5J`SKf:5BTQ\(cMtkTe!`_R7eP7H7#?NmDi'$:$n[j-\Yub:+bVm\J&Mj#I<t5Kf"`K'O?2nsSLS):1f<nC4d7X-f@D[r?jPZ!p@qocJeV[s2ZRjsXrQ2Aslq`.;t66>+Ig^F;lc+Z_Eig+/-(B=ckS_C0I/%lhROA36Zc@+9`8\U[Yp$>4\:8(GL_G+JeS]5E&Cn)7dVFhRrbapbI8Y-ftC'gLRHo=[h1bI,R"h0>"=1JXJTq^,ZiDW(._*5OFIoAt+_.m&/@+9KbD(YF]!Umg`jAluC6\<m"rkHr;IggEb8]'8^(fG!mKc>h2r@-l^K2H@>$ZPs)JGV5$(9T&Y=%X\&;j&puPgu>!4.B^]]FSs_Dg#"BBC%sWSH+oWg$(E,IT?^B2aSjh:G.LC41BgV/Ib1fR9dPW`#n;G4nZYLnU%_Rn?4o;=i;2qpqR?dgqYA3(68sdMfR:.s&T&EETe\rj:`%K3jI1s'qf('-E"'!s_AU+RRAGHaTU$(]"<e#0lJuBu]/?SN4&JD?>4]YC+nJ=X22X7Fnj?eNBRHWI"/r;-=nZPfW3#.%4C,5i!LEWaa-r8cg54[mN57;U5G"9TR"&W2!#Vc?G:;n6eQVp"!0]9bc*(9<WFHM1rbC5L-T9]99D"6dFf8U](B.AGl:ngJid6Np=^#jIP]]Y!J\SZ=-Ved8_X?aQo8s^%IGM`?9fq>`K1dG.g[DaYDn?t%Q:A>&m-g5?:US!!gB'Lf!Z[u$!)Jl1&?j6<^8X+L2c1JoH-tX6@)\c7=H&"6;mWK2Z)V&uVl*&V.eUoIn`i3UQ=d=WPaD."@bE&%MtHO5R2D`>0Sh07n,EMj''cp,o]bmV8SC5R/Sj/CFBP[D-b^k/5E%:3`;tp#X$27n/b]dQ9WLCJ19JAhb-\7R+&0I4rA$Bs3DUI.AB)'k^K%0$'b/%)5dVifj-#VOO5T*LjiORTV[(dL5F([hTYmRD2*4pTA)l?9[FLZClR!>r*jfgRqRJi(E&806pg"sHXdkA[pWdJ7dR;Iud>bKhIJ5rZWXZW/9i]B;<Fr-S0ob?WZ/lI#0%J;A7LDJo4Kqp?$)55bl2j3>8(BBk_?@L`0Ac5a6p`T"/F*hYh.7*fkd[<.D0q6"-/r`DoPsl-h@2;+E4itVfa7d6G(LQ>2=KeF@Iu2o)Tot~>endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1410
|
||||
>>
|
||||
stream
|
||||
Gatm;>Bed\&:WeDbW_<S'ZC]NZ.%0W1aR"MUe/Ah^>teI%TX2U@oM-tVS@RM2e,1)1oK,FBtf_l)inS8$4aeLqAi?45)+$$8qA`'Dh5L<Kl@%jYk>=k%FSb)$WoZfk6?SD?@[>M7R^La70<gn5e0iX4CpdW>`MVcnT<epgd1fo019iI_1QtpH4/\">G9R#0[%r;h^!r1.$huNX<<Fq6HtfAM$Y"&]2/=bG]\!(WP>^`#Dge@KZKB1/Ft'gbbD?jice!<4Ff?8`Aj8/1)>ttW^N)5k7>KTcm)WeIa0I.2nZGeo")`+oTs%F.ED>6F6ug>c%$B9f8TB13#G6!PHcqkai[5OWQ=d;KYO^paOM&/kNQX4*p-*4LkSB.,f]F"W?1"fbb?Mf@$q:f#`,6*Jn]<3WV@dF_fC-3,a3r'#b4E4"1n)jW*(*W2*M9OUW`f]peBM2a*K?Cde0/K^jlPnq3&1!W94!(%6WWka+Y"5:^FQoBT^)OHG0b_Y=?Y*)G3!98k9o&:ZQ!lPRj-A$k5^PGRUCGg0o(JdKZ<JVME?A*d'(,;UU&Z5s0R%JHcTmS6>d/9GA(ni:C@=;*q6X/UEe&0r%Bg`2>K\qrmD$k6B@30M@i%U<HWI[abE66eO_cdhF$>2SVQ?ki+#I:#'EAqLec=\;.N/7Tl>Mk&#WK1#Lt^N!)X:jFPX`IFJ]#2lR[s(K!!@rR;6p(H)4OR/*5i(4!<!BhM,MID>]%*;9-rnm=r2Iu]df&mt/jBb<RkGlXFBLS%e<1RC!I9NMH=8!piO^hM(UCX^Q9f0DI5$tbHQ!(U)J\kN3cX8io46\$C5kW-ZYEi44[mmMS*H!*c,I4]!"CWB%]4]mi><4PqI0_\%][B4+]ci)=(bgU,S'P4+u7$W[!0(*%D_Dq-n@?'\A2JUkTg_:Mq,YL42m8Q>8:9(sWC+4i(.\pp3`S\)2+#C.h)6V`"Ju,rT.sm+@2$i5)(>!I2r12.HJ]P?]=-Y!RM;OPBK(?d%'6jYROp0mLrS641Q1(8>U8OX%k\=M(^VC10pg[%:AWbk`2dnB/F"RgUcq*?$9\S>Be2Cb"Dq_QG)slnn3kkO%4srOo@H4C,JWC%Nj7T'S<;!\*h:m*qS_VcLFuk/Qe4*J&]8pd_VBSE0!]Y_ZiPi>3]s"7>JI>`H0O.#6C1#/P(aleLGgNL/>KhiU5@=9)IbdGBJ2`[>^?ip\#=EQR\$$T\hi<Z/;c1C04>R4N@jg)cj$PBZ<q#C@U_<]-X-'J)%G_cJeXYF$2Ya#\,qH$5^]4:)A?>GSQoIg\Jq%(uB0@ZRB;<Kn%cojVC<Q[KCJMhZo^Q)83>^/WGguaM7e9c)'9uc/+K$9#4L_f6U(<"r7Lb>oc0MB5eY]mIFLd=8.IaK9\;t7PF#%T"l[O))9^;~>endstream
|
||||
endobj
|
||||
31 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1255
|
||||
>>
|
||||
stream
|
||||
Gau0CCNJ2h'`H=\\EtZtfa,c>PKD%32OhgC->=Tnh:_-K[S_j4O^)%NA39bY/&/kZYddtj);CgbHdBAd7h6#s\o;g'B)ot>kkp/9lS,sRCa'OII5i+Q^:%uhC$Wk6F:X"63='u?Vu!B)Ut`Ij>(@#_1HZ\>B;?U\PEtcr.q+/g'g`-g$^'<Cd/HR,"@a13o@OTOKYWhm0o6/<he-moSg8VQhSJ[^L@8&#T@L=UP&=5B@86^9:k.%>kp7TkB3DhaUk//J;"!7Bf,Zh\M=dRaQ]s=WU$5PoY*YFWlHeeF(:)l'KfiR\>SHBu"d?cRRsn)`6E]SX\d*i&nbXkTe,7EP56JPL4X3U\`A=04;XeI"PeC#0*&OY'D7f\J[[pRtKIs.\CdseFKeKAH7&1,3e#ECo=T`K:Yqgdg2"l+6cp%,Rq7_A1C'HW_dN/rK.@VSoK3:%l^>$-.A_4L-/^V*j.pC*jD)DAfas/n/0dT6'R)$sh+&_Ycc<s89&Ua0JkmK%uYQ]o/kXTd9h$1j8`[d2$e12eM)[5S=_dMQC:_<347cE@fO5]<fk.#p7@5PL)l5fU'-R2W]UO+HMUtb,rnG2*1/GK;R,q@T+6D!;2(1=J,hSnTqkksMf,P(d0I;k&Tg0HDUZ$q;mfZX)Nf@`th"gK1ooEgP.o-jef$1I+88BIdnTu[cNrg47Ji$2A[!b_WC66eMl5(e)$6o5%WS=A4G&r8Q=.Lp$A9"g>A?BH''TsF09h&eo+<G+pe-8dLI^*R^Y#a[>s(f;UV##MP92mI:#=%%]&I#)>(',!)1o9CT`RG0!!HTro53fCP1n-q]MHq?M4jbN;D-PWiBDHn+:*\QQ>OY,X/+bA)"7RW-V8s:DiP&-$0VW'Nb5;aNG7ItT>ncjP8.DMj5,#;?Jl[G,:C7;X!$Ktj^d;Rn_2AfpugUABN>S4m:Qm=AH`sOHR%E*4U^$4K2<YIu/%l2,QTkmDo2TR%IXqjDVHo>]3%/n_"8D(>YY!0;fKA%_57%#=[c4:4dEOB*K[UO)[V"-gl>tCbf1[O'YPHT%j8BTU\_jmUaOY:pIni6<0FQ#!%q<RL,jnFb/=='2Uq-PSMjWb[$Y`if<^XHl4,uL)7q(*ZJ733sS($Cg/8#mfB32S+kjPP*r=`u5,'5:Q#j=(VnI3gNof@h$`auEl9T?)tLkBu;k/kD8C<KGd%RVb+O*^/_c6W9i%[Sp&FCpnqmp9'-]WjWdIL5RTa_-6gS_4Ut6If\uB>k7~>endstream
|
||||
endobj
|
||||
32 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1242
|
||||
>>
|
||||
stream
|
||||
Gb!;c>BAd^'Ro4HSFU4=XK[)iOLEW[9Y4j_S\9^0Qh=*DMZrXj87#N)@1rQdO(92'&d"c>'Gpt9cLj7hr^=TB2`9LQ)8P>>\pmVN%ZD`"4lX<A$N:mM^:.d`1N5n3GPW#"!@>)poMH*b$+9:6Ku[[eD4s_92M6m"BYjcg(_`T$?sP:KJP1BH*2a#r;iIXVDKoKHDt;uDJdY[ORD\\[@p`BT4#$L8UGM;j#CJc!SJsK"gN(-Z/l5mo2R-0IaDV$oqS_t/'2s]I\s.5+`/U;-J9,*<1#Ws$<gnF'<pj$^[/k'Ihe=J-ROJ^CU$_R4nbDWk$T%IN7J_Sk$U&_s@`eke$2)=HI1i7krkNdhQY4>-FuoQ88VgRt`.e3)HmH$ljpLOMW8N1@9QE.G2X/dkm0aT1Nd%i0]hHmrGaGBtMRC[s?+%n$d1@uur6>SQ@O];NR>\<^RW>6i'Tj,Q#4NZK,ZQ^(^Qa@WrqrVjr>XO1]I:%bLTqPIG"I<DJ5#8k#G1*\A1REifFO:HFaUfcC_&qS'3T1IX;uEnPU`o5`X:hQc9[oK@!$$YkAUkcO*Q`m`]7[ha8[jD50)#P1$0ZT`,ktmQ>2YYcG_E$aTNg2`#j+u<i8HbS<Pi&_?JMPRca`9J%:*[/lgFCP6Q8Vd$lsu=0FmbE]K('.u=L-W#XiB$$(7j0i-7CE<"h4cG-,r&gPt!I(3>'A'/rkS>[-Dedj9cf2f^S.^#J)/1j"iQU.1GiSMGM:7?t7jL(!r&Qt`,s/e=3L_8GPniB#lZU(mW9Q*!(WLld#`2\[8G(O'.E1Y&Fe2UGIC]ZarY-]9RpBL,]c8[QYi+IF,Xc@(O9d3rtO0?Y&M0l$"i^oWP9Gos=&C5>+i&U[pU^!6<)obeBfX[/$V@o\=RN5+Jc;T[QirdD:5QG@"MhL?FM9L6#'l_?_jFG&4hPoaN:t!P%jG[_fb(2:0Z(g/c^Ge"A:.?VgAc*usA6IL6:*FVi4+]JI-<[$lHKIcchCdq-3l_lRR`73U@*P3c'$$^*Zc<!L`'qLr._><<1SRs-\?/.%^Jef7h!SR=0qY[qqW'*$X$n*6qjnTMh5S`G-;WHCLfAdE=c`'S/n$)te0\UH#K+_eq:RBH5H&>4onZ?@T!(GbY-@SFA\ET1Hcf0tq4#]@^'):)ZhLDq7S8Ms'sXm'g<deh@7,]-J^a4<+Khk"#Kt3=YB?7iNGkCa>i3H,)>JG4o0`.c4dX$.$X3Q3jt"oC~>endstream
|
||||
endobj
|
||||
33 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 675
|
||||
>>
|
||||
stream
|
||||
Gatm8?#uH"'Rf.GSB:C(drAfI7@;XiUoBp/fT0"/:HX<q)QBrE^c3\$J+)Jdj$u/-9p+t_m_.eT]6XsB8KV8e%o_C1JC3E8(4n9N_]/f-HjI)d)K.B:Mn-R`dt@$Cm^k[5d5LTDca8*?`buqV-Eu!en!Y9C_-tQ*22/J?hMrtN7qLVq7j`4qY$D\r;;tfGga5K>Kp%eMZf.6=))mBY5Dn,G&=c*X$c1.RKXZ(@`HN.G;4`8t;V=3;[`=:O_7]0*ebeg8E"_P0m"r-.Tmor6'(]7hjEk%LIbJ/;HDggH)U1W4eB=2_Kl.!K;#_?U-\ps,C5SuqFfUNR*j%0=jLG)+A!@A@X6oc.q??IQK?73[F&g;g3m3u;)TI9m\bTCNJR8Bq[_gVlH%h`JoNs`.0PnFJgoAcdM0+&L8`j-UgtpHDSt]s_M-<]NI""CYqPD,TTnYue&a)uKRZc()RT_ks=)-A=D?+gl50dA%EH.AdE#BIZ7LHJD'G["V!`CKVA9K=V]Uu%lR?FSChX"Y<`k*]ji$S%6lQQk?DmGh&abAK>NU8E`MiK4361;u:*JBa/_0=mXV:qPP3m1Q#;3YLnQq'B8>jaH*7HBu,jD+RJ1m^L'\l6HsfBP<sbUspa+tL%DCMT4#OddS_/KEJ;+`*2GO*GNn&e$U1^B$$T<(m~>endstream
|
||||
endobj
|
||||
34 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1715
|
||||
>>
|
||||
stream
|
||||
Gau0D>BAd^'Ro4HS2/i%-O!qJRpjKXRr,W%B.msdpPFYB>C[A3GqGG;1B-`*Oq"Z>,\5%%L^%'EQga-cqMYS)^+B6?>6%oeT^fi"bD+CYX;5NdkTg!hWdLjLq94jn3^()f6+:>9X4QUp3kS0<'>IYR43"T6g9e;+d-q*BfO/j/$V*32Y,!"&PB:5LgKh>M@5sKn!NsTi<sZso?uuLGEtuf(pj+K[VGi;JjKbUe>&E@lk@Q0`LZ8QuT?)4&"mo9EW`L#Y?UUD]-:MDX+d(bJT=-Ij!+3Nn8snZ%9-aVb5cu:<B^q,&\!6QXc_fC$MqC'Ra)(HWGPq?!$>IC`L%IE<f4i(hLtFM^46kXBg;Lg=cn[Zo;n+g=N_;>h*D7X-b`HR+ml@%I-\?pd#)LB:SWq:>#S<k8_HPQr[_X)?BoL,4Iq^;f7!1RuYL*Vm:q_g5,[c-<,.u@UfA?R@RAKXdi8)-5_q<oDHU%6Y5(eDEC:o6H%t$pk0'KtYbMu:;XfFRo!Er\pB!2CNNBKtWS^AA:rZEe]*ubaDB%piPr1"4FYhZ<"XN*eMm3Vk>M19o=*jSff]F]GKZb)l_C.pur#"o;]<S`D.hLl2rI2YJs'BW48]He7TY"(T^P/c&-q/Lij3>>`0'>_Qh2mMF@h1Ukb,nBPC1^QO:bj(BGTlnO8X2d4L%mmEc3`rTE0(@u8&(e#9%n0$=]S87s`X.g\i\h814kq;L#1HS#6KK5alt%S.l:s`".p=[Y=cJ96pG+<pD=#P2Z,\/W<2=89(Qf]0rQcTE$PD$(eiblH&*)p2be#XY/5kb^8Gcu(-oT`S:dCFkae]r*<T'NuAXd4\#ng0$nL/R]+d,8gYsHbg?A"m"`=1E<R,bM3LKqet-8Bk!-Q]H;oe[U&2XF`#?eFM3'5Z"*?/@%lrGMuWb3dLJ/C/a<^NBk8Kc;I?q&W6P/Cj8X<n,3S=Ns0Hhr8qT%R.FD:,F7\ps7(fNP]K?ko>erE9r4&Yc,SQH22M0GKuX]\f,CR2"8uleCe)!$`D<YNHo)H;:01jPYs0IPShS2HM$I.AW1Gh53'!!)qu'u&3Qh2C,+s`@T$5$"-6IRqGb$l#a%1VR/7M9Bqp"NcJijXk+df3-R*)a`5%PLV:ij+qYSHp,RJkReI+tm+;%`h#Z)St+/(s%DYlLE:s1a-.F;DuK`X*sIa/XR8-#ep1%.\Bprq\.;h5QgBNo[u+CRm=2]j!j%6"V4MUY&S<CUlY]%=?")%<,Bk1oDmp%$;VW*29#d@@crksQV9E:l@a*QM]=KK?Z$G2+L_k''gON>o]:("+>/::S((_k1";4qf+"JB^mk4c%u=#.`hB-$g)*9qOEEJghIDK-fKc=Fi=8.MWVWN==M-D&'dJaCqb"fo>XK&Y$.ET<']$Cp1+*.-P1>f[nGmPQ`iM9<=467ofr7hKkaV?<8a>O("?*ENpGY#Sf8*hR_`'!l%V!oJlA_\"e'^)]#7^$`O!^#XkbXo4TePT3j7,lKLX+SM+BrSn8S4I>SA;d(i05ShN9W0T(@4)tk*hC*`OMV[P;P`(>Iii5ZRIQR)0p7PlJq-?8T`7L6;jI[<?;GIES'`3e6(c"tBiN'%'%j/^@2aQfD]K#]BLXrG8Y'"JZWd":;P7EZl%dV@I0AYYKp>(n9@mb$>r)b<n)[#J>G<.A,TAe2/Sg8*D>iOoE%Rgs-o(t\Ib#Zhb>r!/'jYG)~>endstream
|
||||
endobj
|
||||
35 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 736
|
||||
>>
|
||||
stream
|
||||
Gau`P?#SFN'Re<23:0XJ;%r+S9RC1t'iNZV,MH(&;IUbSEMsmk/&ZqpFiYDr/(1e[J\fruch6gNcJ`a+bFAOZ%-XVQ%QLtVF:@Rn+6b)s;sVQbmE3de@H&UR5nYoDKF-W$0oM7JnIk3_[hfefn8,?TFJ@F.I]k)a`*$oF*sX>D(c_JY5l]?FU!,o]DcuYP6TP>D%'T*76Xkj,mLU'2Y#c:hialGq)S(<^,E*FZ<0rlg`Hdp*8j"\YY'[\7"<FQtcW*0X=OUfgW5Wm8bYGB'62CSWVm+R%^Ud8gF\TZW^`&i-5NcV6B)-Nc1TqiA[tom>YA)dA*>Y&K(>6fbi,Xe#4u-N?V*r!n&m+(h^(krhGm/QAQ-AS0KMn*#iV9De-NCB7Koj>WRq(_EY8=bThsms8Yh@(m2[";SlYY8_s'5<aDg25rn1.IZ_e-E:CcSo$4&D*bnmt609aU$h3no#e@/k?3Ishb?O_SeQ!PMQPoL?Ad*4euI(4(h\6SLGRl7pH:OWHLa2p1Fr.,^(H\42,EQ`1i#k[>qT]i0%3KqPl-(2>9tLL)ellVMTA9'@]Zf8[u]1Q,/k?9ID].ZX3^[k?0DRl`0=9o.S<e)!t1_Y.6+cQ""Q;2D,u.5[dDL>R'/Tfi3%AsI[rU`FHaR-44S(-<K(B&oIBjA0\MP3I)1'6bSCV98U53mW&A9;^j>cdqaLDf1$>]YLFX1B_%.Js^e"k8nVU_>4K!npIM~>endstream
|
||||
endobj
|
||||
36 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1081
|
||||
>>
|
||||
stream
|
||||
GatU2hf#8L&:Vr41"RfHe5FMO&GgFq8\*YLmG;`5G$c`$S1k*o?tTqEHhZ.LfT:cnM43]r!l9,qD`mX9?j7YmbaoUc^n3&\s2BNaO?N[Qh%8!rR&6XN,M(0\qsHH6'*`Fj].=gIA4M\dJ*b3a"E#p`K#@mQ$-WWr4sEZYTLgZ.nA2RJk'REZVcM<nW)k&`>seD"@Jia<0igLUZ8.JY(qdMqAG*F5ChsnGZ)iGY1X-_)Es(#%NP%PZo3=frF4EILI+t(1M[%HH0X;rYS1HEhQC/WQdgh#J>g$g@D^RVcchRnZ-1k!A0M_*+.t`9O'fOY08a36,"mR$ge#^RRY`<(n?qOHdDA;@FQ5OM'dZ>Vg2!=\F)#$@SMY!"J^5!n;$`;L7_"p4^>^TJ;GBV=]]RZ8Hlh:D/mCX(VTpb]cip!(k4Y;(7Q?+UA7S,P8b/Jt)*R3?bZfshl:tDWt.2jmUf6kkZKLa&ke^3eGQB*-EO4XX,"f</:Z:DU83\WBT7!:c5"(jm[U&iBH9n&cc,[hi*!9^ZIX1gQR">Y3B8=R>',/3h'O9uH2OG=XUbXs$[-JrEhRGm#H\NWG$^8=W0VTQ'M'k@tChHrV*?j3LZq@XPI/li3#9_l=hQtE'iqX"Iu`h#8K][/KcitI%7DSlEj=4:N9k0Su41I[nMm9L8s#36!tnNhA.B6:%%pr5shE=s9\F^O*u$no4&6/g*T$;t/dPk9IEE\A,*N*9-b.(IJL\bQ\G=%SmOMAcVW!2kDsaEjmB,-&h0eI4`U?glocMqX!%?5-Qo2n!?:OmLM0-?VmB]2l[V\a&Lf5^[;T?A0XK[#1hZrc'R?9^)aYdrNT:SZ4R?^P;_M3e^IW='R&jCfm'+n>35p=<];\eND+P.%3s!r/>H=Tc;CkpS8T'8[=h)rX4(V`P?699#eE,-#QOtS%<l4W&!gF,hB`Dn@%4S,Skn[VVbA"m\9S"E^o*VMF,p);&gGaS3%'`%$/+EP7T%qb,]ljPl@QgdPgUtiZTLUXuH(66UV^,2=5Af-Dt>Dn8[MYn2uk\hnmGDU6(sH&5eia'Coot<l/YUU]1>?iF>7~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 37
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000122 00000 n
|
||||
0000000229 00000 n
|
||||
0000000341 00000 n
|
||||
0000000446 00000 n
|
||||
0000000523 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
|
||||
0000003754 00000 n
|
||||
0000003906 00000 n
|
||||
0000004776 00000 n
|
||||
0000005897 00000 n
|
||||
0000007335 00000 n
|
||||
0000009043 00000 n
|
||||
0000009971 00000 n
|
||||
0000011197 00000 n
|
||||
0000013000 00000 n
|
||||
0000014502 00000 n
|
||||
0000015849 00000 n
|
||||
0000017183 00000 n
|
||||
0000017949 00000 n
|
||||
0000019756 00000 n
|
||||
0000020583 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<8a6c7f848f6a2a1be56036b86245afb8><8a6c7f848f6a2a1be56036b86245afb8>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 21 0 R
|
||||
/Root 20 0 R
|
||||
/Size 37
|
||||
>>
|
||||
startxref
|
||||
21756
|
||||
%%EOF
|
||||
946
Les07-Supabase/Les07-Live-Coding-Guide.md
Normal file
946
Les07-Supabase/Les07-Live-Coding-Guide.md
Normal file
@@ -0,0 +1,946 @@
|
||||
# Les 7 — Live Coding Guide
|
||||
## Van In-Memory naar Supabase
|
||||
|
||||
> **Jouw spiekbriefje.** Dit bestand staat op je privéscherm. Op de beamer draait Cursor.
|
||||
> Volg stap voor stap. Typ exact wat hier staat. Leg uit met de "Vertel:" blokken.
|
||||
|
||||
---
|
||||
|
||||
## Planning
|
||||
|
||||
| Blok | Tijd | Onderwerp |
|
||||
|------|------|-----------|
|
||||
| Deel 1 | 09:00 – 09:30 | Poll-app afmaken (stemmen werkend) |
|
||||
| Deel 2 | 09:30 – 10:15 | Supabase introductie — No Code |
|
||||
| Pauze | 10:15 – 10:30 | — |
|
||||
| Deel 3 | 10:30 – 11:30 | Supabase koppelen aan Next.js |
|
||||
| Afsluiting | 11:30 – 12:00 | Testen, Q&A, huiswerk |
|
||||
|
||||
---
|
||||
|
||||
# DEEL 1: Poll-app afmaken (09:00 – 09:30)
|
||||
|
||||
> **Vertel:** "Vorige les hebben we de QuickPoll app gebouwd, maar het stemmen werkt nog niet echt. De POST route logt alleen 'hello from server'. Vandaag maken we dat eerst werkend, en daarna koppelen we alles aan een echte database: Supabase."
|
||||
|
||||
---
|
||||
|
||||
## Stap 1.1 — `votePoll()` functie toevoegen aan `data.ts`
|
||||
|
||||
> **Vertel:** "We hebben `getPolls()` en `getPollById()` maar nog geen functie om een stem te verwerken. Die voegen we nu toe."
|
||||
|
||||
Open `lib/data.ts` en voeg onderaan toe:
|
||||
|
||||
```typescript
|
||||
export function votePoll(id: string, optionIndex: number): Poll | undefined {
|
||||
const poll = polls.find((p) => p.id === id);
|
||||
if (!poll) return undefined;
|
||||
|
||||
if (optionIndex < 0 || optionIndex >= poll.options.length) return undefined;
|
||||
|
||||
poll.votes[optionIndex]++;
|
||||
return poll;
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "Simpel: we zoeken de poll, checken of de index geldig is, en verhogen de votes. We returnen de poll zodat de API de updated data kan terugsturen."
|
||||
|
||||
---
|
||||
|
||||
## Stap 1.2 — POST route werkend maken
|
||||
|
||||
> **Vertel:** "Nu gaan we de POST route fixen. Die deed nog niks — alleen console.log. We moeten de body uitlezen, votePoll aanroepen, en een response terugsturen."
|
||||
|
||||
Open `app/api/polls/[id]/route.ts` en vervang de hele inhoud:
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { votePoll } from "@/lib/data";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: RouteParams) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const optionIndex = body.optionIndex;
|
||||
|
||||
const updatedPoll = votePoll(id, optionIndex);
|
||||
|
||||
if (!updatedPoll) {
|
||||
return NextResponse.json(
|
||||
{ error: "Poll niet gevonden of ongeldige optie" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(updatedPoll);
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "We lezen de body uit met `request.json()`, halen de `optionIndex` eruit, en roepen onze nieuwe `votePoll` functie aan. Als het mislukt sturen we een 400 error terug, anders de updated poll."
|
||||
|
||||
---
|
||||
|
||||
## Stap 1.3 — Server Component + VoteForm split
|
||||
|
||||
> **Vertel:** "Nu gaan we de detail pagina goed opzetten. Op dit moment is het hele bestand een Client Component met `'use client'`. Maar dat is niet hoe Next.js bedoeld is. De pagina zelf moet een Server Component zijn — die haalt de data op. En alleen het stukje dat interactief is (stemmen), dat wordt een apart Client Component. Dit is het belangrijkste patroon in Next.js."
|
||||
|
||||
### Stap 1.3a — VoteForm component aanmaken
|
||||
|
||||
Maak een nieuw bestand `components/VoteForm.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { Poll } from "@/types";
|
||||
import { PollItem } from "./PollItem";
|
||||
import { useState } from "react";
|
||||
|
||||
export function VoteForm({ poll: initialPoll }: { poll: Poll }) {
|
||||
const [poll, setPoll] = useState(initialPoll);
|
||||
|
||||
const onVote = async (option: string) => {
|
||||
// Zoek de INDEX van de option in de array
|
||||
const optionIndex = poll.options.indexOf(option);
|
||||
|
||||
const response = await fetch(`/api/polls/${poll.id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ optionIndex }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedPoll = await response.json();
|
||||
setPoll(updatedPoll);
|
||||
}
|
||||
};
|
||||
|
||||
return <PollItem poll={poll} onOptionClick={onVote} />;
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "Dit is het Client Component — het enige stuk dat `'use client'` nodig heeft. Het ontvangt de poll als prop, slaat die op in state, en handelt het stemmen af. Na het stemmen updaten we de state met de response van de API."
|
||||
|
||||
### Stap 1.3b — Page als Server Component
|
||||
|
||||
Open `app/poll/[id]/page.tsx` en vervang de **hele** inhoud:
|
||||
|
||||
```typescript
|
||||
import { getPollById } from "@/lib/data";
|
||||
import { VoteForm } from "@/components/VoteForm";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PollPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const poll = getPollById(id);
|
||||
|
||||
if (!poll) notFound();
|
||||
|
||||
return (
|
||||
<div className="w-full p-4">
|
||||
<h2 className="text-2xl font-bold mb-4">{poll.question}</h2>
|
||||
<VoteForm poll={poll} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "Kijk wat hier gebeurt: de pagina is een Server Component. Geen `'use client'`, geen `useState`, geen `useEffect`. De data wordt direct opgehaald met `getPollById()` — op de server. Dan geven we de poll als prop door aan de VoteForm. Dat is het patroon: **Server Component haalt data, Client Component doet interactie.**"
|
||||
>
|
||||
> **Vertel:** "En we gebruiken `notFound()` — als de poll niet bestaat, toont Next.js automatisch een 404 pagina. Dat hadden we vorige les geleerd."
|
||||
|
||||
---
|
||||
|
||||
## Stap 1.4 — GET route toevoegen
|
||||
|
||||
> **Vertel:** "We hebben een POST route, maar nog geen GET. Die hebben we nodig voor de VoteForm — na het stemmen fetcht die de updated poll via de API."
|
||||
|
||||
Open `app/api/polls/[id]/route.ts` en vervang met de complete versie met GET + POST:
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPollById, votePoll } from "@/lib/data";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: RouteParams) {
|
||||
const { id } = await params;
|
||||
const poll = getPollById(id);
|
||||
|
||||
if (!poll) {
|
||||
return NextResponse.json({ error: "Poll niet gevonden" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(poll);
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: RouteParams) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const optionIndex = body.optionIndex;
|
||||
|
||||
const updatedPoll = votePoll(id, optionIndex);
|
||||
|
||||
if (!updatedPoll) {
|
||||
return NextResponse.json(
|
||||
{ error: "Poll niet gevonden of ongeldige optie" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(updatedPoll);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 1.5 — Visuele feedback toevoegen aan PollItem
|
||||
|
||||
> **Vertel:** "Laten we het iets mooier maken. We tonen nu het aantal votes en een simpele progress bar per optie."
|
||||
|
||||
Open `components/PollItem.tsx` en vervang de inhoud:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { Poll } from "@/types"
|
||||
|
||||
type PollItemProps = {
|
||||
poll: Poll,
|
||||
onOptionClick?: (option: string) => void
|
||||
}
|
||||
|
||||
type PollItemOptionProps = {
|
||||
option: string
|
||||
votes: number
|
||||
percentage: number
|
||||
onClick?: (option: string) => void
|
||||
}
|
||||
|
||||
export const PollItemOption = ({ option, votes, percentage, onClick }: PollItemOptionProps) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(option)}
|
||||
className="relative my-2 p-3 border rounded cursor-pointer hover:bg-gray-50 overflow-hidden"
|
||||
>
|
||||
{/* Achtergrond bar */}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-blue-100 transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
{/* Tekst bovenop de bar */}
|
||||
<div className="relative flex justify-between">
|
||||
<span>{option}</span>
|
||||
<span className="text-gray-500">{votes} ({percentage}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PollItem = ({ poll, onOptionClick }: PollItemProps) => {
|
||||
const totalVotes = poll.votes.reduce((sum, v) => sum + v, 0);
|
||||
|
||||
return (
|
||||
<section className="w-full my-6">
|
||||
<h2 className="text-xl font-bold mb-2">{poll.question}</h2>
|
||||
<p className="text-sm text-gray-500 mb-3">{totalVotes} stemmen</p>
|
||||
{poll.options.map((option, index) => {
|
||||
const votes = poll.votes[index];
|
||||
const percentage = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
|
||||
return (
|
||||
<PollItemOption
|
||||
key={option}
|
||||
option={option}
|
||||
votes={votes}
|
||||
percentage={percentage}
|
||||
onClick={onOptionClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "Nu tonen we bij elke optie het aantal stemmen, een percentage, en een visuele balk. De balk groeit mee met het percentage. We gebruiken `transition-all` zodat het soepel animeert."
|
||||
|
||||
---
|
||||
|
||||
## ✅ Check: Deel 1
|
||||
|
||||
> **Check:** Start de dev server (`npm run dev`), ga naar `http://localhost:3000`, klik op een poll, stem op een optie. Je ziet nu:
|
||||
> - De stem wordt verwerkt
|
||||
> - De votes updaten direct in de UI
|
||||
> - De percentage bars verschijnen
|
||||
>
|
||||
> **Vertel:** "Dit werkt nu, maar er is een probleem: als je de pagina refresht, zijn je stemmen nog steeds bewaard — maar als je de server herstart, is alles weg. Dat is omdat onze data in het geheugen staat. Vandaag gaan we dat oplossen met een echte database."
|
||||
|
||||
---
|
||||
|
||||
# DEEL 2: Introductie Supabase — No Code (09:30 – 10:15)
|
||||
|
||||
> **Vertel:** "Nu gaan we het over een echte database hebben. We gaan Supabase gebruiken. Eerst zonder code — puur via de website."
|
||||
|
||||
---
|
||||
|
||||
## Stap 2.1 — Wat is Supabase?
|
||||
|
||||
> **Vertel:** "Supabase is een open-source alternatief voor Firebase. Het geeft je een hele backend out of the box:"
|
||||
>
|
||||
> - **PostgreSQL database** — een echte, professionele SQL database
|
||||
> - **Authenticatie** — login, registratie, OAuth (Google, GitHub, etc.)
|
||||
> - **Storage** — bestanden uploaden (afbeeldingen, PDF's, etc.)
|
||||
> - **Realtime** — live updates als data verandert
|
||||
> - **Edge Functions** — serverless functies
|
||||
>
|
||||
> **Vertel:** "Het grote verschil met Firebase: Supabase gebruikt PostgreSQL. Dat is een open-source database die al 30+ jaar bestaat. Als je Supabase leert, leer je ook SQL — en dat is een skill die je overal kunt gebruiken."
|
||||
>
|
||||
> **Vertel:** "En het mooie: er is een gratis tier waarmee je prima kunt werken voor kleine projecten en leren."
|
||||
|
||||
---
|
||||
|
||||
## Stap 2.2 — Supabase project aanmaken
|
||||
|
||||
> **Vertel:** "We gaan nu samen een project aanmaken. Open je browser en ga naar supabase.com."
|
||||
|
||||
### Stappen op het scherm:
|
||||
1. Ga naar **supabase.com** → klik **Start your project** (of **Sign In** als je al een account hebt)
|
||||
2. Log in met **GitHub** (makkelijkst)
|
||||
3. Klik **New Project**
|
||||
4. Vul in:
|
||||
- **Organization**: kies je org (of maak er een aan)
|
||||
- **Project name**: `quickpoll`
|
||||
- **Database Password**: genereer een sterk wachtwoord (sla het op!)
|
||||
- **Region**: `West EU (Frankfurt)` — dichtste bij Nederland
|
||||
5. Klik **Create new project**
|
||||
6. Wacht ~30 seconden tot het project klaar is
|
||||
|
||||
> **Vertel:** "Je ziet nu het dashboard. Hier vind je alles: je database, je API keys, de Table Editor, SQL Editor. Laten we beginnen met tabellen maken."
|
||||
|
||||
---
|
||||
|
||||
## Stap 2.3 — `polls` tabel aanmaken via Table Editor
|
||||
|
||||
> **Vertel:** "We gaan nu onze database structuur opzetten. In onze code hadden we één array met Poll objecten. In een database splitsen we dat op in twee tabellen: polls en options. Dat heet normalisatie — elke tabel heeft zijn eigen verantwoordelijkheid."
|
||||
|
||||
### Stappen op het scherm:
|
||||
1. Klik **Table Editor** in het linkermenu
|
||||
2. Klik **Create a new table**
|
||||
3. Vul in:
|
||||
- **Name**: `polls`
|
||||
- **Enable Row Level Security (RLS)**: laat aan staan (maar we gaan er zo een policy voor maken)
|
||||
4. Kolommen:
|
||||
- `id` → staat er al (uuid, primary key) ✅
|
||||
- `created_at` → staat er al (timestamptz, default `now()`) ✅
|
||||
- Klik **Add column**:
|
||||
- **Name**: `question`
|
||||
- **Type**: `text`
|
||||
- Vink **Is Nullable** UIT (niet null)
|
||||
5. Klik **Save**
|
||||
|
||||
> **Vertel:** "We hebben nu een polls tabel met drie kolommen: een automatisch gegenereerd id (uuid — dat is een uniek ID), een question (tekst), en created_at (wanneer de poll is aangemaakt). Supabase maakt id en created_at automatisch voor je."
|
||||
|
||||
---
|
||||
|
||||
## Stap 2.4 — `options` tabel aanmaken
|
||||
|
||||
> **Vertel:** "Nu de options tabel. Elke poll heeft meerdere opties. In plaats van een array in één kolom, maken we een aparte tabel met een verwijzing (foreign key) naar de poll."
|
||||
|
||||
### Stappen op het scherm:
|
||||
1. Klik **New Table**
|
||||
2. Vul in:
|
||||
- **Name**: `options`
|
||||
- **Enable RLS**: aan
|
||||
3. Kolommen (naast id en created_at):
|
||||
- Klik **Add column**:
|
||||
- **Name**: `poll_id`
|
||||
- **Type**: `uuid`
|
||||
- **Is Nullable**: UIT
|
||||
- Klik **Add column**:
|
||||
- **Name**: `text`
|
||||
- **Type**: `text`
|
||||
- **Is Nullable**: UIT
|
||||
- Klik **Add column**:
|
||||
- **Name**: `votes`
|
||||
- **Type**: `int8`
|
||||
- **Default value**: `0`
|
||||
4. Nu de foreign key: klik op het **link-icoontje** naast `poll_id`
|
||||
- **Foreign table**: `polls`
|
||||
- **Foreign column**: `id`
|
||||
- **On delete**: `CASCADE` (als een poll wordt verwijderd, verdwijnen de opties ook)
|
||||
5. Klik **Save**
|
||||
|
||||
> **Vertel:** "CASCADE betekent: als je een poll verwijdert, worden automatisch alle opties van die poll ook verwijderd. Dat voorkomt 'wees-data' — opties die naar een poll verwijzen die niet meer bestaat."
|
||||
|
||||
---
|
||||
|
||||
## Stap 2.5 — RLS policies instellen
|
||||
|
||||
> **Vertel:** "Supabase heeft Row Level Security — dat betekent dat standaard NIEMAND je data kan lezen of schrijven via de API. We moeten expliciet toestemming geven. Voor onze app willen we dat iedereen polls kan lezen en stemmen."
|
||||
|
||||
### Stappen op het scherm:
|
||||
1. Ga naar **Authentication** → **Policies** (of via Table Editor → polls → **RLS Policies**)
|
||||
2. Bij de `polls` tabel, klik **New Policy**
|
||||
- **Policy name**: `Allow public read`
|
||||
- **Allowed operation**: `SELECT`
|
||||
- **Target roles**: `anon`
|
||||
- **USING expression**: `true`
|
||||
- Klik **Save**
|
||||
3. Bij de `options` tabel, maak twee policies:
|
||||
- **Policy 1 — Lezen**:
|
||||
- **Name**: `Allow public read`
|
||||
- **Operation**: `SELECT`
|
||||
- **Target roles**: `anon`
|
||||
- **USING**: `true`
|
||||
- **Policy 2 — Updaten (stemmen)**:
|
||||
- **Name**: `Allow public vote`
|
||||
- **Operation**: `UPDATE`
|
||||
- **Target roles**: `anon`
|
||||
- **USING**: `true`
|
||||
- **WITH CHECK**: `true`
|
||||
|
||||
> **Vertel:** "We geven de `anon` rol (dat zijn niet-ingelogde gebruikers) toestemming om polls en opties te lezen, en om opties te updaten (voor het stemmen). In een productie-app zou je dit veel strakker instellen, maar voor ons leerproject is dit prima."
|
||||
|
||||
---
|
||||
|
||||
## Stap 2.6 — Testdata toevoegen
|
||||
|
||||
> **Vertel:** "Laten we onze twee polls toevoegen, dezelfde als in onze code."
|
||||
|
||||
### In de Table Editor → `polls`:
|
||||
1. Klik **Insert row**
|
||||
2. Vul in: `question`: `Ik ben een vraag` (id en created_at worden automatisch ingevuld)
|
||||
3. Klik **Save**
|
||||
4. Voeg nog een rij toe: `question`: `Ik ben een vraag 2`
|
||||
5. **Kopieer de id's** van beide polls — die heb je nodig voor de options!
|
||||
|
||||
### In de Table Editor → `options`:
|
||||
1. Voeg rijen toe voor poll 1 (gebruik het id van de eerste poll als `poll_id`):
|
||||
- `poll_id`: *[id van poll 1]*, `text`: `optie 1`, `votes`: `1`
|
||||
- `poll_id`: *[id van poll 1]*, `text`: `optie 2`, `votes`: `1`
|
||||
- `poll_id`: *[id van poll 1]*, `text`: `optie 3`, `votes`: `1`
|
||||
- `poll_id`: *[id van poll 1]*, `text`: `optie 4`, `votes`: `1`
|
||||
2. Doe hetzelfde voor poll 2
|
||||
|
||||
> **Check:** Je hebt nu 2 rijen in `polls` en 8 rijen in `options`.
|
||||
|
||||
---
|
||||
|
||||
## Stap 2.7 — SQL Editor verkennen
|
||||
|
||||
> **Vertel:** "Supabase heeft ook een SQL Editor. Hier kun je direct SQL queries uitvoeren. Laten we eens kijken wat Supabase onder water doet."
|
||||
|
||||
### In de SQL Editor, typ en run:
|
||||
|
||||
```sql
|
||||
SELECT * FROM polls;
|
||||
```
|
||||
|
||||
> **Vertel:** "Dit haalt alle polls op. Simpel toch? SQL is de taal waarmee je met databases praat."
|
||||
|
||||
```sql
|
||||
SELECT * FROM options WHERE poll_id = '[plak hier een poll id]';
|
||||
```
|
||||
|
||||
> **Vertel:** "Dit haalt alleen de opties op van één specifieke poll. De WHERE clausule filtert."
|
||||
|
||||
```sql
|
||||
SELECT polls.question, options.text, options.votes
|
||||
FROM polls
|
||||
JOIN options ON options.poll_id = polls.id;
|
||||
```
|
||||
|
||||
> **Vertel:** "Dit is een JOIN — we combineren data uit twee tabellen. Je ziet de vraag samen met elke optie en het aantal stemmen. Dit is de kracht van een relationele database."
|
||||
|
||||
---
|
||||
|
||||
## ☕ PAUZE (10:15 – 10:30)
|
||||
|
||||
> **Vertel:** "Na de pauze gaan we Supabase koppelen aan ons Next.js project. Dan halen we de data niet meer uit een array, maar uit de echte database."
|
||||
|
||||
---
|
||||
|
||||
# DEEL 3: Supabase koppelen aan Next.js (10:30 – 11:30)
|
||||
|
||||
---
|
||||
|
||||
## Stap 3.1 — Supabase client library installeren
|
||||
|
||||
> **Vertel:** "We gaan nu onze Next.js app koppelen aan Supabase. Eerst installeren we de Supabase JavaScript library."
|
||||
|
||||
In de terminal:
|
||||
|
||||
```bash
|
||||
npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 3.2 — Environment variables instellen
|
||||
|
||||
> **Vertel:** "We hebben twee dingen nodig van Supabase: de project URL en de anon key. Die vind je in je dashboard onder Settings → API."
|
||||
|
||||
### Stappen op het scherm:
|
||||
1. Ga naar **Settings** → **API** in het Supabase dashboard
|
||||
2. Kopieer de **Project URL** en de **anon public key**
|
||||
|
||||
Maak een nieuw bestand `.env.local` in de root van je project:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://jouw-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
> **Vertel:** "We gebruiken `NEXT_PUBLIC_` als prefix zodat deze variabelen ook in de browser beschikbaar zijn. Normaal gesproken wil je secrets NIET public maken, maar de anon key is ontworpen om veilig in de browser te gebruiken — de beveiliging zit in de RLS policies die we net hebben ingesteld."
|
||||
>
|
||||
> **Belangrijk:** "Voeg `.env.local` toe aan je `.gitignore` als die er nog niet in staat! Dit bestand mag NOOIT naar GitHub."
|
||||
|
||||
---
|
||||
|
||||
## Stap 3.3 — Supabase client aanmaken
|
||||
|
||||
> **Vertel:** "We maken een utility bestand aan dat de Supabase client configureert. Dit gebruiken we overal in onze app."
|
||||
|
||||
Maak een nieuw bestand `lib/supabase.ts`:
|
||||
|
||||
```typescript
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
```
|
||||
|
||||
> **Vertel:** "Drie regels code. We importeren `createClient` van Supabase, lezen de URL en key uit de environment variables, en exporteren een client instance. Het uitroepteken (`!`) zegt tegen TypeScript: 'ik weet zeker dat deze waarde bestaat'. We gebruiken dit ene `supabase` object overal in onze app."
|
||||
|
||||
---
|
||||
|
||||
## Stap 3.4 — Types updaten
|
||||
|
||||
> **Vertel:** "Onze database structuur is anders dan wat we in de code hadden. In de database hebben we aparte tabellen voor polls en options. Laten we onze types updaten."
|
||||
|
||||
Open `types/index.ts` en vervang de inhoud:
|
||||
|
||||
```typescript
|
||||
export interface Poll {
|
||||
id: string;
|
||||
question: string;
|
||||
created_at: string;
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
export interface Option {
|
||||
id: string;
|
||||
poll_id: string;
|
||||
text: string;
|
||||
votes: number;
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "We hebben nu twee types: Poll en Option. Een Poll heeft een array van Options. Dit matcht precies met onze twee database tabellen. Let op: `id` is nu een `string` (uuid) in plaats van een simpel nummer."
|
||||
|
||||
---
|
||||
|
||||
## Stap 3.5 — `data.ts` aanpassen voor Supabase
|
||||
|
||||
> **Vertel:** "Nu het belangrijkste: we vervangen onze hardcoded array door echte database queries. We herschrijven `lib/data.ts` volledig."
|
||||
|
||||
Open `lib/data.ts` en vervang de **hele** inhoud:
|
||||
|
||||
```typescript
|
||||
import { supabase } from "./supabase";
|
||||
import { Poll, Option } from "@/types";
|
||||
|
||||
export async function getPolls(): Promise<Poll[]> {
|
||||
const { data: polls, error } = await supabase
|
||||
.from("polls")
|
||||
.select("*, options(*)")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching polls:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return polls || [];
|
||||
}
|
||||
|
||||
export async function getPollById(id: string): Promise<Poll | null> {
|
||||
const { data: poll, error } = await supabase
|
||||
.from("polls")
|
||||
.select("*, options(*)")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching poll:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return poll;
|
||||
}
|
||||
|
||||
export async function votePoll(
|
||||
pollId: string,
|
||||
optionId: string
|
||||
): Promise<Option | null> {
|
||||
// Eerst de huidige votes ophalen
|
||||
const { data: option, error: fetchError } = await supabase
|
||||
.from("options")
|
||||
.select("votes")
|
||||
.eq("id", optionId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !option) {
|
||||
console.error("Error fetching option:", fetchError);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dan de votes met 1 verhogen
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from("options")
|
||||
.update({ votes: option.votes + 1 })
|
||||
.eq("id", optionId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating votes:", updateError);
|
||||
return null;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "Laten we dit stap voor stap doorlopen:"
|
||||
>
|
||||
> - **`getPolls()`**: We doen `supabase.from("polls").select("*, options(*)")`. Die `options(*)` is de magie — Supabase haalt automatisch de gerelateerde opties op via de foreign key. Alsof je een JOIN doet, maar dan makkelijker.
|
||||
> - **`getPollById()`**: Zelfde, maar met `.eq("id", id)` filteren we op één poll. `.single()` zegt: ik verwacht precies 1 resultaat.
|
||||
> - **`votePoll()`**: We werken nu met een `optionId` (uuid) in plaats van een index. We halen eerst de huidige votes op, verhogen met 1, en slaan op.
|
||||
|
||||
---
|
||||
|
||||
## Stap 3.6 — Homepage aanpassen
|
||||
|
||||
> **Vertel:** "Onze `getPolls()` is nu async — die returned een Promise. We moeten de homepage aanpassen."
|
||||
|
||||
Open `app/page.tsx` en vervang de inhoud:
|
||||
|
||||
```typescript
|
||||
import { getPolls } from "@/lib/data";
|
||||
import { PollItem } from "@/components/PollItem";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function Home() {
|
||||
const polls = await getPolls();
|
||||
|
||||
return (
|
||||
<div className="w-full p-4">
|
||||
<h2 className="text-2xl font-bold mb-4">Onze polls</h2>
|
||||
{polls.map((poll) => (
|
||||
<Link key={poll.id} href={`/poll/${poll.id}`}>
|
||||
<PollItem poll={poll} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "Let op: de functie is nu `async` en we doen `await getPolls()`. Dit kan omdat dit een Server Component is — die draait op de server en mag async zijn. We wrappen elke poll ook in een Link zodat je erop kunt klikken om naar de detail pagina te gaan."
|
||||
|
||||
---
|
||||
|
||||
## Stap 3.7 — PollItem aanpassen voor nieuwe types
|
||||
|
||||
> **Vertel:** "Onze PollItem moet nu werken met de Option type in plaats van een platte array. We passen het component aan."
|
||||
|
||||
Open `components/PollItem.tsx` en vervang de inhoud:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { Poll, Option } from "@/types"
|
||||
|
||||
type PollItemProps = {
|
||||
poll: Poll
|
||||
onOptionClick?: (option: Option) => void
|
||||
}
|
||||
|
||||
type PollItemOptionProps = {
|
||||
option: Option
|
||||
percentage: number
|
||||
onClick?: (option: Option) => void
|
||||
}
|
||||
|
||||
export const PollItemOption = ({ option, percentage, onClick }: PollItemOptionProps) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(option)}
|
||||
className="relative my-2 p-3 border rounded cursor-pointer hover:bg-gray-50 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-blue-100 transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
<div className="relative flex justify-between">
|
||||
<span>{option.text}</span>
|
||||
<span className="text-gray-500">{option.votes} ({percentage}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PollItem = ({ poll, onOptionClick }: PollItemProps) => {
|
||||
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
|
||||
|
||||
return (
|
||||
<section className="w-full my-6">
|
||||
<h2 className="text-xl font-bold mb-2">{poll.question}</h2>
|
||||
<p className="text-sm text-gray-500 mb-3">{totalVotes} stemmen</p>
|
||||
{poll.options.map((option) => {
|
||||
const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0;
|
||||
return (
|
||||
<PollItemOption
|
||||
key={option.id}
|
||||
option={option}
|
||||
percentage={percentage}
|
||||
onClick={onOptionClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "Het verschil: we gebruiken nu `option.text` en `option.votes` in plaats van de platte arrays. En de key is nu `option.id` — dat is altijd uniek."
|
||||
|
||||
---
|
||||
|
||||
## Stap 3.8 — VoteForm + detail pagina aanpassen
|
||||
|
||||
> **Vertel:** "We hebben in Deel 1 het Server Component + VoteForm patroon opgezet. De pagina is een Server Component die data ophaalt, de VoteForm is een Client Component voor interactie. Dat patroon hoeven we niet te veranderen — we passen alleen de VoteForm aan om met option ID's te werken."
|
||||
|
||||
Open `components/VoteForm.tsx` en vervang de inhoud:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { Poll, Option } from "@/types";
|
||||
import { PollItem } from "./PollItem";
|
||||
import { useState } from "react";
|
||||
|
||||
export function VoteForm({ poll: initialPoll }: { poll: Poll }) {
|
||||
const [poll, setPoll] = useState(initialPoll);
|
||||
|
||||
const onVote = async (option: Option) => {
|
||||
const response = await fetch(`/api/polls/${poll.id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ optionId: option.id }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedPoll = await response.json();
|
||||
setPoll(updatedPoll);
|
||||
}
|
||||
};
|
||||
|
||||
return <PollItem poll={poll} onOptionClick={onVote} />;
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "Bijna identiek als voorheen, maar nu sturen we `option.id` (de uuid) mee in plaats van een index. En de callback ontvangt een `Option` object in plaats van een string."
|
||||
|
||||
Open `app/poll/[id]/page.tsx` en update:
|
||||
|
||||
```typescript
|
||||
import { getPollById } from "@/lib/data";
|
||||
import { VoteForm } from "@/components/VoteForm";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PollPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const poll = await getPollById(id);
|
||||
|
||||
if (!poll) notFound();
|
||||
|
||||
return (
|
||||
<div className="w-full p-4">
|
||||
<h2 className="text-2xl font-bold mb-4">{poll.question}</h2>
|
||||
<VoteForm poll={poll} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "Het enige verschil met Deel 1: `await` voor `getPollById()` — want die is nu async vanwege Supabase. De rest is exact hetzelfde. Dát is de kracht van dit patroon: de pagina structuur verandert niet als je van data source switcht."
|
||||
|
||||
---
|
||||
|
||||
## Stap 3.9 — API routes aanpassen voor Supabase
|
||||
|
||||
> **Vertel:** "Als laatste moeten we de API routes updaten om met Supabase te werken."
|
||||
|
||||
Open `app/api/polls/[id]/route.ts` en vervang de inhoud:
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPollById, votePoll } from "@/lib/data";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: RouteParams) {
|
||||
const { id } = await params;
|
||||
const poll = await getPollById(id);
|
||||
|
||||
if (!poll) {
|
||||
return NextResponse.json({ error: "Poll niet gevonden" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(poll);
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: RouteParams) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { optionId } = body;
|
||||
|
||||
if (!optionId) {
|
||||
return NextResponse.json({ error: "optionId is verplicht" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updatedOption = await votePoll(id, optionId);
|
||||
|
||||
if (!updatedOption) {
|
||||
return NextResponse.json(
|
||||
{ error: "Kon stem niet verwerken" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Haal de volledige poll op om terug te sturen
|
||||
const poll = await getPollById(id);
|
||||
return NextResponse.json(poll);
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "De GET route is bijna hetzelfde, maar nu met `await` omdat `getPollById` async is. De POST route leest nu `optionId` uit de body, roept `votePoll` aan, en stuurt de hele updated poll terug."
|
||||
|
||||
---
|
||||
|
||||
## ✅ Check: Alles testen
|
||||
|
||||
> **Check:** Herstart de dev server (stop en `npm run dev`).
|
||||
>
|
||||
> 1. Ga naar `http://localhost:3000` — je ziet de polls uit Supabase
|
||||
> 2. Klik op een poll — je ziet de detail pagina met opties
|
||||
> 3. Stem op een optie — de votes updaten
|
||||
> 4. **Refresh de pagina** — de stem is bewaard!
|
||||
> 5. **Open Supabase Table Editor** — je ziet de updated votes in de `options` tabel
|
||||
>
|
||||
> **Vertel:** "Dat is het grote verschil met onze in-memory data: als je nu de server herstart, of de pagina refresht, zijn je stemmen er nog steeds. Ze staan in een echte database. En als iemand anders de URL opent, ziet die persoon dezelfde data. Dat is de kracht van een database."
|
||||
|
||||
---
|
||||
|
||||
## Stap 3.10 — Bonus: Realtime (als er tijd over is)
|
||||
|
||||
> **Vertel:** "Als je wil dat stemmen LIVE updaten bij andere gebruikers, kan Supabase dat ook. Dit is een bonus — we laten het even zien."
|
||||
|
||||
Open `components/VoteForm.tsx` en voeg een `useEffect` toe voor realtime:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { Poll, Option } from "@/types";
|
||||
import { PollItem } from "./PollItem";
|
||||
import { useState, useEffect } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export function VoteForm({ poll: initialPoll }: { poll: Poll }) {
|
||||
const [poll, setPoll] = useState(initialPoll);
|
||||
|
||||
// Realtime subscription — luister naar vote updates
|
||||
useEffect(() => {
|
||||
const channel = supabase
|
||||
.channel('votes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'public', table: 'options' },
|
||||
async () => {
|
||||
const res = await fetch(`/api/polls/${poll.id}`);
|
||||
if (res.ok) {
|
||||
setPoll(await res.json());
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [poll.id]);
|
||||
|
||||
const onVote = async (option: Option) => {
|
||||
const response = await fetch(`/api/polls/${poll.id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ optionId: option.id }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setPoll(await response.json());
|
||||
}
|
||||
};
|
||||
|
||||
return <PollItem poll={poll} onOptionClick={onVote} />;
|
||||
}
|
||||
```
|
||||
|
||||
> **Vertel:** "Nu luistert de VoteForm naar veranderingen in de options tabel. Als iemand anders stemt, update jouw scherm automatisch. Dit is realtime — geen polling, geen refresh nodig. En het zit netjes in het Client Component waar het hoort."
|
||||
|
||||
---
|
||||
|
||||
# AFSLUITING (11:30 – 12:00)
|
||||
|
||||
## Samenvatting
|
||||
|
||||
> **Vertel:** "Wat hebben we vandaag gedaan?"
|
||||
>
|
||||
> 1. **Poll afgemaakt** — stemmen werkt nu echt
|
||||
> 2. **Supabase leren kennen** — tabellen, foreign keys, RLS policies, SQL
|
||||
> 3. **Supabase gekoppeld** — van in-memory array naar echte database
|
||||
> 4. **Data is nu persistent** — overleeft server restarts
|
||||
>
|
||||
> **Vertel:** "Volgende week gaan we authenticatie toevoegen met Supabase Auth, zodat je kunt zien wie er gestemd heeft."
|
||||
|
||||
---
|
||||
|
||||
## Huiswerk
|
||||
|
||||
> **Vertel:** "Voor volgende week:"
|
||||
>
|
||||
> 1. **Maak een `/create` pagina** waar je een nieuwe poll kunt aanmaken
|
||||
> - Formulier met een vraag en minimaal 2 opties
|
||||
> - Sla op in Supabase (INSERT in polls + options tabellen)
|
||||
> - Redirect naar de homepage na het aanmaken
|
||||
> 2. **Voeg een "Nieuwe Poll" link toe** in de navbar
|
||||
> 3. **Extra:** probeer de SQL Editor in Supabase — schrijf queries om:
|
||||
> - De poll met de meeste stemmen te vinden
|
||||
> - Alle opties op te halen gesorteerd op votes (hoog naar laag)
|
||||
149
Les07-Supabase/Les07-Slide-Overzicht.md
Normal file
149
Les07-Supabase/Les07-Slide-Overzicht.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Les 7 — Slide Overzicht
|
||||
## Van In-Memory naar Supabase
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Titelslide
|
||||
**Layout:** Split (cream links, blauw rechts) — Keynote stijl
|
||||
- NOVI Hogeschool logo
|
||||
- "AI leerlijn"
|
||||
- **Next.js**
|
||||
- **Les 7**
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Terugblik vorige les
|
||||
**Layout:** Cream + blauw blob rechts
|
||||
- **Titel:** Terugblik vorige les
|
||||
- Links: Wat we gebouwd hebben
|
||||
- Next.js QuickPoll app van scratch
|
||||
- TypeScript types, API routes, pages
|
||||
- Server Components + Client Components
|
||||
- Loading, Error, Not-Found states
|
||||
- Middleware
|
||||
- Rechts: Wat nog mist
|
||||
- Stemmen werkt niet echt
|
||||
- Data verdwijnt bij restart
|
||||
- Geen echte database
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Planning
|
||||
**Layout:** Gele achtergrond + decoratieve blobs
|
||||
- **Titel:** Planning
|
||||
- Deel 1: Poll afmaken — 30 min
|
||||
- votePoll functie, POST route, bug fix, visuele feedback
|
||||
- Deel 2: Supabase Introductie (No Code) — 45 min
|
||||
- Account, tabellen, foreign keys, RLS, SQL
|
||||
- Pauze — 15 min
|
||||
- Deel 3: Supabase koppelen — 60 min
|
||||
- Client, environment variables, data.ts, testen
|
||||
- Afsluiting — 30 min
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Wat is Supabase?
|
||||
**Layout:** Cream + blauw blob rechts
|
||||
- **Titel:** Wat is Supabase?
|
||||
- Open-source Firebase alternatief
|
||||
- PostgreSQL database — 30+ jaar, professioneel
|
||||
- Auth — login, registratie, OAuth
|
||||
- Storage — bestanden uploaden
|
||||
- Realtime — live updates
|
||||
- Edge Functions — serverless
|
||||
- Gratis tier voor leren en kleine projecten
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: Van Array naar Database
|
||||
**Layout:** Cream + blauw blob rechts
|
||||
- **Titel:** Van Array naar Database
|
||||
- Links: code block met de oude in-memory array
|
||||
```
|
||||
let polls: Poll[] = [
|
||||
{ id: "1", question: "...", options: [...], votes: [...] }
|
||||
];
|
||||
```
|
||||
- Rechts: twee tabellen
|
||||
- polls: id, question, created_at
|
||||
- options: id, poll_id, text, votes
|
||||
- Pijl van links naar rechts: "Normalisatie"
|
||||
- Uitleg: "Eén array → twee tabellen met een relatie"
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: Live Coding — Deel 1
|
||||
**Layout:** Blauw volledig + cream blob links
|
||||
- **Titel:** Live Coding
|
||||
- **Subtitel:** Deel 1: Poll afmaken
|
||||
- Stappen:
|
||||
- votePoll() functie
|
||||
- POST route werkend
|
||||
- Bug fix: optionIndex
|
||||
- Visuele feedback
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: Supabase Setup — Deel 2
|
||||
**Layout:** Blauw volledig + cream blob links
|
||||
- **Titel:** Supabase Setup
|
||||
- **Subtitel:** Deel 2: No Code — alles via de browser
|
||||
- Stappen:
|
||||
- Project aanmaken
|
||||
- Tabellen: polls + options
|
||||
- Foreign keys & relaties
|
||||
- RLS policies
|
||||
- Testdata + SQL Editor
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: Pauze
|
||||
**Layout:** Cream + grote blauwe cirkel
|
||||
- **Titel:** Pauze
|
||||
- **Subtitel:** 15 minuten
|
||||
- "Deel 1 & 2 staan er. Na de pauze: koppelen aan Next.js!"
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: Live Coding — Deel 3
|
||||
**Layout:** Blauw volledig + cream blob links
|
||||
- **Titel:** Live Coding
|
||||
- **Subtitel:** Deel 3: Supabase × Next.js
|
||||
- Stappen:
|
||||
- @supabase/supabase-js installeren
|
||||
- .env.local + supabase client
|
||||
- data.ts herschrijven
|
||||
- Components + pages aanpassen
|
||||
- Testen: stem → refresh → data bewaard!
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: Vragen?
|
||||
**Layout:** Cream + blauw blob rechts
|
||||
- **Titel:** Vragen?
|
||||
- Reflectievragen:
|
||||
- Snap je het verschil tussen in-memory en database?
|
||||
- Kun je uitleggen wat een foreign key doet?
|
||||
- Begrijp je hoe RLS werkt?
|
||||
|
||||
---
|
||||
|
||||
## Slide 11: Huiswerk
|
||||
**Layout:** Cream + blauw blob rechts
|
||||
- **Titel:** Huiswerk
|
||||
- Opdracht: /create pagina bouwen
|
||||
1. Formulier met vraag + opties
|
||||
2. Opslaan in Supabase (INSERT)
|
||||
3. Navbar link toevoegen
|
||||
4. Redirect na aanmaken
|
||||
- Extra:
|
||||
- SQL queries schrijven (meeste stemmen, sorteren)
|
||||
- Validatie toevoegen
|
||||
|
||||
---
|
||||
|
||||
## Slide 12: Afsluiting
|
||||
**Layout:** Blauw volledig + cream/roze/zwart blobs links
|
||||
- **Titel:** Tot volgende week!
|
||||
- Volgende les: Supabase Auth — inloggen & registreren
|
||||
- "Van array naar echte database. Goed gedaan!"
|
||||
BIN
Les07-Supabase/Les07-Slides.pptx
Normal file
BIN
Les07-Supabase/Les07-Slides.pptx
Normal file
Binary file not shown.
Reference in New Issue
Block a user