Skip to content

Commit 2876186

Browse files
committed
feat: add Zod validation to editor, extend notes, build review flashcard with SM-2 rating
1 parent e7fafff commit 2876186

6 files changed

Lines changed: 391 additions & 45 deletions

File tree

apps/web/app/(app)/review/page.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1+
export const dynamic = "force-dynamic"
2+
3+
import { ReviewSession } from "@/components/review-session"
4+
15
export default function ReviewPage() {
2-
return (
3-
<div className="p-6 lg:p-8">
4-
<h1 className="font-heading text-2xl font-semibold tracking-tight">Review</h1>
5-
<p className="mt-1 text-sm text-muted-foreground">Flashcard review session — coming in Day 22.</p>
6-
</div>
7-
)
6+
return <ReviewSession />
87
}

apps/web/app/api/review/route.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { auth } from "@clerk/nextjs/server"
2+
import { NextResponse } from "next/server"
3+
import { createAdminClient } from "@/lib/supabase/admin"
4+
import { api } from "@/lib/api"
5+
6+
export async function POST(req: Request) {
7+
const { userId: clerkId } = await auth()
8+
if (!clerkId) {
9+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
10+
}
11+
12+
const supabase = createAdminClient()
13+
14+
const { data: user, error: userError } = await supabase
15+
.from("users")
16+
.select("id")
17+
.eq("clerk_id", clerkId)
18+
.single()
19+
20+
if (userError || !user) {
21+
return NextResponse.json({ error: "User not found" }, { status: 404 })
22+
}
23+
24+
const { snippetId, rating, currentEase, currentInterval, currentReps } = await req.json()
25+
26+
// Call SM-2 algorithm
27+
const schedule = await api.review.schedule({
28+
snippet_id: snippetId,
29+
rating,
30+
current_ease: currentEase,
31+
current_interval: currentInterval,
32+
current_reps: currentReps,
33+
})
34+
35+
// Update snippet SM-2 fields
36+
const { error: snippetError } = await supabase
37+
.from("snippets")
38+
.update({
39+
ease_factor: schedule.ease_factor,
40+
interval_days: schedule.interval_days,
41+
repetitions: schedule.repetitions,
42+
next_review: schedule.next_review,
43+
updated_at: new Date().toISOString(),
44+
})
45+
.eq("id", snippetId)
46+
47+
if (snippetError) {
48+
return NextResponse.json({ error: snippetError.message }, { status: 500 })
49+
}
50+
51+
// Log the review
52+
const { error: logError } = await supabase.from("review_logs").insert({
53+
snippet_id: snippetId,
54+
user_id: user.id,
55+
rating,
56+
ease_factor_after: schedule.ease_factor,
57+
interval_after: schedule.interval_days,
58+
})
59+
60+
if (logError) {
61+
return NextResponse.json({ error: logError.message }, { status: 500 })
62+
}
63+
64+
return NextResponse.json(schedule)
65+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import CodeMirror from "@uiw/react-codemirror"
5+
import { vscodeDark } from "@uiw/codemirror-theme-vscode"
6+
import { cn } from "@/lib/utils"
7+
import { getLanguageExtension, type Language } from "@/lib/languages"
8+
import { Button } from "@/components/ui/button"
9+
import type { Snippet } from "@/types/snippet"
10+
11+
interface ReviewCardProps {
12+
snippet: Snippet
13+
index: number
14+
total: number
15+
onRate: (rating: 1 | 3 | 5) => void
16+
isSubmitting: boolean
17+
}
18+
19+
export function ReviewCard({ snippet, index, total, onRate, isSubmitting }: ReviewCardProps) {
20+
const [revealed, setReveal] = useState(false)
21+
22+
return (
23+
<div className="flex flex-col gap-4">
24+
25+
{/* ── Progress ─────────────────────────────────────── */}
26+
<div className="flex items-center gap-3">
27+
<div className="h-1.5 flex-1 rounded-full bg-border overflow-hidden">
28+
<div
29+
className="h-full rounded-full bg-primary transition-[width] duration-500"
30+
style={{ width: `${(index / total) * 100}%` }}
31+
/>
32+
</div>
33+
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
34+
{index + 1} / {total}
35+
</span>
36+
</div>
37+
38+
{/* ── Card ─────────────────────────────────────────── */}
39+
<div className="rounded-2xl border border-border bg-card overflow-hidden">
40+
41+
{/* Header */}
42+
<div className="px-6 pt-6 pb-4 border-b border-border">
43+
<div className="flex flex-wrap items-center gap-1.5 mb-3">
44+
<span className="rounded-md bg-muted/60 px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
45+
{snippet.language}
46+
</span>
47+
{snippet.tags.map((tag) => (
48+
<span
49+
key={tag}
50+
className="rounded-md bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
51+
>
52+
{tag}
53+
</span>
54+
))}
55+
</div>
56+
57+
<h2 className="font-heading text-xl font-semibold tracking-tight text-foreground text-wrap-balance">
58+
{snippet.title}
59+
</h2>
60+
61+
{snippet.description && (
62+
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
63+
{snippet.description}
64+
</p>
65+
)}
66+
</div>
67+
68+
{/* Code area */}
69+
{snippet.code && (
70+
<div className="relative">
71+
{/* Blurred overlay when not revealed */}
72+
{!revealed && (
73+
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-card/70 backdrop-blur-sm">
74+
<Button
75+
variant="outline"
76+
onClick={() => setReveal(true)}
77+
className="active:scale-[0.96] transition-[transform,opacity] duration-100 shadow-sm"
78+
>
79+
Show answer
80+
</Button>
81+
<p className="text-xs text-muted-foreground">
82+
Try to recall the code first
83+
</p>
84+
</div>
85+
)}
86+
87+
<div className={cn("min-h-48 transition-[filter] duration-300", !revealed && "blur-sm select-none pointer-events-none")}>
88+
<CodeMirror
89+
value={snippet.code}
90+
theme={vscodeDark}
91+
extensions={[
92+
...(getLanguageExtension(snippet.language as Language)
93+
? [getLanguageExtension(snippet.language as Language)!]
94+
: []),
95+
]}
96+
editable={false}
97+
basicSetup={{
98+
lineNumbers: true,
99+
foldGutter: false,
100+
highlightActiveLine: false,
101+
autocompletion: false,
102+
}}
103+
className="text-sm"
104+
style={{ fontFamily: "var(--font-mono, ui-monospace, monospace)" }}
105+
/>
106+
</div>
107+
</div>
108+
)}
109+
</div>
110+
111+
{/* ── Rating buttons ───────────────────────────────── */}
112+
{revealed && (
113+
<div className="flex flex-col gap-2">
114+
<p className="text-center text-xs text-muted-foreground">
115+
How well did you remember this?
116+
</p>
117+
<div className="grid grid-cols-3 gap-2">
118+
<RatingButton
119+
label="Forgot"
120+
sublabel="Start over"
121+
onClick={() => onRate(1)}
122+
disabled={isSubmitting}
123+
variant="forgot"
124+
/>
125+
<RatingButton
126+
label="Hard"
127+
sublabel="Got it, barely"
128+
onClick={() => onRate(3)}
129+
disabled={isSubmitting}
130+
variant="hard"
131+
/>
132+
<RatingButton
133+
label="Easy"
134+
sublabel="Got it!"
135+
onClick={() => onRate(5)}
136+
disabled={isSubmitting}
137+
variant="easy"
138+
/>
139+
</div>
140+
</div>
141+
)}
142+
</div>
143+
)
144+
}
145+
146+
function RatingButton({
147+
label,
148+
sublabel,
149+
onClick,
150+
disabled,
151+
variant,
152+
}: {
153+
label: string
154+
sublabel: string
155+
onClick: () => void
156+
disabled: boolean
157+
variant: "forgot" | "hard" | "easy"
158+
}) {
159+
const styles = {
160+
forgot: "border-destructive/30 bg-destructive/8 text-destructive hover:bg-destructive/15",
161+
hard: "border-pulse/30 bg-pulse/8 text-pulse hover:bg-pulse/15",
162+
easy: "border-primary/30 bg-primary/8 text-primary hover:bg-primary/15",
163+
}
164+
165+
return (
166+
<button
167+
type="button"
168+
onClick={onClick}
169+
disabled={disabled}
170+
className={cn(
171+
"flex flex-col items-center gap-0.5 rounded-xl border px-3 py-3.5",
172+
"active:scale-[0.96] transition-[transform,background-color,opacity] duration-100",
173+
"disabled:pointer-events-none disabled:opacity-50",
174+
styles[variant],
175+
)}
176+
>
177+
<span className="text-sm font-semibold">{label}</span>
178+
<span className="text-[11px] opacity-70">{sublabel}</span>
179+
</button>
180+
)
181+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import Link from "next/link"
5+
import { useDueSnippets } from "@/hooks/use-snippets"
6+
import { useSubmitReview } from "@/hooks/use-review"
7+
import { ReviewCard } from "@/components/review-card"
8+
import type { Snippet } from "@/types/snippet"
9+
10+
export function ReviewSession() {
11+
const { data: due = [], isLoading } = useDueSnippets()
12+
const submitReview = useSubmitReview()
13+
14+
const [index, setIndex] = useState(0)
15+
const [done, setDone] = useState(false)
16+
17+
const handleRate = async (snippet: Snippet, rating: 1 | 3 | 5) => {
18+
await submitReview.mutateAsync({
19+
snippetId: snippet.id,
20+
rating,
21+
currentEase: snippet.ease_factor,
22+
currentInterval: snippet.interval_days,
23+
currentReps: snippet.repetitions,
24+
})
25+
26+
if (index + 1 >= due.length) {
27+
setDone(true)
28+
} else {
29+
setIndex((i) => i + 1)
30+
}
31+
}
32+
33+
if (isLoading) {
34+
return (
35+
<div className="mx-auto max-w-2xl px-4 py-12 sm:px-6">
36+
<div className="space-y-4">
37+
<div className="h-2 w-24 rounded-full bg-muted animate-pulse" />
38+
<div className="h-48 rounded-2xl bg-muted animate-pulse" />
39+
</div>
40+
</div>
41+
)
42+
}
43+
44+
if (done || due.length === 0) {
45+
return (
46+
<div className="mx-auto max-w-2xl px-4 py-16 sm:px-6 flex flex-col items-center text-center gap-4">
47+
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/10">
48+
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden>
49+
<path
50+
d="M5 14.5l6.5 6.5L23 8"
51+
stroke="currentColor"
52+
strokeWidth="2.5"
53+
strokeLinecap="round"
54+
strokeLinejoin="round"
55+
className="text-primary"
56+
/>
57+
</svg>
58+
</div>
59+
60+
<div>
61+
<h1 className="font-heading text-2xl font-semibold tracking-tight">
62+
{done ? "Session complete!" : "All caught up"}
63+
</h1>
64+
<p className="mt-1.5 text-sm text-muted-foreground">
65+
{done
66+
? `You reviewed ${due.length} snippet${due.length !== 1 ? "s" : ""}. Come back tomorrow for the next round.`
67+
: "No snippets are due for review right now. Save more snippets to grow your library."}
68+
</p>
69+
</div>
70+
71+
<div className="flex gap-2 mt-2">
72+
<Link
73+
href="/dashboard"
74+
className="inline-flex h-9 items-center rounded-4xl bg-primary px-4 text-sm font-medium text-primary-foreground active:scale-[0.96] transition-[transform,opacity] duration-100"
75+
>
76+
Go to dashboard
77+
</Link>
78+
<Link
79+
href="/new"
80+
className="inline-flex h-9 items-center rounded-4xl border border-border bg-background px-4 text-sm font-medium text-foreground hover:bg-muted active:scale-[0.96] transition-[transform,background-color,opacity] duration-100"
81+
>
82+
Save a snippet
83+
</Link>
84+
</div>
85+
</div>
86+
)
87+
}
88+
89+
const current = due[index]
90+
91+
return (
92+
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6">
93+
<div className="mb-6">
94+
<h1 className="font-heading text-lg font-semibold tracking-tight">Review</h1>
95+
<p className="mt-0.5 text-sm text-muted-foreground">
96+
<span className="tabular-nums">{due.length}</span>{" "}
97+
snippet{due.length !== 1 ? "s" : ""} due today
98+
</p>
99+
</div>
100+
101+
<ReviewCard
102+
key={current.id}
103+
snippet={current}
104+
index={index}
105+
total={due.length}
106+
onRate={(rating) => handleRate(current, rating)}
107+
isSubmitting={submitReview.isPending}
108+
/>
109+
</div>
110+
)
111+
}

0 commit comments

Comments
 (0)