fix: add 9
This commit is contained in:
551
Les08-Supabase+Nextjs/Les08-Live-Coding-Guide.md
Normal file
551
Les08-Supabase+Nextjs/Les08-Live-Coding-Guide.md
Normal file
@@ -0,0 +1,551 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user