A PDF-to-flashcards study app with spaced repetition, a TikTok-style browse reel, a social feed, and a brutalist editorial aesthetic.
Developed by G🥷
- Drop a PDF — a textbook chapter, a lecture note, anything with a text layer.
- Kortex chunks it — semantic paragraph-aware chunker, ~6000 chars per chunk.
- An LLM generates cards — Gemini 2.5 Flash Lite with a constrained JSON schema returns teacher-tier Q/A, cloze, concept, and example cards.
- SM-2 schedules reviews — the same spaced-repetition algorithm Anki uses; cards resurface as they near forgetting.
- Learn / Practice / Browse / Feed — four distinct surfaces for intro, review, discovery, and social context.
| Layer | Choice | Why |
|---|---|---|
| Runtime / Framework | Next.js 14 (App Router) | Server components for DB access without a separate API layer, file-based routing, HMR, first-class TypeScript, and Route Handlers that double as the backend. |
| Language | TypeScript 5.6 | Type safety across the UI/server boundary. Zod validates at the seam where the LLM output crosses into typed land. |
| UI | React 18 | Industry default; pairs with Next's RSC model. |
| Styling | Tailwind CSS 3.4 | Utility-first lets the brutalist design system be expressed inline with zero naming overhead. Custom tokens in tailwind.config.ts (ink/bone/acid/lava/violet). |
| Animation | Framer Motion 11 | Declarative spring physics for card flips, mascot idle loops, the browse reel swipe, undo toasts, and confetti. |
| Validation | Zod 3 | One source of truth for schemas — used for API payloads, LLM output, and form input. |
| Database | better-sqlite3 | Synchronous, zero-network embedded DB. Fits the single-machine dev model perfectly and avoids the operational overhead of Postgres/MySQL for a portfolio-scale app. WAL journal mode for concurrent reads. |
| Auth | HMAC-signed session cookies + bcryptjs | Stateless — the cookie is the session (no session table). SHA-256 HMAC prevents tampering; timingSafeEqual prevents timing attacks. |
| LLM | Google Gemini 2.5 Flash Lite | Fast, cheap, honors structured JSON output. Schema-constrained responses eliminate a full class of parse errors. |
| PDF parsing | pdf-parse | Pure-JS text extraction. No native deps, works everywhere Node does. |
| Package manager | npm | Default, no build-chain drama. |
┌──────────────── Browser ──────────────────┐
│ React (client components) │
│ • /practice SM-2 review loop │
│ • /learn intro pass (graduation) │
│ • /browse TikTok-style reel │
│ • /feed event timeline │
│ • /upload PDF → cards flow │
└────────────┬──────────────────────────────┘
│ fetch()
▼
┌──────────── Next.js Route Handlers ──────────────┐
│ /api/auth/* signup/login/logout/me │
│ /api/upload PDF → chunk → LLM → cards │
│ /api/review SM-2 update + review_logs append │
│ /api/learn/* graduation flow │
│ /api/browse cross-deck shuffled stream │
│ /api/feed event log query │
│ /api/practice/[id] deck queue │
└────────────┬──────────────────────────────┘
│ prepared statements
▼
┌─────── better-sqlite3 (data/flashcards.db) ──────┐
│ users · decks · cards · review_logs · │
│ feed_events │
└──────────────────────────────────────────────────┘
Next.js 14 lets server components read the DB directly and stream HTML — no tRPC, no REST layer for page data. Only mutations and client-triggered fetches go through route handlers. This cuts the project from three mental contexts (client / server / shared types) to two (client / server, with types imported from both).
users (id, username UNIQUE, passwordHash, createdAt)
decks (id, name, sourceFilename, createdAt, userId)
cards (id, deckId, type, question, answer, concept,
easeFactor, intervalDays, repetitions, dueAt,
lapses, reviewCount, learnedAt, createdAt)
review_logs (id, cardId, quality, reviewedAt)
feed_events (id, userId, type, payload, createdAt)Design notes
- Nullable
decks.userId— legacy decks created before auth was added automatically get adopted by the first user to sign up (lib/auth.tssignup transaction). Avoids orphan data. learnedAtgates SM-2 — a card must pass through the/learnintro before the practice queue picks it up. Keeps "meet the card" separate from "test the card."feed_eventsas append-only log — signup, deck_created, card_learned, card_mastered. Storing events (not derived state) means the feed timeline is reconstructible and historical milestones don't disappear when state changes.- Indexes on
cards(deckId),cards(dueAt),review_logs(cardId),review_logs(reviewedAt),feed_events(userId, createdAt DESC). Every hot query has an index. - WAL mode —
journal_mode = WALlets readers not block writers. Matters when the upload route is inserting cards while the page is rendering deck stats.
The classic SuperMemo 2 algorithm (see lib/sm2.ts):
quality = {again: 0, hard: 3, good: 4, easy: 5}
if quality < 3:
lapsed, interval = 1 day, repetitions = 0
else:
if repetitions == 0: interval = 1 day
elif repetitions == 1: interval = 6 days
else: interval = round(interval * easeFactor)
repetitions += 1
easeFactor += 0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)
easeFactor = max(1.3, easeFactor)
Additions on top of vanilla SM-2:
masteryPercent— blendsrepetitions,easeFactor, andlapsesinto a 0–1 score for the progress bars. Pure SM-2 gives you "next due date" but not a readable "how well do they know this."cardBucket— derives anew | learning | shaky | masteredlabel. UsesintervalDays ≥ 21andrepetitions ≥ 3as the mastered threshold — i.e. the card has survived three successful reviews at increasing gaps.struggleScore—0.6 * lapseRate + 0.4 * easePenalty. Used to float shaky cards to the front of the practice queue before easier ones.
Model: gemini-2.5-flash-lite (overridable via GEMINI_MODEL env var)
Why this model: on Gemini's free tier, Flash Lite offers the best RPM-per-intelligence ratio — ~30 RPM and sub-second first-token latency. The tradeoff vs Flash is a slightly smaller context window, which doesn't matter here because each chunk is <2000 tokens.
Structured output: Gemini's responseSchema enforces the return shape:
{
type: "qa" | "cloze" | "concept" | "example",
question: string,
answer: string,
concept: string
}[]Zod then re-validates defensively on receipt. Two layers because the model occasionally violates the schema subtly (empty strings, mislabeled types).
Chunking (lib/chunker.ts):
- Paragraph-first split on
\n\n - Fall back to sentence split on over-long paragraphs
- Target 6000 chars, max 8000, with 240-char overlap so ideas that straddle a boundary aren't lost
- Bigger chunks = fewer API calls = less rate-limit thrash on the free tier
Rate-limit handling: the upload route concurrency-limits chunks in flight and paces requests to stay inside the free-tier RPM window. Quality filter (lib/quality.ts) drops cards with trivial answers or suspicious length ratios before insert.
Approach: HMAC-signed session cookies. The cookie IS the session.
token = base64url(payload) + "." + HMAC_SHA256(base64url(payload), SECRET)
payload = { uid, u: username, iat }- Stateless — no sessions table, no revocation store. For multi-device revocation you'd add one; for this scale it's unnecessary overhead.
timingSafeEqualon signature check prevents timing attacks.httpOnly,sameSite: lax,securein prod — standard cookie hardening.- bcrypt cost factor 10 for password hashing.
- Middleware (
middleware.ts) presence-checks the cookie to redirect pre-render. The route handlers still callrequireUser()which verifies the HMAC — middleware is just UX polish to avoid a flash of protected content.
Why not NextAuth / Clerk / Supabase Auth: bloat for a two-field form. The entire auth layer is ~100 LOC and has zero network dependencies.
Tokens (tailwind.config.ts):
ink #0B0B0F near-black
bone #F4EFE6 warm cream (light mode base)
charcoal #14131A dark mode base
slab #1E1D26 dark mode surface
acid #D4FF3B electric lime — primary accent
lava #FF4D26 hot orange — warnings, "slap"
violet #5B2BFF deep electric violet — highlights in dark mode
Typography: Fraunces (display, serif), Inter (body), JetBrains Mono (tags/kbd). All via next/font with CSS variables.
Brutalist signatures:
border-2everywhere, no rounded corners except onkbd- Hard offset shadow utilities:
.brut,.brut-acid,.brut-lava,.brut-violet .sticker— rotated label with hard shadow (rot-n3,rot-p3, etc.).highlight— 42% marker-pen band on text. Acid in light mode, violet in dark mode (cream text on acid washes out — violet keeps contrast).- Film-grain noise overlay (
body::before) for editorial warmth .slab-sm / .slab-md— offset shadow utilities that flip to cream in dark mode so edges stay visible on charcoal
Dark mode: darkMode: "class" on <html>. Theme toggle sets localStorage.theme and flips the class. An inline script in <head> applies the saved theme before first paint — no FOUC.
| Route | Kind | Purpose |
|---|---|---|
/ |
RSC | Home: streak, recent deck, deck browser |
/login, /signup |
Client | Auth forms |
/decks |
RSC | All decks with search, sort, mastery bars |
/deck/[id] |
RSC | Per-deck stats + card inspector |
/practice/[id] |
Client | SM-2 review loop with undo toast |
/learn/[id] |
Client | Intro pass — reveal → got it / skip |
/upload |
Client | PDF picker + fun-facts loader |
/browse |
Client | TikTok-style vertical reel across decks |
/feed |
Client | Activity timeline |
/api/auth/{signup,login,logout,me} |
Route handler | Auth endpoints |
/api/upload |
Route handler | PDF → chunks → LLM → card inserts |
/api/practice/[id] |
Route handler | Deck queue |
/api/review |
Route handler | SM-2 update + log |
/api/review/undo |
Route handler | Restore pre-review card snapshot |
/api/learn/[id] |
Route handler | Unlearned cards |
/api/learn/graduate |
Route handler | Mark card learned |
/api/browse |
Route handler | Cross-deck shuffled stream |
/api/feed |
Route handler | Event log query |
Breakpoints (Tailwind defaults):
sm:640px — phone → tablet boundarymd:768px — small tabletlg:1024px — desktopxl:1280px — wide desktop
Mobile specifics:
- Bottom tab bar (
components/MobileNav.tsx): Decks / Browse / Upload / Feed. Hiddensm:hidden.min-h-[56px]per tab for touch targets.paddingBottom: env(safe-area-inset-bottom)for notched devices. - Body has
pb-20 sm:pb-0so content doesn't hide under the tab bar. - Headlines scale
text-4xl sm:text-5xl lg:text-6xl xl:text-7xlon most pages — avoids the "seven-story tall headline on a phone" problem. - Mascot shown at 64px on small screens, 120px on sm, 150px on lg.
- Undo toast floats at
bottom-20 sm:bottom-6so it clears the mobile tab bar.
Tablet specifics:
- Decks grid:
sm:grid-cols-2 lg:grid-cols-3. - Hero layouts
flex-wrapso the mascot drops below the headline on narrow tablets. - Main padding
py-6 sm:py-10 lg:py-12for a gentler vertical rhythm on tablets.
Keyboard support (desktop):
Space— flip / toggle1–4— rate again/hard/good/easy (practice & browse)↑↓/jk— navigate browse reelG / S— got it / skip on /learnU— undo last review- Shortcuts modal (
?) lists all bindings
A brain-blob-in-a-bandana SVG mascot (components/Mascot.tsx) with six moods: wave | think | cheer | sleep | study | flex.
Why a mascot:
- Empty states feel warmer. "No decks yet" + Kort waving ≫ "No decks yet" alone.
- Reinforces brand identity; makes the app feel like a product, not a CS project.
- Uses
currentColorfor strokes so it adapts to dark mode without a separate asset.
Technical notes:
- Pure SVG, ~120 LOC. No external asset.
- Framer Motion drives mood-specific idle loops (wave rotates, cheer bounces, sleep emits
zs). stillprop disables animation — used in the top-left logo so the header doesn't have a distracting bobbing head.- Body is
lava(orange), bandana isacid(lime). Deliberately clashes for brand memorability.
Placements:
- Header logo (top-left, 36px, static)
- Home hero (mobile: inline 64px, desktop: 150px
study) - Decks / Feed / Upload / Login / Signup heroes (110px)
- Empty states across Browse, Learn, Practice
- Done states ("you cooked" —
flexmood)
A 5-second undo toast after every rating (practice page). Why: SM-2 punishes "again" ratings with a full interval reset. One mis-click shouldn't cost a week of progress. Implementation (/api/review/undo) stores a snapshot of the pre-review card state on the response and restores it on undo — the re-queued copy is also removed from the tail of the session queue.
Pure SM-2 throws you into testing the moment a card exists. That's hostile for brand-new material. /learn adds a "meet the card" pass — reveal → "got it" graduates the card into the SM-2 pool, "skip" doesn't. The cards.learnedAt timestamp is the gate.
Practice is goal-oriented (clear the due queue). Browse is discovery-oriented (scroll, tap to flip, optionally rate). TikTok-style swipe on mobile, ↑↓/jk on desktop. Ratings still write through to SM-2 — it's a legitimate review surface, just with a different information diet.
Single-user apps are lonely. The feed shows your milestones alongside a sample of other users' events — just enough to feel like there's a room. Events are append-only; UI never mutates them.
Uploads take 10–60 seconds on free-tier rate limits. A spinner is insulting. The loader rotates study-adjacent trivia to reward the wait.
npm install
cp .env.example .env.local # add GEMINI_API_KEY and AUTH_SECRET
npm run dev # http://localhost:3000Env vars:
GEMINI_API_KEY— required. Get one at aistudio.google.com.GEMINI_MODEL— optional. Defaults togemini-2.5-flash-lite.AUTH_SECRET— required in prod. Random 32+ char string for HMAC signing.
Scripts:
npm run dev— Next dev servernpm run build— production buildnpm start— serve the production buildnpm test— node --test onlib/**/*.test.tsnpm run lint— next lint
| Trade-off | Why accepted |
|---|---|
| SQLite over Postgres | Single-machine dev model. Zero ops. Portable file. |
| Gemini free tier over paid GPT-4 | Cost control during development; quality is sufficient with schema constraints. |
| Custom HMAC auth over NextAuth | 100 LOC vs. a dependency that covers cases we don't need. |
| No tests on the UI | Playwright in a future pass; SM-2 has unit tests (the hard logic). |
| No image/video cards | Scope. The PDF → text → card pipeline is already large. |
| No mobile app | PWA-able later; responsive web covers the primary use case. |
- Export deck as Anki
.apkg - Mobile PWA with offline review queue
- Multi-modal cards (diagrams extracted from PDFs)
- Per-deck difficulty calibration using IRT
- Shared decks with fork semantics
- Scheduled "nudge" notifications
Developed by G🥷