552 lines
13 KiB
Markdown
552 lines
13 KiB
Markdown
# Les 8 — Live Coding Guide
|
||
## Van In-Memory naar Supabase
|
||
|
||
> **Jouw spiekbriefje.** Dit bestand staat op je privéscherm. Op de beamer draait Cursor.
|
||
|
||
---
|
||
|
||
## DEEL 1: Live Coding (09:10–10:15)
|
||
|
||
### Stap 1: npm install
|
||
```bash
|
||
npm install @supabase/supabase-js
|
||
```
|
||
Docent zegt: "Dit geeft ons de JavaScript client."
|
||
|
||
### Stap 2: .env.local toevoegen
|
||
Open Supabase Dashboard → Settings → API Keys
|
||
|
||
Copy deze 2:
|
||
```
|
||
NEXT_PUBLIC_SUPABASE_URL=https://[project].supabase.co
|
||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc...
|
||
```
|
||
|
||
Plak in `.env.local`
|
||
|
||
**BELANGRIJK:** Dev server herstarten! (`npm run dev`)
|
||
|
||
---
|
||
|
||
### Stap 3: 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!
|
||
);
|
||
```
|
||
|
||
Docent zegt: "Dit is onze Supabase client. Eenmalig aanmaken, dan overal gebruiken."
|
||
|
||
---
|
||
|
||
### Stap 4: types/index.ts
|
||
```typescript
|
||
export interface Poll {
|
||
id: number;
|
||
question: string;
|
||
created_at: string;
|
||
options: Option[];
|
||
}
|
||
|
||
export interface Option {
|
||
id: number;
|
||
poll_id: number;
|
||
text: string;
|
||
votes: number;
|
||
created_at: string;
|
||
}
|
||
```
|
||
|
||
Docent zegt: "Types matchen onze database schema."
|
||
|
||
---
|
||
|
||
### Stap 5: lib/data.ts (complete rewrite)
|
||
|
||
Laat EERST het oude code zien:
|
||
```typescript
|
||
// OUD
|
||
const polls = [
|
||
{ question: "...", options: ["...", "..."], votes: [0, 0] }
|
||
];
|
||
|
||
export function getPolls() {
|
||
return polls;
|
||
}
|
||
```
|
||
|
||
Dan: "Dit vervangen we door Supabase queries."
|
||
|
||
```typescript
|
||
import { supabase } from "./supabase";
|
||
import { Poll } from "@/types";
|
||
|
||
export async function getPolls(): Promise<Poll[]> {
|
||
const { data, error } = await supabase
|
||
.from("polls")
|
||
.select("*, options(*)");
|
||
|
||
if (error) {
|
||
console.error("Error fetching polls:", error);
|
||
return [];
|
||
}
|
||
|
||
return data || [];
|
||
}
|
||
|
||
export async function getPollById(id: number): Promise<Poll | null> {
|
||
const { data, error } = await supabase
|
||
.from("polls")
|
||
.select("*, options(*)")
|
||
.eq("id", id)
|
||
.single();
|
||
|
||
if (error) {
|
||
console.error("Error fetching poll:", error);
|
||
return null;
|
||
}
|
||
|
||
return data;
|
||
}
|
||
|
||
export async function votePoll(optionId: number): Promise<boolean> {
|
||
const { error } = await supabase.rpc("vote_option", { option_id: optionId });
|
||
|
||
if (error) {
|
||
console.error("Error voting:", error);
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
```
|
||
|
||
Docent tips:
|
||
- `.select("*, options(*)")` = Haal polls én hun opties op
|
||
- `.eq("id", id)` = WHERE clausa
|
||
- `.single()` = Verwacht exact 1 resultaat
|
||
- `await` = Dit is asynchroon!
|
||
|
||
---
|
||
|
||
### PAUZE VOOR SLIDE 6: Server vs Client: Wie doet wat?
|
||
|
||
**TOON DEZE SLIDE VOOR COMPONENT AANPASSINGEN**
|
||
|
||
Docent zegt: "Nu gaan we componenten aanpassen. Eerst: dit patroon!"
|
||
|
||
```typescript
|
||
// Server Component
|
||
export default async function HomePage() {
|
||
const polls = await getPolls();
|
||
return <>{...}</>
|
||
}
|
||
|
||
// Client Component
|
||
'use client'
|
||
export function VoteForm() {
|
||
const [voted, setVoted] = useState(false);
|
||
return <>{...}</>
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Stap 6: app/page.tsx (Server Component)
|
||
```typescript
|
||
import { getPolls } from "@/lib/data";
|
||
import Link from "next/link";
|
||
import PollItem from "@/components/PollItem";
|
||
|
||
export default async function HomePage() {
|
||
const polls = await getPolls();
|
||
|
||
return (
|
||
<div className="w-full max-w-2xl mx-auto p-6">
|
||
<h1 className="text-3xl font-bold mb-6">Huidige Polls</h1>
|
||
<Link href="/create" className="text-blue-600 hover:underline mb-6 block">
|
||
+ Nieuwe Poll
|
||
</Link>
|
||
<div className="space-y-4">
|
||
{polls.map((poll) => (
|
||
<PollItem key={poll.id} poll={poll} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Docent zegt: "Dit is nu async! Direct await op getPolls(). Link naar /create al meteen toevoegen."
|
||
|
||
---
|
||
|
||
### Stap 7: components/PollItem.tsx (Option type, percentage bars)
|
||
```typescript
|
||
'use client'
|
||
|
||
import Link from "next/link";
|
||
import { Option } from "@/types";
|
||
|
||
interface PollItemProps {
|
||
poll: {
|
||
id: number;
|
||
question: string;
|
||
options: Option[];
|
||
};
|
||
}
|
||
|
||
export default function PollItem({ poll }: PollItemProps) {
|
||
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
|
||
|
||
return (
|
||
<div className="border rounded p-4">
|
||
<h2 className="text-xl font-semibold mb-4">{poll.question}</h2>
|
||
<div className="space-y-2">
|
||
{poll.options.map((option) => {
|
||
const percentage = totalVotes > 0 ? (option.votes / totalVotes) * 100 : 0;
|
||
return (
|
||
<Link key={option.id} href={`/poll/${poll.id}`}>
|
||
<div className="flex items-center gap-2 cursor-pointer hover:opacity-80">
|
||
<div className="flex-1 bg-gray-200 rounded h-8 overflow-hidden">
|
||
<div
|
||
className="bg-blue-600 h-full transition-all"
|
||
style={{ width: `${percentage}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-sm font-medium w-20">
|
||
{option.text} ({option.votes})
|
||
</span>
|
||
</div>
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Docent zegt: "Nu hebben we Option type. Percentage bars visueel!"
|
||
|
||
---
|
||
|
||
### Stap 8: components/VoteForm.tsx (Client Component)
|
||
```typescript
|
||
'use client'
|
||
|
||
import { useState } from "react";
|
||
import { votePoll } from "@/lib/data";
|
||
import { Option } from "@/types";
|
||
|
||
interface VoteFormProps {
|
||
options: Option[];
|
||
}
|
||
|
||
export default function VoteForm({ options }: VoteFormProps) {
|
||
const [loading, setLoading] = useState(false);
|
||
const [voted, setVoted] = useState(false);
|
||
|
||
const handleVote = async (optionId: number) => {
|
||
setLoading(true);
|
||
const success = await votePoll(optionId);
|
||
if (success) {
|
||
setVoted(true);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
if (voted) {
|
||
return <p className="text-green-600">Dank je voor je stem!</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{options.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => handleVote(option.id)}
|
||
disabled={loading}
|
||
className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{option.text}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Docent zegt: "'use client' bovenaan. useState werkt. onClick handlers werken. After vote: feedback!"
|
||
|
||
---
|
||
|
||
### Stap 9: app/poll/[id]/page.tsx
|
||
```typescript
|
||
import { getPollById } from "@/lib/data";
|
||
import VoteForm from "@/components/VoteForm";
|
||
import { notFound } from "next/navigation";
|
||
|
||
export default async function PollPage({ params }: { params: { id: string } }) {
|
||
const poll = await getPollById(parseInt(params.id));
|
||
|
||
if (!poll) {
|
||
notFound();
|
||
}
|
||
|
||
return (
|
||
<div className="w-full max-w-md mx-auto p-6">
|
||
<h1 className="text-2xl font-bold mb-6">{poll.question}</h1>
|
||
<VoteForm options={poll.options} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Docent zegt: "Server Component haalt data. Geeft VoteForm (Client) de options."
|
||
|
||
---
|
||
|
||
### Stap 10: app/api/polls/[id]/route.ts
|
||
```typescript
|
||
import { getPollById, votePoll } from "@/lib/data";
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
|
||
export async function GET(
|
||
request: NextRequest,
|
||
{ params }: { params: { id: string } }
|
||
) {
|
||
const poll = await getPollById(parseInt(params.id));
|
||
if (!poll) {
|
||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||
}
|
||
return NextResponse.json(poll);
|
||
}
|
||
|
||
export async function POST(
|
||
request: NextRequest,
|
||
{ params }: { params: { id: string } }
|
||
) {
|
||
const { optionId } = await request.json();
|
||
const success = await votePoll(optionId);
|
||
return NextResponse.json({ success });
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Stap 11: TESTEN
|
||
- http://localhost:3000 → Alle polls
|
||
- Click poll → Detail pagina
|
||
- Stem → Votes incrementen
|
||
- Controleer Supabase dashboard → votes kolom wijzigt
|
||
|
||
---
|
||
|
||
## DEEL 2: Zelf Doen — /create pagina (10:30–11:30)
|
||
|
||
### Theorie op Beamer (15 min)
|
||
|
||
**Toon INSERT query uitleggen:**
|
||
|
||
```typescript
|
||
// 1. Insert poll → krijg ID terug
|
||
const { data: poll } = await supabase
|
||
.from("polls")
|
||
.insert({ question: "Wat is je favoriete taal?" })
|
||
.select()
|
||
.single();
|
||
|
||
// poll.id = 42
|
||
|
||
// 2. Insert options
|
||
await supabase.from("options").insert([
|
||
{ poll_id: 42, text: "JavaScript", votes: 0 },
|
||
{ poll_id: 42, text: "Python", votes: 0 },
|
||
{ poll_id: 42, text: "Rust", votes: 0 }
|
||
]);
|
||
```
|
||
|
||
**Docent zegt:**
|
||
- ".insert() = INSERT"
|
||
- ".select().single() = geef terug wat je insertde"
|
||
- "poll.id gebruiken voor options"
|
||
- "Meerdere rows in [{}] array"
|
||
- "Dan router.push('/') terug naar home"
|
||
|
||
---
|
||
|
||
### RLS Policy (SQL Editor in Supabase)
|
||
|
||
**Docent laat dit zien:**
|
||
|
||
```sql
|
||
-- INSERT policy voor polls
|
||
CREATE POLICY "Allow public insert on polls"
|
||
ON polls FOR INSERT
|
||
TO anon
|
||
WITH CHECK (true);
|
||
|
||
-- INSERT policy voor options
|
||
CREATE POLICY "Allow public insert on options"
|
||
ON options FOR INSERT
|
||
TO anon
|
||
WITH CHECK (true);
|
||
```
|
||
|
||
**Docent zegt:**
|
||
"Dit zegt: Iedereen mag INSERT-en. Zonder dit: RLS policy violation."
|
||
|
||
---
|
||
|
||
### Reference Code: app/create/page.tsx
|
||
|
||
Toon dit op beamer als hulp:
|
||
|
||
```typescript
|
||
'use client'
|
||
|
||
import { supabase } from "@/lib/supabase";
|
||
import { useRouter } from "next/navigation";
|
||
import { useState } from "react";
|
||
|
||
export default function CreatePoll() {
|
||
const [question, setQuestion] = useState("");
|
||
const [options, setOptions] = useState(["", ""]);
|
||
const [loading, setLoading] = useState(false);
|
||
const router = useRouter();
|
||
|
||
const addOption = () => setOptions([...options, ""]);
|
||
|
||
const updateOption = (index: number, value: string) => {
|
||
const newOptions = [...options];
|
||
newOptions[index] = value;
|
||
setOptions(newOptions);
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setLoading(true);
|
||
|
||
// 1. Insert poll
|
||
const { data: poll, error: pollError } = await supabase
|
||
.from("polls")
|
||
.insert({ question })
|
||
.select()
|
||
.single();
|
||
|
||
if (pollError || !poll) {
|
||
console.error("Error creating poll:", pollError);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
// 2. Insert options
|
||
const optionRows = options
|
||
.filter((opt) => opt.trim() !== "")
|
||
.map((opt) => ({
|
||
poll_id: poll.id,
|
||
text: opt,
|
||
votes: 0,
|
||
}));
|
||
|
||
const { error: optionsError } = await supabase
|
||
.from("options")
|
||
.insert(optionRows);
|
||
|
||
if (optionsError) {
|
||
console.error("Error creating options:", optionsError);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
router.push("/");
|
||
};
|
||
|
||
return (
|
||
<div className="w-full max-w-md mx-auto p-6">
|
||
<h1 className="text-2xl font-bold mb-6">Nieuwe Poll</h1>
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Vraag</label>
|
||
<input
|
||
type="text"
|
||
value={question}
|
||
onChange={(e) => setQuestion(e.target.value)}
|
||
className="w-full p-2 border rounded"
|
||
placeholder="Stel je vraag..."
|
||
required
|
||
/>
|
||
</div>
|
||
{options.map((option, index) => (
|
||
<div key={index}>
|
||
<label className="block text-sm font-medium mb-1">
|
||
Optie {index + 1}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={option}
|
||
onChange={(e) => updateOption(index, e.target.value)}
|
||
className="w-full p-2 border rounded"
|
||
placeholder={`Optie ${index + 1}`}
|
||
required
|
||
/>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={addOption}
|
||
className="text-blue-600 text-sm hover:underline"
|
||
>
|
||
+ Optie toevoegen
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{loading ? "Bezig..." : "Poll aanmaken"}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Docent Loop Ronde Timing
|
||
|
||
- **Min 0-5:** Iedereen aan het werk?
|
||
- **Min 15:** RLS policy check. Help vastlopen studenten.
|
||
- **Min 25:** Toon useState setup snippet.
|
||
- **Min 30:** Eerste werkende insert check. Toon in Supabase dashboard.
|
||
- **Min 45:** Finalisatie + vragen.
|
||
|
||
---
|
||
|
||
### Veelvoorkomende Problemen
|
||
|
||
| Probleem | Oplossing |
|
||
|----------|-----------|
|
||
| "RLS policy violation" | Policy toegevoegd in dashboard? |
|
||
| "poll is undefined" | .select().single() vergeten? |
|
||
| "Form refresh pagina" | e.preventDefault()? |
|
||
| "Redirect werkt niet" | useRouter import juist? next/navigation? |
|
||
| "Options fout" | Spread operator [...options] gebruiken? |
|
||
| "Votes niet updatend" | Supabase RLS blocking? Check policy. |
|
||
|
||
---
|
||
|
||
## Timing Summary
|
||
|
||
- **09:00–09:10:** Welkom + Slide 1, 2, 3
|
||
- **09:10–10:15:** Live Coding (Stap 1–11) + Slide 6 halverwege
|
||
- **10:15–10:30:** Pauze (Slide 7)
|
||
- **10:30–11:30:** Zelf Doen + Theorie (Slide 8)
|
||
- **11:30–11:45:** Vragen
|
||
- **11:45–12:00:** Huiswerk + Afsluiting (Slide 9, 10)
|