Freezmo: self-hosted notes-to-activities study app
Next.js 15 app that turns photos, PDFs, DOCX/PPTX and text into a swipeable feed of AI-generated activities (flashcards, multiple choice, true/false, fill-in-the-blank, matching, ordering, short answer) using the user's own Ollama models. Highlights: - All AI calls run in server route handlers; the browser never talks to Ollama. - Bring-your-own-key mode (default): the key lives only in the browser and is sent per-request; server, local-daemon and shared-key modes also supported. - Client-side document extraction (pdf.js, mammoth, jszip) with scanned-PDF page rendering for the vision model; notes stay in their original language. - ChatGPT-style shell: collapsible sidebar / mobile drawer, neutral token-based light + dark themes, folder organisation, PWA (manifest, SW, offline page). - Strict JSON validation with zod + auto-repair, rate limiting, and request guards (cross-origin, body-size, API-key sanitising). - setup.sh bootstraps deps, .env.local and a production build. Gitignore excludes .env*, the generated pdf.js worker and local scratch files.
This commit is contained in:
parent
3baa0fa8ce
commit
3ea2cea100
83 changed files with 10909 additions and 165 deletions
53
components/ApiKeyBanner.tsx
Normal file
53
components/ApiKeyBanner.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Tells the user when they need to add (or fix) their own Ollama API key.
|
||||
* Renders nothing in local-daemon / server-key setups.
|
||||
*/
|
||||
export function ApiKeyBanner() {
|
||||
const { health, loading } = useHealth();
|
||||
if (!health || loading) return null;
|
||||
if (health.authMode !== "user-key") return null;
|
||||
|
||||
const rejected = health.keyAccepted === false;
|
||||
if (!health.needsKey && !rejected) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={
|
||||
rejected
|
||||
? "glass-panel animate-rise flex flex-wrap items-center gap-3 rounded-[1.35rem] p-4 text-sm text-rose-900 dark:text-rose-100"
|
||||
: "glass-panel animate-rise flex flex-wrap items-center gap-3 rounded-[1.35rem] p-4 text-sm text-frost-900 dark:text-frost-50"
|
||||
}
|
||||
>
|
||||
<span aria-hidden className="text-xl">
|
||||
🔑
|
||||
</span>
|
||||
<p className="min-w-48 flex-1">
|
||||
<span className="font-bold">
|
||||
{rejected ? "Your Ollama API key was rejected." : "Add your Ollama API key to generate decks."}
|
||||
</span>{" "}
|
||||
<span className="font-normal">
|
||||
Create one at{" "}
|
||||
<a
|
||||
className="font-semibold underline underline-offset-2"
|
||||
href="https://ollama.com/settings/keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
ollama.com/settings/keys
|
||||
</a>{" "}
|
||||
and paste it in settings. It stays in this browser — the server never stores it.
|
||||
</span>
|
||||
</p>
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
Add API key
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
components/AppHeader.tsx
Normal file
85
components/AppHeader.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { GearIcon, MenuIcon, SnowflakeIcon } from "@/components/icons";
|
||||
import { SettingsDialog } from "@/components/SettingsDialog";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { sidebarOpen } from "@/lib/nav";
|
||||
import { OPEN_SETTINGS_EVENT } from "@/lib/ui-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Minimal, sticky frosted top bar: brand on the left, quiet actions on the
|
||||
* right. The phone layout keeps the brand compact and relies on the tab bar
|
||||
* for navigation.
|
||||
*/
|
||||
export function AppHeader({ className, compact = false }: { className?: string; compact?: boolean }) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onOpen = () => setSettingsOpen(true);
|
||||
window.addEventListener(OPEN_SETTINGS_EVENT, onOpen);
|
||||
return () => window.removeEventListener(OPEN_SETTINGS_EVENT, onOpen);
|
||||
}, []);
|
||||
|
||||
// Restore the desktop sidebar width preference once, on the client.
|
||||
useEffect(() => {
|
||||
void import("@/components/Sidebar").then((module) => module.sidebarCollapse.hydrate());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={cn(
|
||||
"glass sticky top-0 z-40 -mx-4 mb-6 px-4 lg:hidden sm:-mx-6 sm:px-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sidebarOpen.toggle()}
|
||||
aria-label="Open menu"
|
||||
className="mr-1 grid size-9 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<MenuIcon className="size-5" />
|
||||
</button>
|
||||
<Link href="/" className="group flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label="Freezmo home">
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-8 place-items-center rounded-[0.6rem] bg-[var(--text)] text-[var(--page)] transition-transform group-hover:scale-[1.04]"
|
||||
>
|
||||
<SnowflakeIcon className="size-[18px]" />
|
||||
</span>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">
|
||||
Freezmo
|
||||
</span>
|
||||
{!compact ? (
|
||||
<span className="hidden text-[13px] text-[var(--text-subtle)] sm:inline">
|
||||
study feed
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
aria-label="Open settings and Ollama status"
|
||||
title="Settings"
|
||||
className="inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<GearIcon className="size-[18px]" />
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
407
components/DeckCard.tsx
Normal file
407
components/DeckCard.tsx
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
|
||||
import { createFolder as makeFolder, folderName, listFolders } from "@/lib/folders";
|
||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
|
||||
import { cn, formatDate, truncate } from "@/lib/utils";
|
||||
import { DownloadIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Busy = null | "more" | "regenerate";
|
||||
|
||||
const ADD_OPTIONS = [3, 5, 8];
|
||||
|
||||
/**
|
||||
* One deck in the dashboard grid: title, muted metadata, tags, summary and a
|
||||
* prominent Study CTA with quiet secondary actions.
|
||||
*/
|
||||
export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: string | undefined) => void }) {
|
||||
const folders = listFolders();
|
||||
const [busy, setBusy] = useState<Busy>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [title, setTitle] = useState(deck.title);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [addCount, setAddCount] = useState(5);
|
||||
const [newFolder, setNewFolder] = useState<string | null>(null);
|
||||
|
||||
const hasSource = deck.sourceText.trim().length > 20;
|
||||
|
||||
const saveTitle = () => {
|
||||
const trimmed = title.trim();
|
||||
if (trimmed && trimmed !== deck.title) renameDeck(deck.id, trimmed);
|
||||
else setTitle(deck.title);
|
||||
setRenaming(false);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
const addMore = async () => {
|
||||
if (!hasSource) {
|
||||
setError("This deck has no stored source notes, so it can't be extended.");
|
||||
return;
|
||||
}
|
||||
setBusy("more");
|
||||
setError(null);
|
||||
try {
|
||||
const { deck: generated } = await generateDeck({
|
||||
text: deck.sourceText,
|
||||
difficulty: deck.difficulty,
|
||||
language: deck.language,
|
||||
count: addCount,
|
||||
mode: "more",
|
||||
existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length },
|
||||
});
|
||||
appendActivities(deck.id, materialiseActivities(generated.activities, deck.difficulty));
|
||||
setNotice(`Added ${generated.activities.length} activities`);
|
||||
setMenuOpen(false);
|
||||
window.setTimeout(() => setNotice(null), 3000);
|
||||
} catch (addError) {
|
||||
setError(addError instanceof ApiError ? addError.message : "Could not add more activities.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerate = async () => {
|
||||
if (!hasSource) {
|
||||
setError("This deck has no stored source notes, so it can't be regenerated.");
|
||||
return;
|
||||
}
|
||||
setBusy("regenerate");
|
||||
setError(null);
|
||||
try {
|
||||
const { deck: generated } = await generateDeck({
|
||||
text: deck.sourceText,
|
||||
difficulty: deck.difficulty,
|
||||
language: deck.language,
|
||||
count: Math.max(8, deck.activities.length),
|
||||
});
|
||||
upsertDeck({
|
||||
...deck,
|
||||
title: generated.title || deck.title,
|
||||
subject: generated.subject ?? deck.subject,
|
||||
summary: generated.summary ?? deck.summary,
|
||||
language: generated.language ?? deck.language,
|
||||
activities: materialiseActivities(generated.activities, deck.difficulty),
|
||||
attempts: 0,
|
||||
bestScore: undefined,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
setNotice("Deck regenerated");
|
||||
setMenuOpen(false);
|
||||
window.setTimeout(() => setNotice(null), 3000);
|
||||
} catch (regenerateError) {
|
||||
setError(regenerateError instanceof ApiError ? regenerateError.message : "Could not regenerate the deck.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const menuRow =
|
||||
"flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-[13px] font-medium text-[var(--text)] transition hover:bg-[var(--hover)]";
|
||||
|
||||
return (
|
||||
<li className="card card-hover group relative flex flex-col p-5">
|
||||
{/* header ------------------------------------------------------- */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
|
||||
Deck title
|
||||
</label>
|
||||
<input
|
||||
id={`rename-${deck.id}`}
|
||||
value={title}
|
||||
autoFocus
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") saveTitle();
|
||||
if (event.key === "Escape") {
|
||||
setTitle(deck.title);
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
|
||||
/>
|
||||
<Button size="sm" onClick={saveTitle}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<h3
|
||||
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
|
||||
title={deck.title}
|
||||
>
|
||||
{truncate(deck.title, 90)}
|
||||
</h3>
|
||||
)}
|
||||
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)]">
|
||||
{formatDate(deck.updatedAt)}
|
||||
<span aria-hidden> · </span>
|
||||
{deck.activities.length} activities
|
||||
{deck.attempts > 0 ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
{deck.attempts} {deck.attempts === 1 ? "run" : "runs"}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Deck actions"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((value) => !value)}
|
||||
className={cn(
|
||||
"grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
menuOpen && "bg-[var(--hover)] text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* tags --------------------------------------------------------- */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{deck.subject ? <Tag>{deck.subject}</Tag> : null}
|
||||
<Tag>
|
||||
<span className={difficultyClass(deck.difficulty)}>{deck.difficulty}</span>
|
||||
</Tag>
|
||||
{deck.folderId ? (
|
||||
<Tag>
|
||||
<FolderIcon className="size-3.5 opacity-70" />
|
||||
{folderName(deck.folderId)}
|
||||
</Tag>
|
||||
) : null}
|
||||
{deck.bestScore !== undefined ? (
|
||||
<Tag>
|
||||
Best <span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* summary ------------------------------------------------------ */}
|
||||
{deck.summary ? (
|
||||
<p className="mt-3 line-clamp-2 text-[13.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
{truncate(deck.summary, 160)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* status ------------------------------------------------------- */}
|
||||
{busy ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
<ProgressBar value={1} max={2} label={busy === "more" ? "Adding activities" : "Regenerating deck"} />
|
||||
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">
|
||||
{busy === "more" ? "Adding new activities…" : "Regenerating the whole deck…"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<p className="mt-3 rounded-xl bg-emerald-500/10 px-3 py-2 text-[12.5px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="mt-3 rounded-xl bg-rose-500/10 px-3 py-2 text-[12.5px] font-medium text-rose-700 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* single action ------------------------------------------------ */}
|
||||
<div className="mt-5">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm">
|
||||
<PlayIcon className="size-4" />
|
||||
Study
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* actions panel ------------------------------------------------ */}
|
||||
{menuOpen ? (
|
||||
<div
|
||||
data-testid="deck-actions"
|
||||
className="animate-rise mt-3 space-y-1 border-t border-[var(--hairline)] pt-3"
|
||||
>
|
||||
<button type="button" className={menuRow} onClick={() => setRenaming(true)} disabled={Boolean(busy)}>
|
||||
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]">
|
||||
✎
|
||||
</span>
|
||||
Rename
|
||||
</button>
|
||||
<button type="button" className={menuRow} onClick={() => downloadDeck(deck)} disabled={Boolean(busy)}>
|
||||
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={menuRow}
|
||||
onClick={() => void regenerate()}
|
||||
disabled={Boolean(busy) || !hasSource}
|
||||
>
|
||||
<RefreshIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
Regenerate deck
|
||||
</button>
|
||||
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
|
||||
Really delete
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={cn(menuRow, "text-rose-600 dark:text-rose-300")} onClick={() => setConfirmDelete(true)}>
|
||||
<span aria-hidden className="w-4 text-center">
|
||||
⌫
|
||||
</span>
|
||||
Delete deck
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* folder ---------------------------------------------------- */}
|
||||
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Folder
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...folders].map((folder) => {
|
||||
const current = (deck.folderId ?? UNORGANIZED_ID) === folder.id;
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
aria-pressed={current}
|
||||
onClick={() => {
|
||||
onMove?.(folder.id === UNORGANIZED_ID ? undefined : folder.id);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[12px] font-medium transition",
|
||||
current
|
||||
? "bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
|
||||
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
{folder.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{newFolder === null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewFolder("")}
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
+ New folder
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-0.5 pl-2.5 pr-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={newFolder}
|
||||
onChange={(event) => setNewFolder(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") setNewFolder(null);
|
||||
if (event.key === "Enter" && newFolder.trim()) {
|
||||
const created = makeFolder(newFolder);
|
||||
if ("folder" in created) {
|
||||
onMove?.(created.folder.id);
|
||||
setNewFolder(null);
|
||||
setMenuOpen(false);
|
||||
} else {
|
||||
setError(created.error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-label="New folder name"
|
||||
placeholder="Folder name"
|
||||
maxLength={60}
|
||||
className="w-24 bg-transparent text-[12px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary h-6 px-2.5 text-[11px]"
|
||||
onClick={() => {
|
||||
const created = makeFolder(newFolder);
|
||||
if ("folder" in created) {
|
||||
onMove?.(created.folder.id);
|
||||
setNewFolder(null);
|
||||
setMenuOpen(false);
|
||||
} else {
|
||||
setError(created.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* add more -------------------------------------------------- */}
|
||||
<div className="mt-1 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Add more activities
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{ADD_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setAddCount(option)}
|
||||
aria-pressed={addCount === option}
|
||||
className={cn(
|
||||
"min-w-9 rounded-full px-2.5 py-1 text-[12px] font-medium transition",
|
||||
addCount === option
|
||||
? "bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
|
||||
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
+{option}
|
||||
</button>
|
||||
))}
|
||||
<Button size="sm" onClick={() => void addMore()} disabled={Boolean(busy) || !hasSource} loading={busy === "more"}>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
{!hasSource ? (
|
||||
<p className="px-1 pt-2 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
No stored source text for this deck (imported decks don't carry it).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function difficultyClass(difficulty: Deck["difficulty"]): string {
|
||||
if (difficulty === "easy") return "diff-easy";
|
||||
if (difficulty === "hard") return "diff-hard";
|
||||
return "diff-medium";
|
||||
}
|
||||
|
||||
/** Neutral pill; colour is only ever used on the text inside it. */
|
||||
function Tag({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--surface-sunken)] px-2.5 py-1 text-[12px] font-medium leading-none text-[var(--text-muted)]">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
127
components/FolderBar.tsx
Normal file
127
components/FolderBar.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { FolderIcon, PlusIcon } from "@/components/icons";
|
||||
import { createFolder } from "@/lib/folders";
|
||||
import type { Folder } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const ALL_FOLDERS = "all";
|
||||
|
||||
/**
|
||||
* Folder switcher. Reads like a row of quiet pills (ChatGPT sidebar items):
|
||||
* the active one is filled, the rest are text with a hover wash.
|
||||
*/
|
||||
export function FolderBar({
|
||||
folders,
|
||||
counts,
|
||||
selected,
|
||||
onSelect,
|
||||
onCreated,
|
||||
className,
|
||||
}: {
|
||||
folders: Folder[];
|
||||
counts: Record<string, number>;
|
||||
selected: string;
|
||||
onSelect: (id: string) => void;
|
||||
onCreated: (id: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const submit = () => {
|
||||
const result = createFolder(draft);
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onCreated(result.folder.id);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const pill = (active: boolean) =>
|
||||
cn(
|
||||
"inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium transition",
|
||||
active
|
||||
? "bg-[var(--surface-sunken)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("no-scrollbar -mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0", className)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(ALL_FOLDERS)}
|
||||
aria-pressed={selected === ALL_FOLDERS}
|
||||
className={pill(selected === ALL_FOLDERS)}
|
||||
>
|
||||
All decks
|
||||
</button>
|
||||
|
||||
{folders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(folder.id)}
|
||||
aria-pressed={selected === folder.id}
|
||||
className={pill(selected === folder.id)}
|
||||
>
|
||||
<FolderIcon className="size-4 opacity-80" />
|
||||
{folder.name}
|
||||
<span className={cn("tabular-nums", selected === folder.id ? "opacity-70" : "text-[var(--text-subtle)]")}>
|
||||
{counts[folder.id] ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{creating ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-1 pl-3 pr-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") submit();
|
||||
if (event.key === "Escape") {
|
||||
setCreating(false);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Folder name"
|
||||
aria-label="New folder name"
|
||||
maxLength={60}
|
||||
className="w-28 bg-transparent text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="btn-primary h-7 px-3 text-[12px]"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
New folder
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error ? <p className="pt-1 text-xs font-medium text-rose-600 dark:text-rose-300">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
components/InstallHint.tsx
Normal file
64
components/InstallHint.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ShareIcon } from "@/components/icons";
|
||||
|
||||
const DISMISS_KEY = "freezmo.installHintDismissed";
|
||||
|
||||
/**
|
||||
* iOS has no `beforeinstallprompt`, so the only way to explain installing the
|
||||
* PWA is to point at the Share sheet. Shown once, only in iOS Safari, only
|
||||
* outside standalone mode.
|
||||
*/
|
||||
export function InstallHint() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (window.localStorage.getItem(DISMISS_KEY) === "1") return;
|
||||
const ua = window.navigator.userAgent;
|
||||
const isIos = /iPad|iPhone|iPod/.test(ua) || (ua.includes("Macintosh") && "ontouchend" in document);
|
||||
const isSafari = /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua);
|
||||
const standalone =
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
(window.navigator as unknown as { standalone?: boolean }).standalone === true;
|
||||
if (isIos && isSafari && !standalone) setVisible(true);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
setVisible(false);
|
||||
try {
|
||||
window.localStorage.setItem(DISMISS_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-panel animate-rise flex items-start gap-3 rounded-[1.35rem] p-3.5 text-sm">
|
||||
<ShareIcon className="mt-0.5 size-5 shrink-0 text-accent-600 dark:text-accent-300" />
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-frost-950 dark:text-frost-50">Install Freezmo</p>
|
||||
<p className="text-frost-600 dark:text-frost-300">
|
||||
Tap <span className="font-medium">Share</span> then{" "}
|
||||
<span className="font-medium">Add to Home Screen</span> for a full-screen, app-like
|
||||
experience that works without the browser chrome.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss install hint"
|
||||
className="rounded-lg px-2 py-1 text-frost-400 transition-colors hover:bg-[var(--surface-sunken)]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
components/LibraryScreen.tsx
Normal file
332
components/LibraryScreen.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { ApiKeyBanner } from "@/components/ApiKeyBanner";
|
||||
import { DeckCard } from "@/components/DeckCard";
|
||||
import { FolderBar } from "@/components/FolderBar";
|
||||
import { FolderIcon, UploadIcon } from "@/components/icons";
|
||||
import { Button, EmptyState } from "@/components/ui";
|
||||
import { folderStore, renameFolder } from "@/lib/folders";
|
||||
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||
import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Dashboard: page title, folder pills, then a spacious responsive grid of
|
||||
* deck cards grouped by folder.
|
||||
*/
|
||||
export function LibraryScreen() {
|
||||
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
||||
const folders = useSyncExternalStore(
|
||||
folderStore.subscribe,
|
||||
folderStore.getSnapshot,
|
||||
folderStore.getServerSnapshot,
|
||||
);
|
||||
const selected = useSelectedFolder();
|
||||
const setSelected = folderSelection.set;
|
||||
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
||||
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const importRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const sorted = useMemo(() => sortDecks(decks), [decks]);
|
||||
const folderList = useMemo(
|
||||
() => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })),
|
||||
[folders],
|
||||
);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const folder of folderList) map[folder.id] = 0;
|
||||
for (const deck of decks) {
|
||||
const key = deck.folderId && map[deck.folderId] !== undefined ? deck.folderId : UNORGANIZED_ID;
|
||||
map[key] = (map[key] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
}, [decks, folderList]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const unorganized: { id: string; name: string; decks: Deck[]; virtual: boolean } = {
|
||||
id: UNORGANIZED_ID,
|
||||
name: "Unorganized",
|
||||
decks: sorted.filter((deck) => !deck.folderId),
|
||||
virtual: true,
|
||||
};
|
||||
|
||||
if (selected === UNORGANIZED_ID) return [unorganized];
|
||||
if (selected !== ALL_FOLDERS) {
|
||||
const folder = folderList.find((entry) => entry.id === selected);
|
||||
if (!folder) return [unorganized];
|
||||
return [
|
||||
{ id: folder.id, name: folder.name, decks: sorted.filter((deck) => deck.folderId === folder.id), virtual: false },
|
||||
];
|
||||
}
|
||||
|
||||
const named = folderList.map((folder) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
decks: sorted.filter((deck) => deck.folderId === folder.id),
|
||||
virtual: false,
|
||||
}));
|
||||
return [...named, unorganized];
|
||||
}, [folderList, selected, sorted]);
|
||||
|
||||
const totalActivities = decks.reduce((sum, deck) => sum + deck.activities.length, 0);
|
||||
const bestEver = decks.reduce((best, deck) => Math.max(best, deck.bestScore ?? 0), 0);
|
||||
const played = decks.some((deck) => deck.attempts > 0);
|
||||
|
||||
const handleImport = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const result = importDeck(text);
|
||||
if ("error" in result) setMessage({ tone: "error", text: result.error });
|
||||
else setMessage({ tone: "ok", text: `Imported “${result.deck.title}”.` });
|
||||
} catch {
|
||||
setMessage({ tone: "error", text: "Could not read that file." });
|
||||
}
|
||||
};
|
||||
|
||||
const removeFolderEverywhere = (folderId: string) => {
|
||||
deleteFolder(folderId);
|
||||
setConfirmDelete(null);
|
||||
setSelected(ALL_FOLDERS);
|
||||
setMessage({ tone: "ok", text: "Folder deleted — its decks moved to Unorganized." });
|
||||
};
|
||||
|
||||
const isEmpty = sorted.length === 0 && folderList.length === 0;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* page header ---------------------------------------------------- */}
|
||||
<section className="mb-6 space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-[24px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[26px]">
|
||||
Your icebox
|
||||
</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
|
||||
Decks frozen from your notes, saved in this browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" size="md" onClick={() => importRef.current?.click()}>
|
||||
<UploadIcon className="size-4" />
|
||||
Import JSON
|
||||
</Button>
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void handleImport(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary stats folded into the header as quiet text on desktop,
|
||||
where the sidebar already shows per-folder counts. */}
|
||||
{decks.length > 0 ? (
|
||||
<p className="text-[13px] text-[var(--text-subtle)] lg:hidden">
|
||||
{decks.length} {decks.length === 1 ? "deck" : "decks"}
|
||||
<span aria-hidden> · </span>
|
||||
{folderList.length} {folderList.length === 1 ? "folder" : "folders"}
|
||||
<span aria-hidden> · </span>
|
||||
{totalActivities} activities
|
||||
{played ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
best {bestEver}%
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Mobile folder switcher; the sidebar covers desktop. */}
|
||||
<FolderBar className="lg:hidden"
|
||||
folders={folderList}
|
||||
counts={counts}
|
||||
selected={selected}
|
||||
onSelect={(id) => {
|
||||
setSelected(id);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCreated={(id) => {
|
||||
setSelected(id);
|
||||
setMessage({
|
||||
tone: "ok",
|
||||
text: "Folder created — decks you freeze while it's selected land here.",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<ApiKeyBanner />
|
||||
|
||||
{message ? (
|
||||
<p
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] px-3.5 py-2.5 text-[13px] font-medium",
|
||||
message.tone === "ok" ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300",
|
||||
)}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* deck grid ------------------------------------------------------ */}
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
title="No decks yet"
|
||||
action={
|
||||
<Link href="/create" className="btn-primary h-10 px-4 text-sm">
|
||||
Make your first deck
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
Freeze a page of notes into a feed of flashcards and quizzes.
|
||||
<span className="mt-3 block text-[12.5px] text-[var(--text-subtle)]">
|
||||
Using your own Ollama models —{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSettings}
|
||||
className="font-medium text-[var(--text-muted)] underline underline-offset-2 hover:text-[var(--text)]"
|
||||
>
|
||||
check the connection in Settings
|
||||
</button>
|
||||
.
|
||||
</span>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{groups.map((group) => {
|
||||
const isEditing = editingFolder?.id === group.id;
|
||||
return (
|
||||
<section key={group.id} aria-label={group.name}>
|
||||
{/* group header -------------------------------------- */}
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<FolderIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
||||
|
||||
{isEditing && editingFolder ? (
|
||||
<span className="flex flex-1 items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={editingFolder.name}
|
||||
onChange={(event) => setEditingFolder({ ...editingFolder, name: event.target.value })}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") {
|
||||
const result = renameFolder(editingFolder.id, editingFolder.name);
|
||||
if ("error" in result) setMessage({ tone: "error", text: result.error });
|
||||
else setEditingFolder(null);
|
||||
}
|
||||
if (event.key === "Escape") setEditingFolder(null);
|
||||
}}
|
||||
aria-label="Folder name"
|
||||
maxLength={60}
|
||||
className="min-w-32 max-w-xs flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const result = renameFolder(editingFolder.id, editingFolder.name);
|
||||
if ("error" in result) setMessage({ tone: "error", text: result.error });
|
||||
else setEditingFolder(null);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(group.virtual ? UNORGANIZED_ID : group.id)}
|
||||
className="truncate text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]"
|
||||
>
|
||||
{group.name}
|
||||
<span className="ml-2 text-[13px] font-normal tabular-nums text-[var(--text-subtle)]">
|
||||
{group.decks.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!group.virtual && !isEditing ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingFolder({ id: group.id, name: group.name })}
|
||||
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
{confirmDelete === group.id ? (
|
||||
<>
|
||||
<Button size="sm" variant="danger" onClick={() => removeFolderEverywhere(group.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(group.id)}
|
||||
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{confirmDelete === group.id ? (
|
||||
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
||||
The {group.decks.length} deck{group.decks.length === 1 ? "" : "s"} inside will move to
|
||||
Unorganized — nothing is deleted.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{group.decks.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--hairline)] px-5 py-8 text-center text-[13px] text-[var(--text-subtle)]">
|
||||
{group.virtual
|
||||
? "Nothing unfiled right now."
|
||||
: "Empty folder — freeze a deck into it or move one over from Unorganized."}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
|
||||
{group.decks.map((deck) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
onMove={(folderId) => {
|
||||
upsertDeck({ ...deck, folderId });
|
||||
setMessage({
|
||||
tone: "ok",
|
||||
text: folderId ? "Deck moved." : "Deck moved to Unorganized.",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
components/ResultsScreen.tsx
Normal file
205
components/ResultsScreen.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import type { Activity, Deck } from "@/lib/schemas";
|
||||
import { downloadDeck } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon } from "@/components/icons";
|
||||
import { Button, Chip } from "@/components/ui";
|
||||
|
||||
import type { AttemptResult } from "./StudyFeed";
|
||||
|
||||
export function answerFor(activity: Activity): string {
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return activity.back;
|
||||
case "multiple_choice":
|
||||
return activity.options[activity.correctIndex];
|
||||
case "true_false":
|
||||
return activity.answer ? "True" : "False";
|
||||
case "fill_blank":
|
||||
return activity.answer;
|
||||
case "matching":
|
||||
return activity.pairs.map((pair) => `${pair.term} → ${pair.definition}`).join("; ");
|
||||
case "ordering":
|
||||
return activity.items.map((item, index) => `${index + 1}. ${item}`).join(" ");
|
||||
case "short_answer":
|
||||
return activity.referenceAnswer;
|
||||
}
|
||||
}
|
||||
|
||||
export function promptFor(activity: Activity): string {
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return activity.front;
|
||||
case "multiple_choice":
|
||||
return activity.question;
|
||||
case "true_false":
|
||||
return activity.statement;
|
||||
case "fill_blank":
|
||||
return activity.sentence.replace(/_{2,}/, "____");
|
||||
case "matching":
|
||||
return activity.instruction;
|
||||
case "ordering":
|
||||
return activity.instruction;
|
||||
case "short_answer":
|
||||
return activity.question;
|
||||
}
|
||||
}
|
||||
|
||||
export function ResultsScreen({
|
||||
deck,
|
||||
xp,
|
||||
bestStreak,
|
||||
total,
|
||||
correct,
|
||||
missed,
|
||||
results,
|
||||
retryMode,
|
||||
onRetryMissed,
|
||||
onRestart,
|
||||
}: {
|
||||
deck: Deck;
|
||||
xp: number;
|
||||
bestStreak: number;
|
||||
total: number;
|
||||
correct: number;
|
||||
missed: Activity[];
|
||||
results: Record<string, AttemptResult>;
|
||||
retryMode: boolean;
|
||||
onRetryMissed: () => void;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
const perfect = missed.length === 0 && total > 0;
|
||||
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="card flex flex-col items-center gap-3 p-6 text-center"
|
||||
>
|
||||
<ScoreRing percent={percent} tone={tone} />
|
||||
<h1 className="text-2xl font-semibold text-frost-950 text-balance dark:text-frost-50">
|
||||
{perfect ? "Perfect freeze! 🧊" : percent >= 60 ? "Nicely done!" : "Good start — keep going!"}
|
||||
</h1>
|
||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||
{perfect
|
||||
? `You aced all ${total} activities in “${deck.title}”.`
|
||||
: `You got ${correct} of ${total} right${retryMode ? " on the retry run" : ""}.`}
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex flex-wrap justify-center gap-2">
|
||||
<Chip tone="accent">
|
||||
<BoltIcon className="size-3.5" /> {xp} XP earned
|
||||
</Chip>
|
||||
<Chip tone="warning">
|
||||
<FlameIcon className="size-3.5" /> Best streak {bestStreak}
|
||||
</Chip>
|
||||
<Chip tone="neutral">
|
||||
{[deck.difficulty].map((value) => value[0].toUpperCase() + value.slice(1))[0]} difficulty
|
||||
</Chip>
|
||||
{deck.bestScore !== undefined && !retryMode ? (
|
||||
<Chip tone="success">🏆 Best {deck.bestScore}%</Chip>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{missed.length > 0 ? (
|
||||
<div className="card space-y-3 p-5">
|
||||
<h2 className="flex items-center gap-2 text-base font-bold text-frost-950 dark:text-frost-50">
|
||||
<SnowflakeIcon className="size-4" /> Missed items ({missed.length})
|
||||
</h2>
|
||||
<ul className="space-y-3">
|
||||
{missed.map((activity) => (
|
||||
<li
|
||||
key={activity.id}
|
||||
className="rounded-2xl border border-white/50 bg-white/45 backdrop-blur-xl p-3 text-sm dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<p className="font-semibold text-frost-950 dark:text-frost-50">{promptFor(activity)}</p>
|
||||
{results[activity.id]?.userAnswer ? (
|
||||
<p className="mt-1 text-xs text-rose-600 dark:text-rose-300">
|
||||
Your answer: {results[activity.id]?.userAnswer}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
<span className="font-bold">Answer: </span>
|
||||
{answerFor(activity)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{missed.length > 0 ? (
|
||||
<Button size="lg" onClick={onRetryMissed}>
|
||||
<RefreshIcon className="size-5" /> Retry missed ones ({missed.length})
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" variant="secondary" onClick={onRestart}>
|
||||
<RefreshIcon className="size-5" /> Study this deck again
|
||||
</Button>
|
||||
)}
|
||||
{missed.length > 0 ? (
|
||||
<Button size="lg" variant="secondary" onClick={onRestart}>
|
||||
<RefreshIcon className="size-5" /> Study the whole deck again
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
|
||||
<DownloadIcon className="size-4" /> Export JSON
|
||||
</Button>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-white/80 px-4 py-2.5 text-sm font-semibold text-frost-800 transition hover:bg-white dark:bg-white/10 dark:text-frost-100 dark:hover:bg-white/20"
|
||||
>
|
||||
<HomeIcon className="size-4" /> Library
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "frost" | "amber" }) {
|
||||
const radius = 52;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference * (1 - Math.min(100, Math.max(0, percent)) / 100);
|
||||
const colors: Record<string, string> = {
|
||||
emerald: "stroke-emerald-500",
|
||||
frost: "stroke-accent-500",
|
||||
amber: "stroke-amber-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative grid size-32 place-items-center">
|
||||
<svg viewBox="0 0 120 120" className="size-32 -rotate-90" aria-hidden>
|
||||
<circle cx="60" cy="60" r={radius} className="fill-none stroke-[var(--surface-sunken)]" strokeWidth="10" />
|
||||
<motion.circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r={radius}
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
className={cn("fill-none", colors[tone])}
|
||||
strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center">
|
||||
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{percent}%</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-frost-500 dark:text-frost-400">
|
||||
score
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
components/ServiceWorkerRegistrar.tsx
Normal file
30
components/ServiceWorkerRegistrar.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Registers the service worker in production builds only.
|
||||
*
|
||||
* Dev stays untouched: a cached shell would fight hot reload, and the worker
|
||||
* would also hide real 404s while developing.
|
||||
*/
|
||||
export function ServiceWorkerRegistrar() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
|
||||
|
||||
const register = () => {
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((error) => {
|
||||
console.warn("[freezmo] service worker registration failed", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "complete") register();
|
||||
else {
|
||||
window.addEventListener("load", register, { once: true });
|
||||
return () => window.removeEventListener("load", register);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
289
components/SettingsDialog.tsx
Normal file
289
components/SettingsDialog.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { clearApiKey, saveSettings } from "@/lib/settings";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { AlertTriangleIcon, CheckCircleIcon, XCircleIcon } from "@/components/icons";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
|
||||
export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { health, loading, error, refresh, apiKey } = useHealth();
|
||||
const [keyDraft, setKeyDraft] = useState(apiKey);
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKeyDraft(apiKey);
|
||||
setSaved(false);
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, apiKey, onClose]);
|
||||
|
||||
const save = () => {
|
||||
saveSettings({ apiKey: keyDraft.trim() });
|
||||
setSaved(true);
|
||||
window.setTimeout(() => setSaved(false), 2500);
|
||||
};
|
||||
|
||||
const forget = () => {
|
||||
clearApiKey();
|
||||
setKeyDraft("");
|
||||
};
|
||||
|
||||
const needsKeyMode = health?.authMode === "user-key";
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-frost-950/35 p-0 backdrop-blur-md sm:items-center sm:p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-title"
|
||||
initial={{ y: 48, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 32, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 340, damping: 32 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="glass-panel thin-scroll max-h-[90dvh] w-full max-w-lg overflow-y-auto rounded-t-[1.6rem] p-5 pb-safe shadow-2xl sm:rounded-[1.35rem] sm:pb-5"
|
||||
>
|
||||
<div className="sheet-grabber sm:hidden" aria-hidden />
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 id="settings-title" className="text-lg font-semibold text-frost-950 dark:text-frost-50">
|
||||
Settings
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Models come from <code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code> on
|
||||
the server; your API key stays in this browser.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Close settings">
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------ API key */}
|
||||
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">Ollama API key</h3>
|
||||
{apiKey ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200">
|
||||
saved in this browser
|
||||
</span>
|
||||
) : needsKeyMode ? (
|
||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
||||
required
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-frost-600 dark:text-frost-300">
|
||||
not needed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="sr-only" htmlFor="ollama-api-key">
|
||||
Ollama API key
|
||||
</label>
|
||||
<input
|
||||
id="ollama-api-key"
|
||||
type={reveal ? "text" : "password"}
|
||||
value={keyDraft}
|
||||
onChange={(event) => setKeyDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") save();
|
||||
}}
|
||||
placeholder="paste your key from ollama.com/settings/keys"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="min-w-40 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-frost-400 focus:border-accent-500 dark:text-frost-50"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={() => setReveal((value) => !value)}>
|
||||
{reveal ? "Hide" : "Show"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={keyDraft.trim() === apiKey}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
|
||||
Stored only in this browser's local storage and sent with each request so the server can talk to
|
||||
Ollama for you. It is never written to the server, never logged and never put in a cookie. Prefer the
|
||||
local daemon? Set <code>OLLAMA_AUTH=local</code> in <code>.env.local</code> and use{" "}
|
||||
<code>ollama signin</code> instead.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button size="sm" variant="secondary" onClick={() => void refresh()} loading={loading}>
|
||||
Test connection
|
||||
</Button>
|
||||
{apiKey ? (
|
||||
<Button size="sm" variant="ghost" onClick={forget}>
|
||||
Remove key from this browser
|
||||
</Button>
|
||||
) : null}
|
||||
{saved ? (
|
||||
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
Saved — testing…
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading && !health ? (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-slate-600 dark:text-slate-300">
|
||||
<Spinner className="size-5" /> Checking Ollama…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{health ? <HealthPanel health={health} /> : null}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"] }) {
|
||||
if (!health) return null;
|
||||
const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose";
|
||||
const toneClasses: Record<string, string> = {
|
||||
emerald: "bg-emerald-50 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200",
|
||||
amber: "bg-amber-50 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200",
|
||||
rose: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={cn("flex items-start gap-2 rounded-2xl p-3 text-sm font-medium", toneClasses[tone])}>
|
||||
<span aria-hidden className="mt-0.5 shrink-0">
|
||||
{tone === "emerald" ? (
|
||||
<CheckCircleIcon className="size-5" />
|
||||
) : tone === "amber" ? (
|
||||
<AlertTriangleIcon className="size-5" />
|
||||
) : (
|
||||
<XCircleIcon className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{health.ok
|
||||
? "Ollama is reachable and both models are available."
|
||||
: health.needsKey
|
||||
? "Waiting for your API key."
|
||||
: health.keyAccepted === false
|
||||
? "Ollama rejected your API key."
|
||||
: health.reachable
|
||||
? "Ollama is reachable, but a configured model is missing."
|
||||
: "Ollama isn't reachable."}
|
||||
{health.error ? <p className="mt-1 font-normal">{health.error}</p> : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Info
|
||||
label="Auth"
|
||||
value={
|
||||
health.authMode === "user-key"
|
||||
? "Your own key"
|
||||
: health.authMode === "server"
|
||||
? "Server key (.env)"
|
||||
: "Local daemon sign-in"
|
||||
}
|
||||
/>
|
||||
<Info label="Endpoint" value={health.baseUrl} mono />
|
||||
<Info label="Mode" value={health.mode === "direct-cloud" ? "Direct cloud API" : "Local daemon"} />
|
||||
<Info
|
||||
label="Key"
|
||||
value={
|
||||
health.needsKey
|
||||
? "missing"
|
||||
: health.keyAccepted === true
|
||||
? "accepted"
|
||||
: health.keyAccepted === false
|
||||
? "rejected"
|
||||
: health.authMode === "local"
|
||||
? "not needed"
|
||||
: "unknown"
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ModelRow title="Vision model (reads photos)" model={health.vision} />
|
||||
<ModelRow title="Text model (builds & grades)" model={health.text} />
|
||||
</div>
|
||||
|
||||
{health.mode === "direct-cloud" && health.needsKey ? (
|
||||
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-frost-700 dark:text-frost-200">
|
||||
Create a key at{" "}
|
||||
<a className="font-semibold underline underline-offset-2" href="https://ollama.com/settings/keys" target="_blank" rel="noreferrer">
|
||||
ollama.com/settings/keys
|
||||
</a>{" "}
|
||||
and paste it above. Tip: with the local daemon you can run <code>ollama signin</code> instead and set{" "}
|
||||
<code>OLLAMA_AUTH=local</code>.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<dt className="text-xs uppercase tracking-wide text-frost-500 dark:text-frost-400">{label}</dt>
|
||||
<dd className={cn("mt-0.5 truncate font-semibold", mono && "font-mono text-xs")}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
title,
|
||||
model,
|
||||
}: {
|
||||
title: string;
|
||||
model: { configured: string; resolved: string; available: boolean | null; note?: string };
|
||||
}) {
|
||||
const badge =
|
||||
model.available === true
|
||||
? { text: "available", className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
|
||||
: model.available === false
|
||||
? { text: "not found", className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
|
||||
: { text: "unknown", className: "bg-[var(--surface-sunken)] text-frost-600 dark:text-frost-300" };
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs uppercase tracking-wide text-frost-500 dark:text-frost-400">{title}</p>
|
||||
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-bold", badge.className)}>
|
||||
{badge.text}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm font-semibold">{model.resolved}</p>
|
||||
{model.configured !== model.resolved ? (
|
||||
<p className="text-xs text-frost-500 dark:text-frost-400">configured as {model.configured}</p>
|
||||
) : null}
|
||||
{model.note ? <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{model.note}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
components/Sidebar.tsx
Normal file
362
components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
FolderIcon,
|
||||
GearIcon,
|
||||
LayersIcon,
|
||||
MoonIcon,
|
||||
PlusIcon,
|
||||
SnowflakeIcon,
|
||||
SunIcon,
|
||||
} from "@/components/icons";
|
||||
import { useTheme } from "@/components/ThemeToggle";
|
||||
import { createFolder, folderStore } from "@/lib/folders";
|
||||
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
|
||||
import { UNORGANIZED_ID } from "@/lib/schemas";
|
||||
import { deckStore } from "@/lib/storage";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const COLLAPSE_KEY = "freezmo.sidebarCollapsed";
|
||||
|
||||
/** Shared nav content for the desktop rail and the mobile drawer. */
|
||||
function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
|
||||
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
||||
const folders = useSyncExternalStore(
|
||||
folderStore.subscribe,
|
||||
folderStore.getSnapshot,
|
||||
folderStore.getServerSnapshot,
|
||||
);
|
||||
const selected = useSelectedFolder();
|
||||
const { theme, toggle } = useTheme();
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sortedFolders = useMemo(
|
||||
() => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })),
|
||||
[folders],
|
||||
);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const map: Record<string, number> = { [UNORGANIZED_ID]: 0 };
|
||||
for (const folder of sortedFolders) map[folder.id] = 0;
|
||||
for (const deck of decks) {
|
||||
const key = deck.folderId && map[deck.folderId] !== undefined ? deck.folderId : UNORGANIZED_ID;
|
||||
map[key] = (map[key] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
}, [decks, sortedFolders]);
|
||||
|
||||
const submit = () => {
|
||||
const result = createFolder(draft);
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
folderSelection.set(result.folder.id);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const select = (id: string) => {
|
||||
folderSelection.set(id);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
/** Counts of 0 are noise — only show a count when there is something in it. */
|
||||
const count = (value: number) =>
|
||||
!collapsed && value > 0 ? <span className="text-[12px] tabular-nums opacity-60">{value}</span> : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* brand ---------------------------------------------------------- */}
|
||||
<div className={cn("mb-1 flex items-center", collapsed ? "justify-center" : "justify-between")}>
|
||||
<Link
|
||||
href="/"
|
||||
onClick={onNavigate}
|
||||
className={cn("nav-row gap-2.5", collapsed ? "w-auto px-2" : "flex-1")}
|
||||
aria-label="Freezmo home"
|
||||
title="Freezmo"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-7 shrink-0 place-items-center rounded-lg bg-[var(--text)] text-[var(--page)]"
|
||||
>
|
||||
<SnowflakeIcon className="size-4" />
|
||||
</span>
|
||||
{!collapsed ? (
|
||||
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">Freezmo</span>
|
||||
) : null}
|
||||
</Link>
|
||||
{!collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sidebarCollapse.set(true)}
|
||||
aria-label="Collapse sidebar"
|
||||
title="Collapse sidebar"
|
||||
className="grid size-8 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* new deck: a quiet row, not a competing primary button ---------- */}
|
||||
<Link
|
||||
href="/create"
|
||||
onClick={onNavigate}
|
||||
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
||||
title="New deck"
|
||||
>
|
||||
<PlusIcon className="size-4 shrink-0" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">New deck</span> : null}
|
||||
</Link>
|
||||
|
||||
{/* folders --------------------------------------------------------- */}
|
||||
{!collapsed ? (
|
||||
<div className="flex items-center justify-between px-2 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
Folders
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
aria-label="New folder"
|
||||
title="New folder"
|
||||
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<nav aria-label="Folders" className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sidebarCollapse.set(false)}
|
||||
aria-label="Expand sidebar"
|
||||
title="Expand sidebar"
|
||||
className="nav-row justify-center px-2"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(ALL_FOLDERS)}
|
||||
aria-pressed={selected === ALL_FOLDERS}
|
||||
title="All decks"
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<LayersIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">All decks</span> : null}
|
||||
{count(decks.length)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(UNORGANIZED_ID)}
|
||||
aria-pressed={selected === UNORGANIZED_ID}
|
||||
title="Unorganized"
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<FolderIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">Unorganized</span> : null}
|
||||
{count(counts[UNORGANIZED_ID] ?? 0)}
|
||||
</button>
|
||||
|
||||
{sortedFolders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
onClick={() => select(folder.id)}
|
||||
aria-pressed={selected === folder.id}
|
||||
title={folder.name}
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<FolderIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{folder.name}</span> : null}
|
||||
{count(counts[folder.id] ?? 0)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{creating && !collapsed ? (
|
||||
<div className="flex items-center gap-1 px-1 pt-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") submit();
|
||||
if (event.key === "Escape") {
|
||||
setCreating(false);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Folder name"
|
||||
aria-label="New folder name"
|
||||
maxLength={60}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-2.5 py-1.5 text-[13px] text-[var(--text)] outline-none focus:border-[var(--text-subtle)]"
|
||||
/>
|
||||
<button type="button" onClick={submit} className="btn-primary h-8 px-3 text-[12px]">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="px-2 pt-1 text-[12px] text-rose-600 dark:text-rose-300">{error}</p> : null}
|
||||
</nav>
|
||||
|
||||
{/* footer ---------------------------------------------------------- */}
|
||||
<div className="mt-2 space-y-0.5 border-t border-[var(--hairline)] pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<GearIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">Settings</span> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title={theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<SunIcon className="size-4 shrink-0 opacity-70" />
|
||||
) : (
|
||||
<MoonIcon className="size-4 shrink-0 opacity-70" />
|
||||
)}
|
||||
{!collapsed ? (
|
||||
<span className="flex-1 truncate text-left">{theme === "dark" ? "Light mode" : "Dark mode"}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Collapsed state (shared between rail and drawer) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let collapsed = false;
|
||||
const collapseListeners = new Set<() => void>();
|
||||
|
||||
export const sidebarCollapse = {
|
||||
subscribe(listener: () => void): () => void {
|
||||
collapseListeners.add(listener);
|
||||
return () => collapseListeners.delete(listener);
|
||||
},
|
||||
getSnapshot(): boolean {
|
||||
return collapsed;
|
||||
},
|
||||
getServerSnapshot(): boolean {
|
||||
return false;
|
||||
},
|
||||
set(value: boolean): void {
|
||||
collapsed = value;
|
||||
try {
|
||||
window.localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0");
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
collapseListeners.forEach((listener) => listener());
|
||||
},
|
||||
hydrate(): void {
|
||||
try {
|
||||
collapsed = window.localStorage.getItem(COLLAPSE_KEY) === "1";
|
||||
} catch {
|
||||
collapsed = false;
|
||||
}
|
||||
collapseListeners.forEach((listener) => listener());
|
||||
},
|
||||
};
|
||||
|
||||
function useCollapsed(): boolean {
|
||||
return useSyncExternalStore(
|
||||
sidebarCollapse.subscribe,
|
||||
sidebarCollapse.getSnapshot,
|
||||
sidebarCollapse.getServerSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Desktop rail + mobile drawer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Sidebar() {
|
||||
const isCollapsed = useCollapsed();
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Sidebar"
|
||||
className={cn(
|
||||
"sidebar-surface fixed inset-y-0 left-0 z-40 hidden flex-col px-2.5 py-3 transition-[width] duration-200 lg:flex",
|
||||
isCollapsed ? "w-[68px]" : "w-[260px]",
|
||||
)}
|
||||
>
|
||||
<NavContent collapsed={isCollapsed} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileSidebar() {
|
||||
const open = useSidebarOpen();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex lg:hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
onClick={() => sidebarOpen.set(false)}
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.aside
|
||||
aria-label="Menu"
|
||||
initial={{ x: -280 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -280 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 36 }}
|
||||
className="sidebar-surface relative z-10 flex h-full w-[272px] flex-col px-2.5 py-3 pb-safe"
|
||||
>
|
||||
<NavContent onNavigate={() => sidebarOpen.set(false)} />
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/** Spacer so page content clears the fixed rail (desktop only). */
|
||||
export function SidebarOffset() {
|
||||
const isCollapsed = useCollapsed();
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn("hidden shrink-0 transition-[width] duration-200 lg:block", isCollapsed ? "w-[68px]" : "w-[260px]")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
278
components/StudyFeed.tsx
Normal file
278
components/StudyFeed.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ActivityRenderer } from "@/components/activities";
|
||||
import { celebrate } from "@/lib/confetti";
|
||||
import type { Activity, Deck } from "@/lib/schemas";
|
||||
import { recordAttempt } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BoltIcon, FlameIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
import { ResultsScreen } from "./ResultsScreen";
|
||||
|
||||
export type AttemptResult = { correct: boolean; userAnswer?: string };
|
||||
|
||||
export function StudyFeed({ deck }: { deck: Deck }) {
|
||||
const [queue, setQueue] = useState<Activity[]>(deck.activities);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [results, setResults] = useState<Record<string, AttemptResult>>({});
|
||||
const [xp, setXp] = useState(0);
|
||||
const [streak, setStreak] = useState(0);
|
||||
const [bestStreak, setBestStreak] = useState(0);
|
||||
const [retryMode, setRetryMode] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const recordedRef = useRef(false);
|
||||
|
||||
const total = queue.length;
|
||||
const slideCount = total + 1; // + results slide
|
||||
|
||||
const goTo = useCallback((target: number) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const clamped = Math.max(0, Math.min(target, slideCount - 1));
|
||||
container.scrollTo({ top: clamped * container.clientHeight, behavior: "smooth" });
|
||||
}, [slideCount]);
|
||||
|
||||
const next = useCallback(() => goTo(index + 1), [goTo, index]);
|
||||
|
||||
// Track which slide is visible (works for wheel, touch and programmatic scroll).
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
let frame = 0;
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const height = container.clientHeight || 1;
|
||||
const current = Math.round(container.scrollTop / height);
|
||||
setIndex((previous) => (previous === current ? previous : current));
|
||||
});
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation.
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "j") {
|
||||
event.preventDefault();
|
||||
next();
|
||||
} else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") {
|
||||
event.preventDefault();
|
||||
goTo(index - 1);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
goTo(0);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
goTo(slideCount - 1);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [goTo, index, next, slideCount]);
|
||||
|
||||
const finished = index >= total;
|
||||
|
||||
useEffect(() => {
|
||||
if (!finished) return;
|
||||
if (!retryMode && !recordedRef.current) {
|
||||
recordedRef.current = true;
|
||||
const answered = Object.values(results);
|
||||
const correct = answered.filter((entry) => entry.correct).length;
|
||||
const score = total > 0 ? (correct / total) * 100 : 0;
|
||||
recordAttempt(deck.id, { scorePercent: score, correct, total });
|
||||
void celebrate("big");
|
||||
}
|
||||
}, [finished, results, total, deck.id, retryMode]);
|
||||
|
||||
const handleAnswered = useCallback(
|
||||
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
|
||||
setResults((current) => {
|
||||
if (current[activity.id]) return current; // first answer counts
|
||||
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
|
||||
});
|
||||
if (correct) {
|
||||
const nextStreak = streak + 1;
|
||||
setStreak(nextStreak);
|
||||
setBestStreak((best) => Math.max(best, nextStreak));
|
||||
setXp((value) => value + activity.xp);
|
||||
if (nextStreak > 0 && nextStreak % 5 === 0) void celebrate("normal");
|
||||
} else {
|
||||
setStreak(0);
|
||||
}
|
||||
},
|
||||
[streak],
|
||||
);
|
||||
|
||||
const missed = useMemo(
|
||||
() => queue.filter((activity) => !results[activity.id]?.correct),
|
||||
[queue, results],
|
||||
);
|
||||
|
||||
const correctCount = useMemo(
|
||||
() => queue.filter((activity) => results[activity.id]?.correct).length,
|
||||
[queue, results],
|
||||
);
|
||||
|
||||
const restart = useCallback(
|
||||
(activities: Activity[], retry: boolean) => {
|
||||
setQueue(activities);
|
||||
setResults({});
|
||||
setStreak(0);
|
||||
setRetryMode(retry);
|
||||
recordedRef.current = retry; // don't overwrite the best score with a retry run
|
||||
setIndex(0);
|
||||
requestAnimationFrame(() => goTo(0));
|
||||
},
|
||||
[goTo],
|
||||
);
|
||||
|
||||
const progressValue = Math.min(index + 1, slideCount);
|
||||
const progressMax = slideCount;
|
||||
|
||||
return (
|
||||
<div className="frost-bg fixed inset-0 flex flex-col">
|
||||
{/* Chrome ---------------------------------------------------- */}
|
||||
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
|
||||
<div className="glass mx-auto max-w-xl space-y-1.5 rounded-2xl px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Back to your library"
|
||||
className="grid size-9 shrink-0 place-items-center rounded-xl bg-white/80 text-sm font-bold text-frost-800 transition hover:bg-white dark:bg-white/10 dark:text-frost-100"
|
||||
>
|
||||
✕
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProgressBar
|
||||
value={progressValue}
|
||||
max={progressMax}
|
||||
label={`Activity ${progressValue} of ${progressMax}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs font-extrabold transition",
|
||||
streak >= 3
|
||||
? "tint-peach"
|
||||
: "glass-chip text-frost-600 dark:text-frost-200",
|
||||
)}
|
||||
title={`Answer streak: ${streak}`}
|
||||
aria-label={`Answer streak: ${streak}`}
|
||||
>
|
||||
<FlameIcon className="size-3.5" />
|
||||
{streak}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-xl bg-frost-500/15 px-2 py-1 text-xs font-extrabold text-frost-800 dark:text-frost-100"
|
||||
title={`XP earned: ${xp}`}
|
||||
aria-label={`XP earned: ${xp}`}
|
||||
>
|
||||
<BoltIcon className="size-3.5" />
|
||||
{xp}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-frost-600 dark:text-frost-300">
|
||||
<span className="min-w-0 truncate">{deck.title}</span>
|
||||
<span className="shrink-0">
|
||||
{retryMode ? "Retry · " : ""}
|
||||
{Math.min(index + 1, total)}/{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Feed ------------------------------------------------------ */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="study-feed"
|
||||
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain"
|
||||
>
|
||||
{queue.map((activity, activityIndex) => (
|
||||
<section
|
||||
key={activity.id}
|
||||
aria-label={`Activity ${activityIndex + 1} of ${total}`}
|
||||
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
<ActivityRenderer
|
||||
activity={activity}
|
||||
active={index === activityIndex}
|
||||
language={deck.language}
|
||||
isLast={activityIndex === total - 1}
|
||||
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
|
||||
onNext={() => goTo(activityIndex + 1)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section
|
||||
aria-label="Results"
|
||||
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
<ResultsScreen
|
||||
deck={deck}
|
||||
xp={xp}
|
||||
bestStreak={bestStreak}
|
||||
total={total}
|
||||
correct={correctCount}
|
||||
missed={missed}
|
||||
results={results}
|
||||
retryMode={retryMode}
|
||||
onRetryMissed={() => restart(missed, true)}
|
||||
onRestart={() => restart(deck.activities, false)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Swipe hint on the very first screen ----------------------- */}
|
||||
<AnimatePresence>
|
||||
{index === 0 && Object.keys(results).length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="pointer-events-none absolute inset-x-0 bottom-6 z-20 flex justify-center"
|
||||
>
|
||||
<span className="rounded-full bg-frost-950/70 px-4 py-2 text-xs font-semibold text-white backdrop-blur">
|
||||
Swipe up for the next activity ↑
|
||||
</span>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Desktop next/prev buttons --------------------------------- */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden justify-center pb-4 sm:flex">
|
||||
<div className="glass pointer-events-auto flex items-center gap-2 rounded-2xl px-2 py-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label="Previous activity">
|
||||
↑
|
||||
</Button>
|
||||
<span className="px-2 text-xs font-semibold text-frost-600 dark:text-frost-300">
|
||||
{index + 1} / {slideCount}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => goTo(index + 1)} disabled={index >= slideCount - 1} aria-label="Next activity">
|
||||
↓
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
components/TabBar.tsx
Normal file
47
components/TabBar.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* iOS-style bottom tab bar. Rendered on the library and create screens on
|
||||
* phones; hidden on desktop (the header carries the same actions there) and on
|
||||
* the full-screen study feed.
|
||||
*/
|
||||
export function TabBar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const item = "flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
|
||||
const active = "text-[var(--text)]";
|
||||
const idle = "text-[var(--text-subtle)]";
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Main"
|
||||
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
|
||||
>
|
||||
<Link href="/" aria-current={pathname === "/" ? "page" : undefined} className={cn(item, pathname === "/" ? active : idle)}>
|
||||
<HomeIcon className="size-6" />
|
||||
Library
|
||||
</Link>
|
||||
<Link
|
||||
href="/create"
|
||||
aria-current={pathname === "/create" ? "page" : undefined}
|
||||
className={cn(item, pathname === "/create" ? active : idle)}
|
||||
>
|
||||
<span className="grid size-6 place-items-center rounded-full bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]">
|
||||
<PlusIcon className="size-4" />
|
||||
</span>
|
||||
New deck
|
||||
</Link>
|
||||
<button type="button" onClick={openSettings} className={cn(item, idle)}>
|
||||
<GearIcon className="size-6" />
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
54
components/ThemeToggle.tsx
Normal file
54
components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MoonIcon, SunIcon } from "@/components/icons";
|
||||
|
||||
const STORAGE_KEY = "freezmo.theme";
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
const isDark = stored
|
||||
? stored === "dark"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
setTheme(isDark ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
const apply = (next: "light" | "dark") => {
|
||||
setTheme(next);
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
};
|
||||
|
||||
return { theme, toggle: () => apply(theme === "dark" ? "light" : "dark") };
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const { theme, toggle } = useTheme();
|
||||
const dark = theme === "dark";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={dark ? "Light mode" : "Dark mode"}
|
||||
className={cn(
|
||||
"inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>
|
||||
{dark ? <SunIcon className="size-5" /> : <MoonIcon className="size-5" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
804
components/UploadScreen.tsx
Normal file
804
components/UploadScreen.tsx
Normal file
|
|
@ -0,0 +1,804 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { ApiError, generateDeck, type GenerateProgress } from "@/lib/client-api";
|
||||
import {
|
||||
DOCUMENT_ACCEPT_ATTR,
|
||||
KIND_LABELS,
|
||||
describeDocumentError,
|
||||
documentKindOf,
|
||||
documentsToText,
|
||||
extractDocument,
|
||||
isDocumentFile,
|
||||
type DocumentKind,
|
||||
type ExtractedDocument,
|
||||
} from "@/lib/documents";
|
||||
import {
|
||||
ACCEPTED_TYPES,
|
||||
MAX_IMAGES,
|
||||
describeFileError,
|
||||
formatBytes,
|
||||
imageFilesFromDataTransfer,
|
||||
isImageFile,
|
||||
prepareImage,
|
||||
type PreparedImage,
|
||||
} from "@/lib/image";
|
||||
import { createFolder, folderStore } from "@/lib/folders";
|
||||
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
||||
import { upsertDeck } from "@/lib/storage";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn, uid } from "@/lib/utils";
|
||||
import { ApiKeyBanner } from "@/components/ApiKeyBanner";
|
||||
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||
|
||||
/** A document the user added, with its client-side extraction state. */
|
||||
type DocItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DocumentKind;
|
||||
status: "extracting" | "ready" | "error";
|
||||
progress?: string;
|
||||
extracted?: ExtractedDocument;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const MAX_DOCUMENTS = 6;
|
||||
/** Photos + rendered PDF pages + images embedded in documents. */
|
||||
const MAX_IMAGES_TO_SEND = 12;
|
||||
/** Cap for the merged text sent to the API. */
|
||||
const MAX_TEXT_TO_SEND = 100_000;
|
||||
|
||||
function describeExtraction(document: ExtractedDocument): string {
|
||||
const parts = [
|
||||
`${document.words.toLocaleString()} word${document.words === 1 ? "" : "s"}`,
|
||||
document.pages ? `${document.pages} page${document.pages === 1 ? "" : "s"}` : "",
|
||||
document.images.length
|
||||
? `${document.images.length} page image${document.images.length === 1 ? "" : "s"}`
|
||||
: "",
|
||||
document.truncated ? "truncated" : "",
|
||||
];
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
const DIFFICULTIES: Array<{ value: Difficulty; label: string; icon: React.ReactNode; blurb: string }> = [
|
||||
{
|
||||
value: "easy",
|
||||
label: "Easy",
|
||||
icon: <SunIcon className="size-5" />,
|
||||
blurb: "Recall & recognition",
|
||||
},
|
||||
{ value: "medium", label: "Medium", icon: <SnowflakeIcon className="size-5" />, blurb: "Understanding & application" },
|
||||
{ value: "hard", label: "Hard", icon: <IceCubeIcon className="size-5" />, blurb: "Analysis & tricky edges" },
|
||||
];
|
||||
|
||||
const COUNT_OPTIONS = [8, 10, 12, 15];
|
||||
|
||||
const GENERATION_TIPS = [
|
||||
"Tip: swipe up and down through your deck like a feed.",
|
||||
"Tip: matching questions only give full XP for a perfect match.",
|
||||
"Tip: your decks are saved in this browser — no account needed.",
|
||||
"Tip: export a deck as JSON to share it with a friend.",
|
||||
];
|
||||
|
||||
export function UploadScreen() {
|
||||
const router = useRouter();
|
||||
const [images, setImages] = useState<PreparedImage[]>([]);
|
||||
const [documents, setDocuments] = useState<DocItem[]>([]);
|
||||
const [text, setText] = useState("");
|
||||
const [focus, setFocus] = useState("");
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("medium");
|
||||
const [folderId, setFolderId] = useState<string>(UNORGANIZED_ID);
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [folderDraft, setFolderDraft] = useState("");
|
||||
const [count, setCount] = useState(10);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "Warming up…" });
|
||||
|
||||
const folders = useSyncExternalStore(
|
||||
folderStore.subscribe,
|
||||
folderStore.getSnapshot,
|
||||
folderStore.getServerSnapshot,
|
||||
);
|
||||
const sortedFolders = useMemo(
|
||||
() => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })),
|
||||
[folders],
|
||||
);
|
||||
const targetFolder = folderId === UNORGANIZED_ID ? undefined : folderId;
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
/**
|
||||
* Mirrors of the current lists. React defers state updaters, so reading the
|
||||
* count from inside `setState` would silently skip work — always read these.
|
||||
*/
|
||||
const imagesRef = useRef<PreparedImage[]>([]);
|
||||
const documentsRef = useRef<DocItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
imagesRef.current = images;
|
||||
}, [images]);
|
||||
useEffect(() => {
|
||||
documentsRef.current = documents;
|
||||
}, [documents]);
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setFileError(null);
|
||||
setPhase("preparing");
|
||||
|
||||
const problems: string[] = [];
|
||||
const imageFiles: File[] = [];
|
||||
const documentFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (isImageFile(file)) {
|
||||
const problem = describeFileError(file);
|
||||
if (problem) problems.push(problem);
|
||||
else imageFiles.push(file);
|
||||
} else if (isDocumentFile(file)) {
|
||||
const problem = describeDocumentError(file);
|
||||
if (problem) problems.push(problem);
|
||||
else documentFiles.push(file);
|
||||
} else {
|
||||
problems.push(
|
||||
`"${file.name}" is not a supported format. Add photos or a PDF, DOCX, PPTX, ODT/ODP, TXT or MD file.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const documentRoom = Math.max(0, MAX_DOCUMENTS - documentsRef.current.length);
|
||||
const acceptedDocuments = documentFiles.slice(0, documentRoom);
|
||||
if (documentFiles.length > documentRoom) problems.push(`Up to ${MAX_DOCUMENTS} documents per deck.`);
|
||||
|
||||
// Documents are read asynchronously (pdf.js can take a while for scans).
|
||||
if (acceptedDocuments.length > 0) {
|
||||
for (const file of acceptedDocuments) {
|
||||
const kind = documentKindOf(file);
|
||||
if (!kind) continue;
|
||||
const item: DocItem = { id: uid("doc"), name: file.name, kind, status: "extracting" };
|
||||
documentsRef.current = [...documentsRef.current, item];
|
||||
setDocuments((current) => (current.some((entry) => entry.id === item.id) ? current : [...current, item]));
|
||||
|
||||
const update = (patch: Partial<DocItem>) =>
|
||||
setDocuments((current) =>
|
||||
current.map((entry) => (entry.id === item.id ? { ...entry, ...patch } : entry)),
|
||||
);
|
||||
|
||||
try {
|
||||
const extracted = await extractDocument(file, (progress) => update({ progress: progress.message }));
|
||||
update({ status: "ready", extracted, progress: undefined });
|
||||
} catch (error) {
|
||||
update({
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : "Could not read this file.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const photoRoom = Math.max(0, MAX_IMAGES - imagesRef.current.length);
|
||||
const acceptedImages = imageFiles.slice(0, photoRoom);
|
||||
if (imageFiles.length > photoRoom) problems.push(`Up to ${MAX_IMAGES} photos per deck.`);
|
||||
|
||||
const prepared: PreparedImage[] = [];
|
||||
for (const file of acceptedImages) {
|
||||
try {
|
||||
prepared.push(await prepareImage(file));
|
||||
} catch {
|
||||
problems.push(`Could not read "${file.name}".`);
|
||||
}
|
||||
}
|
||||
if (prepared.length > 0) {
|
||||
imagesRef.current = [...imagesRef.current, ...prepared];
|
||||
setImages((current) => [...current, ...prepared]);
|
||||
}
|
||||
|
||||
if (problems.length) setFileError(problems.slice(0, 3).join(" "));
|
||||
setPhase("idle");
|
||||
}, []);
|
||||
|
||||
// Clipboard paste (Ctrl/⌘ + V) of screenshots or copied photos.
|
||||
useEffect(() => {
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
if (phase === "loading") return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTextField = target && /^(INPUT|TEXTAREA)$/.test(target.tagName);
|
||||
const files = imageFilesFromDataTransfer(event.clipboardData);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
void addFiles(files);
|
||||
return;
|
||||
}
|
||||
if (!isTextField) {
|
||||
const pasted = event.clipboardData?.getData("text/plain")?.trim();
|
||||
if (pasted && pasted.length > 40) {
|
||||
event.preventDefault();
|
||||
setText((current) => (current ? `${current}\n${pasted}` : pasted));
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
}, [addFiles, phase]);
|
||||
|
||||
const removeImage = (id: string) => {
|
||||
imagesRef.current = imagesRef.current.filter((image) => image.id !== id);
|
||||
setImages((current) => current.filter((image) => image.id !== id));
|
||||
};
|
||||
const removeDocument = (id: string) => {
|
||||
documentsRef.current = documentsRef.current.filter((entry) => entry.id !== id);
|
||||
setDocuments((current) => current.filter((entry) => entry.id !== id));
|
||||
};
|
||||
|
||||
const readyDocuments = documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||
const documentsBusy = documents.some((entry) => entry.status === "extracting");
|
||||
const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images);
|
||||
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
|
||||
|
||||
const totalBytes = images.reduce((sum, image) => sum + image.bytes, 0);
|
||||
const hasContent =
|
||||
images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0;
|
||||
const canGenerate = hasContent && !documentsBusy && phase !== "preparing";
|
||||
const imagesToSend = [...images.map((image) => image.dataUrl), ...documentImages.map((image) => image.dataUrl)].slice(
|
||||
0,
|
||||
MAX_IMAGES_TO_SEND,
|
||||
);
|
||||
const imagesSkipped =
|
||||
images.length + documentImages.length - imagesToSend.length;
|
||||
|
||||
const generate = async () => {
|
||||
if (!canGenerate) return;
|
||||
setError(null);
|
||||
setPhase("loading");
|
||||
setProgress({
|
||||
stage: imagesToSend.length ? "vision" : "generation",
|
||||
message: imagesToSend.length ? "Reading your notes…" : "Finding the key ideas…",
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const mergedText = [text.trim(), documentText.trim()].filter(Boolean).join("\n\n").slice(0, MAX_TEXT_TO_SEND);
|
||||
|
||||
try {
|
||||
const { deck } = await generateDeck(
|
||||
{
|
||||
images: imagesToSend.length ? imagesToSend : undefined,
|
||||
text: mergedText || undefined,
|
||||
focus: focus.trim() || undefined,
|
||||
difficulty,
|
||||
count,
|
||||
},
|
||||
{ onProgress: setProgress, signal: controller.signal },
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const stored = {
|
||||
...deck,
|
||||
id: uid("deck"),
|
||||
folderId: targetFolder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
attempts: 0,
|
||||
};
|
||||
upsertDeck(stored);
|
||||
router.push(`/deck/${stored.id}`);
|
||||
} catch (generateError) {
|
||||
setError({
|
||||
message:
|
||||
generateError instanceof ApiError
|
||||
? generateError.message
|
||||
: "Something went wrong while generating your deck. Check your Ollama settings and try again.",
|
||||
kind: generateError instanceof ApiError ? generateError.kind : undefined,
|
||||
});
|
||||
setPhase("error");
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (phase === "loading") {
|
||||
return <LoadingState progress={progress} onCancel={() => abortRef.current?.abort()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative space-y-5">
|
||||
<section className="space-y-2">
|
||||
<h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]">
|
||||
<SnowflakeIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
|
||||
<span>Turn your notes into a study feed</span>
|
||||
</h1>
|
||||
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">
|
||||
Snap or drop photos of your notes. Freezmo reads them with your own Ollama vision model
|
||||
and builds a swipeable deck of mini study games.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ApiKeyBanner />
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100"
|
||||
>
|
||||
<p className="font-bold">
|
||||
{error.kind === "needs_key"
|
||||
? "API key required"
|
||||
: error.kind === "rate_limited"
|
||||
? "Slow down a moment"
|
||||
: error.kind === "auth"
|
||||
? "Ollama rejected your key"
|
||||
: "Generation failed"}
|
||||
</p>
|
||||
<p className="mt-1">{error.message}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{error.kind === "needs_key" || error.kind === "auth" ? (
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
Open settings
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant={error.kind === "needs_key" || error.kind === "auth" ? "secondary" : "primary"} onClick={() => void generate()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section
|
||||
aria-label="Add photos of your notes"
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
void addFiles(imageFilesFromDataTransfer(event.dataTransfer));
|
||||
}}
|
||||
className={cn(
|
||||
"card relative overflow-hidden p-4 transition-all sm:p-5",
|
||||
dragging && "border-frost-400 bg-frost-50 ring-4 ring-frost-200 dark:bg-frost-500/10 dark:ring-frost-500/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">1. Add your notes</h2>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
Drag & drop, paste a screenshot (Ctrl/⌘+V), or use the camera. Photos, PDF, Word,
|
||||
PowerPoint, OpenDocument and text files. Up to {MAX_IMAGES} photos and {MAX_DOCUMENTS}{" "}
|
||||
documents.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<FolderIcon className="size-4" /> Choose files
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => cameraInputRef.current?.click()}>
|
||||
<CameraIcon className="size-4" /> Camera
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{images.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-4 flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-white/70 bg-white/40 px-4 py-10 text-center backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/65 dark:border-white/15 dark:bg-white/[0.05] dark:hover:bg-white/[0.09]"
|
||||
>
|
||||
<span aria-hidden className="animate-float text-frost-500 dark:text-frost-200">
|
||||
<SnowflakeIcon className="size-12" />
|
||||
</span>
|
||||
<span className="text-[14px] font-medium text-[var(--text)]">
|
||||
Drop your notes here
|
||||
</span>
|
||||
<span className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
Photos, PDF, DOCX, PPTX, ODT/ODP, TXT, MD · images resized to 1600px before sending
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<ul className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{images.map((image) => (
|
||||
<li key={image.id} className="group relative overflow-hidden rounded-2xl border border-frost-200 dark:border-white/15">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image.dataUrl}
|
||||
alt={image.name}
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(image.id)}
|
||||
aria-label={`Remove ${image.name}`}
|
||||
className="absolute right-1 top-1 grid size-7 place-items-center rounded-full bg-frost-950/70 text-xs font-bold text-white transition hover:bg-rose-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<span className="absolute inset-x-0 bottom-0 bg-frost-950/60 px-2 py-1 text-[10px] font-semibold text-white">
|
||||
{formatBytes(image.bytes)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{images.length < MAX_IMAGES ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="grid aspect-square w-full place-items-center rounded-2xl border-2 border-dashed border-white/70 text-2xl text-accent-500 backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/60 dark:border-white/15 dark:text-accent-200"
|
||||
aria-label="Add more photos"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{documents.length > 0 ? (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{documents.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/60 bg-white/50 p-3 backdrop-blur-xl dark:border-white/12 dark:bg-white/[0.06]"
|
||||
>
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-frost-100 text-[10px] font-extrabold uppercase tracking-wide text-frost-700 dark:bg-white/10 dark:text-frost-100">
|
||||
{KIND_LABELS[entry.kind]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-[var(--text)]" title={entry.name}>
|
||||
{entry.name}
|
||||
</p>
|
||||
{entry.status === "extracting" ? (
|
||||
<p className="text-xs font-medium text-frost-700 dark:text-frost-200">
|
||||
{entry.progress ?? "Reading…"}
|
||||
</p>
|
||||
) : null}
|
||||
{entry.status === "error" ? (
|
||||
<p className="text-xs font-medium text-rose-600 dark:text-rose-300">{entry.error}</p>
|
||||
) : null}
|
||||
{entry.status === "ready" && entry.extracted ? (
|
||||
<>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
{describeExtraction(entry.extracted)}
|
||||
</p>
|
||||
{entry.extracted.words === 0 && entry.extracted.images.length === 0 ? (
|
||||
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
No readable text — is this a scan without a text layer?
|
||||
</p>
|
||||
) : null}
|
||||
{entry.extracted.warnings.map((warning) => (
|
||||
<p key={warning} className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
ⓘ {warning}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDocument(entry.id)}
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-950/10 text-xs font-bold text-frost-900 transition hover:bg-rose-500 hover:text-white dark:bg-white/10 dark:text-frost-50"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{images.length > 0 || documents.length > 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-[var(--text-subtle)]">
|
||||
{images.length > 0
|
||||
? `${images.length} photo${images.length === 1 ? "" : "s"} · ${formatBytes(totalBytes)} after compression`
|
||||
: ""}
|
||||
{images.length > 0 && documents.length > 0 ? " · " : ""}
|
||||
{documents.length > 0
|
||||
? `${documents.length} document${documents.length === 1 ? "" : "s"}${
|
||||
documentImages.length ? ` (${documentImages.length} page image${documentImages.length === 1 ? "" : "s"})` : ""
|
||||
}`
|
||||
: ""}
|
||||
{imagesSkipped > 0
|
||||
? ` · ${imagesSkipped} image${imagesSkipped === 1 ? "" : "s"} will be skipped (max ${MAX_IMAGES_TO_SEND} per deck)`
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{phase === "preparing" ? (
|
||||
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">Compressing photos…</p>
|
||||
) : null}
|
||||
{fileError ? (
|
||||
<p role="alert" className="mt-2 text-xs font-semibold text-rose-600 dark:text-rose-300">
|
||||
{fileError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section className="card space-y-4 p-4 sm:p-5">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">2. Optional extras</h2>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
Paste extra notes or give the AI a hint about what to focus on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="notes-text" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Pasted notes or text
|
||||
</label>
|
||||
<textarea
|
||||
id="notes-text"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="Paste lecture text, a summary, or any extra facts here…"
|
||||
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="focus" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Topic hint (optional)
|
||||
</label>
|
||||
<input
|
||||
id="focus"
|
||||
value={focus}
|
||||
onChange={(event) => setFocus(event.target.value)}
|
||||
placeholder="e.g. focus on the formulas, or on dates"
|
||||
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section className="card space-y-4 p-4 sm:p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">3. Difficulty & size</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Freeze into folder
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...sortedFolders].map((folder) => {
|
||||
const active = folderId === folder.id;
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setFolderId(folder.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-[13px] font-medium transition",
|
||||
active
|
||||
? "border-accent-500 bg-accent-50 text-accent-800 dark:bg-accent-500/20 dark:text-accent-100"
|
||||
: "border-[var(--hairline)] text-frost-600 hover:text-frost-900 dark:text-frost-300 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="size-4" />
|
||||
{folder.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{creatingFolder ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={folderDraft}
|
||||
onChange={(event) => setFolderDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
setCreatingFolder(false);
|
||||
setFolderDraft("");
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
const created = createFolder(folderDraft);
|
||||
if ("folder" in created) {
|
||||
setFolderId(created.folder.id);
|
||||
setFolderDraft("");
|
||||
setCreatingFolder(false);
|
||||
} else {
|
||||
setFileError(created.error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-label="New folder name"
|
||||
placeholder="e.g. History"
|
||||
maxLength={60}
|
||||
className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const created = createFolder(folderDraft);
|
||||
if ("folder" in created) {
|
||||
setFolderId(created.folder.id);
|
||||
setFolderDraft("");
|
||||
setCreatingFolder(false);
|
||||
} else {
|
||||
setFileError(created.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreatingFolder(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-dashed border-[var(--hairline)] px-3 py-1.5 text-sm font-semibold text-frost-500 transition-colors hover:text-accent-700 dark:text-frost-400 dark:hover:text-accent-200"
|
||||
>
|
||||
<PlusIcon className="size-4" /> New folder
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="radiogroup" aria-label="Difficulty" className="grid grid-cols-3 gap-2">
|
||||
{DIFFICULTIES.map((option) => {
|
||||
const selected = difficulty === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setDifficulty(option.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors",
|
||||
selected
|
||||
? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
|
||||
: "border-white/60 bg-white/45 backdrop-blur-xl hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="text-frost-600 dark:text-frost-200">
|
||||
{option.icon}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-[var(--text)]">{option.label}</span>
|
||||
<span className="hidden text-[11px] text-slate-500 sm:block dark:text-slate-400">{option.blurb}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Activities
|
||||
</span>
|
||||
{COUNT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={count === option}
|
||||
onClick={() => setCount(option)}
|
||||
className={cn(
|
||||
"min-w-12 rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
|
||||
count === option
|
||||
? "border-transparent bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
|
||||
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="sticky bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 -mx-4 border-t border-[var(--hairline)] bg-white/65 px-4 py-3 backdrop-blur-2xl dark:bg-night-950/70 sm:bottom-0 sm:mx-0 sm:rounded-[1.35rem] sm:border sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
{documentsBusy
|
||||
? "Reading your documents…"
|
||||
: hasContent
|
||||
? "Ready when you are — generation can take a minute."
|
||||
: "Add a photo or a document (or some text) to start."}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="lg" onClick={() => void generate()} disabled={!canGenerate}>
|
||||
<SnowflakeIcon className="size-5" /> Freeze my deck
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Loading state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; messages: string[] }> = [
|
||||
{ stage: "vision", messages: ["Reading your notes…", "Transcribing handwriting…", "Spotting definitions…"] },
|
||||
{ stage: "generation", messages: ["Finding the key ideas…", "Writing good questions…", "Building your activities…"] },
|
||||
];
|
||||
|
||||
function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) {
|
||||
const [tick, setTick] = useState(0);
|
||||
const [tip, setTip] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setTick((value) => value + 1), 3200);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTip(Math.floor(Math.random() * GENERATION_TIPS.length));
|
||||
}, []);
|
||||
|
||||
const activeIndex = progress.stage === "vision" ? 0 : 1;
|
||||
const messages = STAGE_STEPS[activeIndex].messages;
|
||||
const message = progress.message && tick === 0 ? progress.message : messages[tick % messages.length];
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-[70dvh] flex-col items-center justify-center gap-6 text-center">
|
||||
<motion.div
|
||||
aria-hidden
|
||||
animate={{ rotate: [0, 8, -8, 0], scale: [1, 1.08, 1] }}
|
||||
transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="relative grid size-28 place-items-center rounded-[1.75rem] bg-accent-600 text-white shadow-lift"
|
||||
>
|
||||
<SnowflakeIcon className="size-16" />
|
||||
</motion.div>
|
||||
|
||||
<div className="relative w-full max-w-md space-y-3">
|
||||
<p className="text-xl font-extrabold text-[var(--text)]" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
<ProgressBar value={activeIndex + 1} max={2} label="Generation progress" />
|
||||
<ol className="space-y-1 text-left text-[14.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
{STAGE_STEPS.map((step, index) => (
|
||||
<li key={step.stage} className="flex items-center gap-2">
|
||||
<span aria-hidden>
|
||||
{index < activeIndex ? "✅" : index === activeIndex ? "🌀" : "⬜"}
|
||||
</span>
|
||||
{step.messages[0]}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="pt-2 text-[12.5px] text-[var(--text-subtle)]">{GENERATION_TIPS[tip]}</p>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
components/activities/FillBlank.tsx
Normal file
202
components/activities/FillBlank.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
OptionTile,
|
||||
PromptCard,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type FillBlankActivity = Extract<Activity, { type: "fill_blank" }>;
|
||||
|
||||
const BLANK = /_{2,}/;
|
||||
|
||||
function normalise(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9\s]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function levenshtein(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
if (!a.length || !b.length) return Math.max(a.length, b.length);
|
||||
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
const current = [i];
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
current[j] = Math.min(
|
||||
previous[j] + 1,
|
||||
current[j - 1] + 1,
|
||||
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
return previous[b.length];
|
||||
}
|
||||
|
||||
/** Lenient comparison: case/punctuation-insensitive, one typo allowed, "/" alternatives. */
|
||||
export function isFillBlankCorrect(userValue: string, answer: string): boolean {
|
||||
const user = normalise(userValue);
|
||||
if (!user) return false;
|
||||
const candidates = answer
|
||||
.split("/")
|
||||
.map((part) => normalise(part.replace(/\(.*?\)/g, "")))
|
||||
.filter(Boolean);
|
||||
return candidates.some((candidate) => {
|
||||
if (candidate === user) return true;
|
||||
if (candidate.length >= 5 && user.length >= 4 && levenshtein(candidate, user) <= 1) return true;
|
||||
// accept the answer with/without a trailing unit or article
|
||||
const candidateWords = candidate.split(" ");
|
||||
const userWords = user.split(" ");
|
||||
if (candidateWords.length > 1 && userWords.length === 1) return candidateWords.includes(user);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function FillBlank({ activity, active, onAnswered, onNext, isLast }: ActivityComponentProps<FillBlankActivity>) {
|
||||
const [value, setValue] = useState("");
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasBlank = BLANK.test(activity.sentence);
|
||||
const [before, after = ""] = hasBlank ? activity.sentence.split(BLANK) : [activity.sentence, ""];
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !hasBlank && !activity.options) inputRef.current?.focus();
|
||||
}, [active, hasBlank, activity.options]);
|
||||
|
||||
const submit = (answerValue: string) => {
|
||||
if (result !== null) return;
|
||||
const correct = isFillBlankCorrect(answerValue, activity.answer);
|
||||
setChosen(answerValue);
|
||||
setResult(correct);
|
||||
onAnswered(correct, { userAnswer: answerValue });
|
||||
};
|
||||
|
||||
const displayAnswer = chosen ?? "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="fill_blank" topic={activity.topic} />
|
||||
<PromptCard>
|
||||
<p className="text-lg font-semibold leading-loose text-frost-950 sm:text-xl dark:text-frost-50">
|
||||
{before}
|
||||
{activity.options ? (
|
||||
<span
|
||||
className={cn(
|
||||
"mx-1 inline-block min-w-24 rounded-xl border-b-4 px-3 py-0.5 text-center align-baseline",
|
||||
result === null
|
||||
? "border-frost-300 bg-frost-50/80 text-frost-500 dark:bg-white/10 dark:text-frost-200"
|
||||
: result
|
||||
? "border-emerald-400 bg-emerald-50 text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-100"
|
||||
: "border-rose-400 bg-rose-50 text-rose-800 dark:bg-rose-400/15 dark:text-rose-100",
|
||||
)}
|
||||
>
|
||||
{displayAnswer || "?"}
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={displayAnswer}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !result) {
|
||||
event.preventDefault();
|
||||
submit(value);
|
||||
}
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={result !== null}
|
||||
aria-label="Type the missing word"
|
||||
placeholder="type it"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
className={cn(
|
||||
"mx-1 inline-block rounded-xl border-b-4 bg-white/70 px-2 py-0.5 text-center text-frost-950 outline-none backdrop-blur-lg placeholder:text-frost-300 dark:bg-white/10 dark:text-frost-50 dark:placeholder:text-white/30",
|
||||
result === null
|
||||
? "border-frost-300 focus:border-frost-500"
|
||||
: result
|
||||
? "border-emerald-400 bg-emerald-50 text-emerald-900 dark:bg-emerald-400/15 dark:text-emerald-100"
|
||||
: "border-rose-400 bg-rose-50 text-rose-900 dark:bg-rose-400/15 dark:text-rose-100",
|
||||
)}
|
||||
style={{ width: `${Math.max(5, (displayAnswer.length || 6) + 2)}ch` }}
|
||||
/>
|
||||
)}
|
||||
{after}
|
||||
</p>
|
||||
</PromptCard>
|
||||
|
||||
{activity.options ? (
|
||||
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label="Word bank">
|
||||
{activity.options.map((option) => {
|
||||
const state =
|
||||
result === null
|
||||
? "idle"
|
||||
: normalise(option) === normalise(activity.answer)
|
||||
? "correct"
|
||||
: chosen && normalise(chosen) === normalise(option) && result === false
|
||||
? "wrong"
|
||||
: "dim";
|
||||
return (
|
||||
<OptionTile
|
||||
key={option}
|
||||
state={state}
|
||||
disabled={result !== null}
|
||||
onClick={() => submit(option)}
|
||||
className="justify-center text-center"
|
||||
>
|
||||
{option}
|
||||
</OptionTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : result === null ? (
|
||||
<div className="flex gap-2">
|
||||
<Button size="lg" onClick={() => submit(value)} disabled={!value.trim()} className="flex-1">
|
||||
Check answer
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result === null && !activity.options ? (
|
||||
<Hint>Press Enter to check. Small typos are forgiven.</Hint>
|
||||
) : null}
|
||||
|
||||
{result !== null ? (
|
||||
<FeedbackPanel
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
<span>
|
||||
Expected: <span className="font-bold">{activity.answer}</span>
|
||||
{!result && chosen ? (
|
||||
<>
|
||||
{" "}
|
||||
— you wrote <span className="italic">{chosen}</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
92
components/activities/Flashcard.tsx
Normal file
92
components/activities/Flashcard.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
PromptCard,
|
||||
QuestionText,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
|
||||
|
||||
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<FlashcardActivity>) {
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
|
||||
const settle = (correct: boolean) => {
|
||||
if (result !== null) return;
|
||||
setResult(correct);
|
||||
onAnswered(correct);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="flashcard" topic={activity.topic} />
|
||||
<Hint>{flipped ? "Did you know it before flipping?" : "Tap the card to flip it."}</Hint>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFlipped((value) => !value)}
|
||||
className="group relative block w-full text-left [perspective:1400px]"
|
||||
aria-label={flipped ? "Flip back to the question" : "Reveal the answer"}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotateX: flipped ? 180 : 0 }}
|
||||
transition={{ type: "spring", stiffness: 220, damping: 24 }}
|
||||
className="relative min-h-56 w-full [transform-style:preserve-3d]"
|
||||
>
|
||||
<PromptCard className="absolute inset-0 flex flex-col justify-center gap-3 group-hover:border-frost-300 [backface-visibility:hidden]">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
|
||||
Question
|
||||
</span>
|
||||
<QuestionText>{activity.front}</QuestionText>
|
||||
<span className="mt-2 text-xs font-semibold text-slate-400 dark:text-slate-400">
|
||||
tap to flip ↻
|
||||
</span>
|
||||
</PromptCard>
|
||||
|
||||
<div className="[transform:rotateX(180deg)] [backface-visibility:hidden]">
|
||||
<div className="card flex min-h-56 flex-col justify-center gap-3 px-5 py-6 sm:px-6">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
|
||||
Answer
|
||||
</span>
|
||||
<p className="text-lg font-semibold leading-relaxed text-frost-950 dark:text-frost-50">
|
||||
{activity.back}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
{flipped && result === null ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="secondary" size="lg" onClick={() => settle(false)}>
|
||||
😅 Not yet
|
||||
</Button>
|
||||
<Button variant="success" size="lg" onClick={() => settle(true)}>
|
||||
🧊 I knew it
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result !== null ? (
|
||||
<FeedbackPanel
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
headline={result ? "Frozen solid!" : "Keep it on the pile"}
|
||||
onNext={onNext}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
151
components/activities/Matching.tsx
Normal file
151
components/activities/Matching.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn, shuffle } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
PromptCard,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type MatchingActivity = Extract<Activity, { type: "matching" }>;
|
||||
|
||||
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<MatchingActivity>) {
|
||||
const terms = useMemo(
|
||||
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
|
||||
[activity],
|
||||
);
|
||||
const definitions = useMemo(
|
||||
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.definition }))),
|
||||
[activity],
|
||||
);
|
||||
|
||||
const [selectedTerm, setSelectedTerm] = useState<number | null>(null);
|
||||
const [matched, setMatched] = useState<number[]>([]);
|
||||
const [misses, setMisses] = useState(0);
|
||||
const [flash, setFlash] = useState<{ term: number; definition: number } | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const total = activity.pairs.length;
|
||||
const complete = matched.length === total;
|
||||
|
||||
const onDefinitionTap = (definitionIndex: number) => {
|
||||
if (done || selectedTerm === null || matched.includes(definitionIndex)) return;
|
||||
if (selectedTerm === definitionIndex) {
|
||||
const next = [...matched, definitionIndex];
|
||||
setMatched(next);
|
||||
setSelectedTerm(null);
|
||||
if (next.length === total) {
|
||||
const correct = misses === 0;
|
||||
setDone(true);
|
||||
onAnswered(correct);
|
||||
}
|
||||
} else {
|
||||
setMisses((value) => value + 1);
|
||||
setFlash({ term: selectedTerm, definition: definitionIndex });
|
||||
window.setTimeout(() => setFlash(null), 500);
|
||||
setSelectedTerm(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader
|
||||
type="matching"
|
||||
topic={activity.topic}
|
||||
extra={
|
||||
<span className="rounded-full bg-white/70 px-3 py-1 text-xs font-bold text-slate-600 dark:bg-white/10 dark:text-slate-200">
|
||||
{matched.length}/{total} matched
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<PromptCard className="py-4">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<Hint>
|
||||
{done
|
||||
? "All matched!"
|
||||
: selectedTerm === null
|
||||
? "Tap a term on the left, then its match on the right."
|
||||
: "Now tap the matching definition →"}
|
||||
</Hint>
|
||||
</PromptCard>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{terms.map((term) => {
|
||||
const isMatched = matched.includes(term.index);
|
||||
const isSelected = selectedTerm === term.index;
|
||||
const isFlashing = flash?.term === term.index;
|
||||
return (
|
||||
<button
|
||||
key={`term-${term.index}`}
|
||||
type="button"
|
||||
disabled={isMatched || done}
|
||||
onClick={() => setSelectedTerm(isSelected ? null : term.index)}
|
||||
className={cn(
|
||||
"min-h-16 rounded-2xl border px-3 py-2 text-left text-sm font-semibold backdrop-blur-xl transition-all",
|
||||
isMatched
|
||||
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
|
||||
: isSelected
|
||||
? "border-accent-400 bg-accent-50/80 text-frost-900 dark:bg-accent-500/20 dark:text-white"
|
||||
: "border-white/60 bg-white/55 text-frost-950 hover:bg-white/80 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50",
|
||||
isFlashing && "animate-wiggle border-rose-400 bg-rose-50 dark:bg-rose-400/15",
|
||||
)}
|
||||
>
|
||||
<span className="mr-1">{isMatched ? "✓" : "•"}</span>
|
||||
{term.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{definitions.map((definition) => {
|
||||
const isMatched = matched.includes(definition.index);
|
||||
const isFlashing = flash?.definition === definition.index;
|
||||
return (
|
||||
<button
|
||||
key={`def-${definition.index}`}
|
||||
type="button"
|
||||
disabled={isMatched || done || selectedTerm === null}
|
||||
onClick={() => onDefinitionTap(definition.index)}
|
||||
className={cn(
|
||||
"min-h-16 rounded-2xl border-2 px-3 py-2 text-left text-sm transition-all",
|
||||
isMatched
|
||||
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
|
||||
: "border-white/60 bg-white/55 text-frost-700 hover:bg-white/80 disabled:opacity-60 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-200",
|
||||
isFlashing && "animate-wiggle border-rose-400 bg-rose-50 dark:bg-rose-400/15",
|
||||
)}
|
||||
>
|
||||
{definition.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{complete ? (
|
||||
<FeedbackPanel
|
||||
correct={misses === 0}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
headline={misses === 0 ? "Perfect match!" : "Matched — with a few slips"}
|
||||
detail={
|
||||
<span>
|
||||
{misses === 0
|
||||
? "You matched everything first try."
|
||||
: `You made ${misses} wrong ${misses === 1 ? "attempt" : "attempts"}.`}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
components/activities/MultipleChoice.tsx
Normal file
100
components/activities/MultipleChoice.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
OptionTile,
|
||||
PromptCard,
|
||||
QuestionText,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type MultipleChoiceActivity = Extract<Activity, { type: "multiple_choice" }>;
|
||||
|
||||
export function MultipleChoice({
|
||||
activity,
|
||||
active,
|
||||
onAnswered,
|
||||
onNext,
|
||||
isLast,
|
||||
}: ActivityComponentProps<MultipleChoiceActivity>) {
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const answered = selected !== null;
|
||||
|
||||
const choose = (index: number) => {
|
||||
if (answered) return;
|
||||
setSelected(index);
|
||||
onAnswered(index === activity.correctIndex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || answered) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && /^(INPUT|TEXTAREA)$/.test(target.tagName)) return;
|
||||
const index = Number.parseInt(event.key, 10) - 1;
|
||||
if (index >= 0 && index < activity.options.length) {
|
||||
event.preventDefault();
|
||||
choose(index);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, answered, activity.options.length]);
|
||||
|
||||
const stateFor = (index: number): "idle" | "correct" | "wrong" | "dim" => {
|
||||
if (!answered) return "idle";
|
||||
if (index === activity.correctIndex) return "correct";
|
||||
if (index === selected) return "wrong";
|
||||
return "dim";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="multiple_choice" topic={activity.topic} />
|
||||
<PromptCard>
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
</PromptCard>
|
||||
|
||||
<div className="flex flex-col gap-2.5" role="group" aria-label="Answer options">
|
||||
{activity.options.map((option, index) => (
|
||||
<OptionTile
|
||||
key={option + index}
|
||||
state={stateFor(index)}
|
||||
disabled={answered}
|
||||
onClick={() => choose(index)}
|
||||
shortcut={String(index + 1)}
|
||||
>
|
||||
{option}
|
||||
</OptionTile>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!answered ? <Hint>Tip: press 1–4 on a keyboard.</Hint> : null}
|
||||
|
||||
{answered ? (
|
||||
<FeedbackPanel
|
||||
correct={selected === activity.correctIndex}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
selected === activity.correctIndex ? null : (
|
||||
<>
|
||||
Correct answer:{" "}
|
||||
<span className="font-bold">{activity.options[activity.correctIndex]}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
components/activities/Ordering.tsx
Normal file
132
components/activities/Ordering.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { Reorder } from "framer-motion";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn, shuffle } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
PromptCard,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
|
||||
|
||||
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<OrderingActivity>) {
|
||||
const initial = useMemo(() => shuffle(activity.items), [activity]);
|
||||
const [items, setItems] = useState<string[]>(initial);
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [correct, setCorrect] = useState(false);
|
||||
|
||||
const isRight = (list: string[]) => list.every((item, index) => item === activity.items[index]);
|
||||
|
||||
const check = () => {
|
||||
if (checked) return;
|
||||
const result = isRight(items);
|
||||
setChecked(true);
|
||||
setCorrect(result);
|
||||
onAnswered(result, { userAnswer: items.join(" → ") });
|
||||
};
|
||||
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
if (checked) return;
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= items.length) return;
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setItems(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="ordering" topic={activity.topic} />
|
||||
<PromptCard className="py-4">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<Hint>
|
||||
{checked ? "Here is the correct order:" : "Drag the cards, or use ▲▼, then check your order."}
|
||||
</Hint>
|
||||
</PromptCard>
|
||||
|
||||
{checked ? (
|
||||
<ol className="flex flex-col gap-2.5">
|
||||
{activity.items.map((item, index) => {
|
||||
const wasRight = items[index] === item;
|
||||
return (
|
||||
<li
|
||||
key={`solution-${item}`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-2xl border-2 px-4 py-3 text-sm font-semibold",
|
||||
wasRight
|
||||
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-900 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
|
||||
: "border-rose-200/80 bg-rose-50/85 text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/15 dark:text-rose-100",
|
||||
)}
|
||||
>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-white/70 text-xs font-extrabold dark:bg-white/15">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex-1">{item}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<Reorder.Group axis="y" values={items} onReorder={setItems} className="flex flex-col gap-2.5">
|
||||
{items.map((item, index) => (
|
||||
<Reorder.Item
|
||||
key={item}
|
||||
value={item}
|
||||
className="flex cursor-grab items-center gap-2 rounded-2xl border border-white/60 bg-white/60 px-3 py-3 text-sm font-semibold text-frost-950 shadow-sm backdrop-blur-xl active:cursor-grabbing dark:border-white/12 dark:bg-white/[0.08] dark:text-frost-50"
|
||||
>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-100 text-xs font-extrabold text-frost-700 dark:bg-white/15 dark:text-frost-100">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex-1">{item}</span>
|
||||
<span className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Move "${item}" up`}
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Move "${item}" down`}
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === items.length - 1}
|
||||
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</span>
|
||||
<span aria-hidden className="text-slate-400">
|
||||
⠿
|
||||
</span>
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)}
|
||||
|
||||
{!checked ? (
|
||||
<Button size="lg" onClick={check} disabled={items.length < 2}>
|
||||
Check order
|
||||
</Button>
|
||||
) : (
|
||||
<FeedbackPanel
|
||||
correct={correct}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
189
components/activities/ShortAnswer.tsx
Normal file
189
components/activities/ShortAnswer.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ApiError, gradeAnswer } from "@/lib/client-api";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
PromptCard,
|
||||
QuestionText,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type ShortAnswerActivity = Extract<Activity, { type: "short_answer" }>;
|
||||
|
||||
type GradeState =
|
||||
| { status: "idle" }
|
||||
| { status: "grading" }
|
||||
| { status: "graded"; correct: boolean; feedback: string; missing: string[] }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "self-check" };
|
||||
|
||||
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps<ShortAnswerActivity>) {
|
||||
const [value, setValue] = useState("");
|
||||
const [state, setState] = useState<GradeState>({ status: "idle" });
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && state.status === "idle") {
|
||||
const timer = window.setTimeout(() => textareaRef.current?.focus(), 350);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [active, state.status]);
|
||||
|
||||
const grade = async () => {
|
||||
if (!value.trim() || state.status === "grading") return;
|
||||
setState({ status: "grading" });
|
||||
try {
|
||||
const result = await gradeAnswer({
|
||||
question: activity.question,
|
||||
referenceAnswer: activity.referenceAnswer,
|
||||
keyPoints: activity.keyPoints,
|
||||
userAnswer: value.trim(),
|
||||
language,
|
||||
});
|
||||
setState({
|
||||
status: "graded",
|
||||
correct: result.correct,
|
||||
feedback: result.feedback,
|
||||
missing: result.missing,
|
||||
});
|
||||
onAnswered(result.correct, { userAnswer: value.trim() });
|
||||
} catch (error) {
|
||||
setState({
|
||||
status: "error",
|
||||
message:
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Grading failed. Check that Ollama is reachable and try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const selfCheck = (correct: boolean) => {
|
||||
setState({ status: "graded", correct, feedback: "Self-checked — you judged this one yourself.", missing: [] });
|
||||
onAnswered(correct, { userAnswer: value.trim() });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="short_answer" topic={activity.topic} />
|
||||
<PromptCard>
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
</PromptCard>
|
||||
|
||||
<label className="sr-only" htmlFor={`answer-${activity.id}`}>
|
||||
Your answer
|
||||
</label>
|
||||
<textarea
|
||||
id={`answer-${activity.id}`}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
void grade();
|
||||
}
|
||||
}}
|
||||
disabled={state.status === "grading" || state.status === "graded"}
|
||||
rows={3}
|
||||
placeholder="Type your answer in your own words…"
|
||||
className="w-full resize-none rounded-2xl border-2 border-frost-200 bg-white/90 px-4 py-3 text-[15px] font-medium text-frost-950 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-frost-500 disabled:opacity-70 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50 dark:placeholder:text-slate-400"
|
||||
/>
|
||||
|
||||
{state.status === "idle" ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Hint>Ctrl/⌘ + Enter to submit.</Hint>
|
||||
<Button size="lg" onClick={() => void grade()} disabled={!value.trim()}>
|
||||
Submit answer
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === "grading" ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/60 bg-white/50 backdrop-blur-xl px-4 py-3 text-sm font-semibold text-frost-800 dark:border-white/15 dark:bg-white/10 dark:text-frost-100">
|
||||
<Spinner className="size-5" />
|
||||
Grading your answer…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === "error" ? (
|
||||
<div className="rounded-2xl border-2 border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
|
||||
<p className="font-bold">Couldn't grade that answer</p>
|
||||
<p className="mt-1">{state.message}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => void grade()}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
|
||||
Show answer & self-check
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === "self-check" ? (
|
||||
<div className="card space-y-3 p-4">
|
||||
<p className="text-sm font-bold text-frost-900 dark:text-frost-100">Model answer</p>
|
||||
<p className="text-sm leading-relaxed text-frost-700 dark:text-frost-200">{activity.referenceAnswer}</p>
|
||||
<p className="text-xs text-frost-500 dark:text-frost-400">Key points: {activity.keyPoints.join(" · ")}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="secondary" onClick={() => selfCheck(false)}>
|
||||
😅 I missed it
|
||||
</Button>
|
||||
<Button variant="success" onClick={() => selfCheck(true)}>
|
||||
🧊 I had it right
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === "graded" ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border-2 p-3 text-sm font-semibold",
|
||||
state.correct
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-100"
|
||||
: "border-rose-300 bg-rose-50 text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100",
|
||||
)}
|
||||
>
|
||||
<p className="font-extrabold uppercase tracking-wide">
|
||||
{state.correct ? "Accepted" : "Needs work"}
|
||||
</p>
|
||||
<p className="mt-1 font-normal">{state.feedback}</p>
|
||||
{state.missing.length ? (
|
||||
<ul className="mt-2 list-inside list-disc font-normal">
|
||||
{state.missing.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{!state.correct ? (
|
||||
<p className="mt-2 font-normal">
|
||||
<span className="font-bold">Model answer: </span>
|
||||
{activity.referenceAnswer}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FeedbackPanel
|
||||
correct={state.correct}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
components/activities/TrueFalse.tsx
Normal file
90
components/activities/TrueFalse.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
FeedbackPanel,
|
||||
Hint,
|
||||
PromptCard,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
||||
|
||||
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
const x = useMotionValue(0);
|
||||
const trueOpacity = useTransform(x, [30, 110], [0, 1]);
|
||||
const falseOpacity = useTransform(x, [-30, -110], [1, 0]);
|
||||
|
||||
const answer = (value: boolean) => {
|
||||
if (result !== null) return;
|
||||
setResult(value === activity.answer);
|
||||
onAnswered(value === activity.answer, { userAnswer: value ? "true" : "false" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityHeader type="true_false" topic={activity.topic} />
|
||||
<Hint>
|
||||
{result === null ? "Swipe the card right for true, left for false — or tap a button." : "Nice swipe!"}
|
||||
</Hint>
|
||||
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
drag={result === null ? "x" : false}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.55}
|
||||
style={{ x }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (info.offset.x > 90) answer(true);
|
||||
else if (info.offset.x < -90) answer(false);
|
||||
}}
|
||||
whileTap={{ cursor: "grabbing" }}
|
||||
className="touch-pan-y"
|
||||
>
|
||||
<PromptCard className="relative min-h-44 select-none">
|
||||
<motion.span style={{ opacity: trueOpacity }} className="absolute left-4 top-3 text-sm font-extrabold uppercase tracking-widest text-emerald-500">
|
||||
✓ true
|
||||
</motion.span>
|
||||
<motion.span style={{ opacity: falseOpacity }} className="absolute right-4 top-3 text-sm font-extrabold uppercase tracking-widest text-rose-500">
|
||||
false ✕
|
||||
</motion.span>
|
||||
<p className="pt-6 text-center text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50">
|
||||
“{activity.statement}”
|
||||
</p>
|
||||
</PromptCard>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{result === null ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="secondary" size="lg" onClick={() => answer(false)} className="border-rose-200 text-rose-700 dark:text-rose-200">
|
||||
✕ False
|
||||
</Button>
|
||||
<Button variant="success" size="lg" onClick={() => answer(true)}>
|
||||
✓ True
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FeedbackPanel
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
<span>
|
||||
This statement is <span className="font-bold">{activity.answer ? "true" : "false"}</span>.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
components/activities/index.tsx
Normal file
44
components/activities/index.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { FillBlank } from "./FillBlank";
|
||||
import { Flashcard } from "./Flashcard";
|
||||
import { Matching } from "./Matching";
|
||||
import { MultipleChoice } from "./MultipleChoice";
|
||||
import { Ordering } from "./Ordering";
|
||||
import { ShortAnswer } from "./ShortAnswer";
|
||||
import { TrueFalse } from "./TrueFalse";
|
||||
import { type ActivityComponentProps } from "./shared";
|
||||
|
||||
export type { ActivityComponentProps } from "./shared";
|
||||
export { TYPE_META } from "./shared";
|
||||
|
||||
/**
|
||||
* Renders an activity from JSON based on its `type` field.
|
||||
* Every activity type is a self-contained React component.
|
||||
*/
|
||||
export function ActivityRenderer(props: ActivityComponentProps) {
|
||||
const { activity } = props;
|
||||
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return <Flashcard {...props} activity={activity} />;
|
||||
case "multiple_choice":
|
||||
return <MultipleChoice {...props} activity={activity} />;
|
||||
case "true_false":
|
||||
return <TrueFalse {...props} activity={activity} />;
|
||||
case "fill_blank":
|
||||
return <FillBlank {...props} activity={activity} />;
|
||||
case "matching":
|
||||
return <Matching {...props} activity={activity} />;
|
||||
case "ordering":
|
||||
return <Ordering {...props} activity={activity} />;
|
||||
case "short_answer":
|
||||
return <ShortAnswer {...props} activity={activity} />;
|
||||
default: {
|
||||
// Exhaustiveness guard: adding a new activity type without a component
|
||||
// becomes a TypeScript error.
|
||||
const never: never = activity;
|
||||
return <p className="text-sm">Unsupported activity: {JSON.stringify(never)}</p>;
|
||||
}
|
||||
}
|
||||
}
|
||||
204
components/activities/shared.tsx
Normal file
204
components/activities/shared.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
export type ActivityComponentProps<A extends Activity = Activity> = {
|
||||
activity: A;
|
||||
/** True when this slide is the one currently filling the screen. */
|
||||
active: boolean;
|
||||
/** Language of the deck, so AI grading answers in the same language. */
|
||||
language?: string;
|
||||
/** Report the result to the feed (once per attempt). */
|
||||
onAnswered: (correct: boolean, meta?: { userAnswer?: string }) => void;
|
||||
/** Scroll the feed to the next slide. */
|
||||
onNext: () => void;
|
||||
isLast: boolean;
|
||||
};
|
||||
|
||||
export const TYPE_META: Record<Activity["type"], { label: string; icon: string; tint: string }> = {
|
||||
flashcard: { label: "Flashcard", icon: "🃏", tint: "tint-sky" },
|
||||
multiple_choice: { label: "Multiple choice", icon: "🔘", tint: "tint-lilac" },
|
||||
true_false: { label: "True or false", icon: "⚖️", tint: "tint-mint" },
|
||||
fill_blank: { label: "Fill the blank", icon: "✏️", tint: "tint-peach" },
|
||||
matching: { label: "Match up", icon: "🧲", tint: "tint-sky" },
|
||||
ordering: { label: "Put in order", icon: "🔢", tint: "tint-lilac" },
|
||||
short_answer: { label: "Short answer", icon: "💬", tint: "tint-rose" },
|
||||
};
|
||||
export function ActivityHeader({
|
||||
type,
|
||||
topic,
|
||||
extra,
|
||||
}: {
|
||||
type: Activity["type"];
|
||||
topic?: string;
|
||||
extra?: React.ReactNode;
|
||||
}) {
|
||||
const meta = TYPE_META[type];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm",
|
||||
meta.tint,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{meta.icon}</span>
|
||||
{meta.label}
|
||||
</span>
|
||||
{topic ? (
|
||||
<span className="glass-chip rounded-full px-3 py-1 text-xs font-medium text-frost-600 dark:text-frost-300">
|
||||
{topic}
|
||||
</span>
|
||||
) : null}
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("glass-panel rounded-[1.35rem] px-5 py-6 text-balance sm:px-6", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionText({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<p className={cn("text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** Standard tile used for answer options. */
|
||||
export function OptionTile({
|
||||
children,
|
||||
state = "idle",
|
||||
disabled,
|
||||
onClick,
|
||||
className,
|
||||
shortcut,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
state?: "idle" | "selected" | "correct" | "wrong" | "dim";
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
shortcut?: string;
|
||||
}) {
|
||||
const states: Record<string, string> = {
|
||||
idle: "border-white/60 bg-white/55 hover:bg-white/80 dark:border-white/12 dark:bg-white/[0.07] dark:hover:bg-white/[0.12]",
|
||||
selected: "border-accent-400 bg-accent-50/80 dark:bg-accent-500/20",
|
||||
correct:
|
||||
"border-emerald-300 bg-emerald-50/90 text-emerald-950 dark:border-emerald-400/50 dark:bg-emerald-400/15 dark:text-emerald-50",
|
||||
wrong: "border-rose-300 bg-rose-50/90 text-rose-950 dark:border-rose-400/50 dark:bg-rose-400/15 dark:text-rose-50",
|
||||
dim: "border-white/40 bg-white/35 text-frost-400 dark:border-white/10 dark:bg-white/[0.04] dark:text-frost-500",
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-2xl border px-4 py-3.5 text-left text-[15px] font-medium backdrop-blur-xl transition-all duration-200 disabled:cursor-default",
|
||||
states[state],
|
||||
!disabled && "active:scale-[0.99]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{shortcut ? (
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-lg bg-black/5 text-xs font-bold text-slate-600 dark:bg-white/10 dark:text-slate-300">
|
||||
{shortcut}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-1">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedbackPanel({
|
||||
correct,
|
||||
explanation,
|
||||
xp,
|
||||
onNext,
|
||||
isLast,
|
||||
headline,
|
||||
detail,
|
||||
children,
|
||||
}: {
|
||||
correct: boolean;
|
||||
explanation: string;
|
||||
xp?: number;
|
||||
onNext: () => void;
|
||||
isLast?: boolean;
|
||||
headline?: string;
|
||||
detail?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 32 }}
|
||||
className={cn(
|
||||
"rounded-3xl border p-4 shadow-lift backdrop-blur-xl",
|
||||
correct
|
||||
? "border-emerald-200/80 bg-gradient-to-b from-emerald-50/95 to-emerald-100/70 dark:border-emerald-400/30 dark:from-emerald-500/15 dark:to-emerald-500/5"
|
||||
: "border-rose-200/80 bg-gradient-to-b from-rose-50/95 to-rose-100/70 dark:border-rose-400/30 dark:from-rose-500/15 dark:to-rose-500/5",
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-base font-extrabold",
|
||||
correct ? "text-emerald-900 dark:text-emerald-200" : "text-rose-900 dark:text-rose-200",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{correct ? "✅" : "💧"}</span>
|
||||
{headline ?? (correct ? "Nice one!" : "Not quite")}
|
||||
</p>
|
||||
{correct && xp ? (
|
||||
<motion.span
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="rounded-full bg-emerald-500 px-3 py-1 text-sm font-extrabold text-white shadow"
|
||||
>
|
||||
+{xp} XP
|
||||
</motion.span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{detail ? <div className="mt-2 text-sm text-frost-700 dark:text-frost-200">{detail}</div> : null}
|
||||
|
||||
<p className="mt-2 text-sm leading-relaxed text-frost-700 dark:text-frost-200">
|
||||
<span className="font-bold">Why: </span>
|
||||
{explanation}
|
||||
</p>
|
||||
|
||||
{children}
|
||||
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<Button onClick={onNext} size="md" className="min-w-28">
|
||||
{isLast ? "See results" : "Next"}
|
||||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small hint row under a question, e.g. "Tap a term, then its definition". */
|
||||
export function Hint({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-sm text-frost-600 dark:text-frost-300">{children}</p>;
|
||||
}
|
||||
239
components/icons.tsx
Normal file
239
components/icons.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Small inline SVG icons used for the app chrome so that critical controls
|
||||
* don't depend on a system emoji font being installed.
|
||||
*/
|
||||
|
||||
type IconProps = { className?: string; strokeWidth?: number };
|
||||
|
||||
const base = "size-5";
|
||||
|
||||
function Svg({
|
||||
children,
|
||||
className,
|
||||
filled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
filled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className ?? base}
|
||||
fill={filled ? "currentColor" : "none"}
|
||||
stroke={filled ? "none" : "currentColor"}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IceCubeIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3.2 19 7v10l-7 3.8L5 17V7l7-3.8Z" />
|
||||
<path d="M5 7l7 3.8L19 7M12 10.8V21" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SnowflakeIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 2v20M4.5 6.5l15 11M19.5 6.5l-15 11" />
|
||||
<path d="M12 6l-2.2-2.2M12 6l2.2-2.2M12 18l-2.2 2.2M12 18l2.2 2.2M7 9.2 4.8 8.6M7 14.8l-2.2.6M17 9.2l2.2-.6M17 14.8l2.2.6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GearIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.11A1.7 1.7 0 0 0 8.9 19.3a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.7 15a1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.11A1.7 1.7 0 0 0 4.7 8.9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.7a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.11A1.7 1.7 0 0 0 15.1 4.7a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9v.03A1.7 1.7 0 0 0 21 10.06H21a2 2 0 1 1 0 4h-.11A1.7 1.7 0 0 0 19.4 15Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.6 6.6 0 0 0 9.8 9.8Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoltIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className} filled>
|
||||
<path d="M13.5 2 4 13.2h5.4L9.5 22 19 10.8h-5.4L13.5 2Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlameIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className} filled>
|
||||
<path d="M12 2s4.5 4.2 4.5 8.2c0 1.2-.5 2.2-1.2 2.9.1-1.6-.5-3.3-1.8-4.6.3 2.6-1 4-2.2 5.1-1 .9-1.8 1.8-1.8 3.2 0 1 .4 1.9 1.1 2.6-2.9-.3-5.1-2.6-5.1-5.5C5.5 9.7 12 8 12 2Z" />
|
||||
<path d="M12 22c2.2 0 4-1.6 4-3.7 0-1.6-.8-2.4-1.7-3.3-.6-.6-1.3-1.3-1.6-2.3-.8.8-1.4 2-1.4 3.2 0 .9-.4 1.4-1 1.8-.5.4-1.1.9-1.1 1.9C9.2 21.1 10.4 22 12 22Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SparklesIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z" />
|
||||
<path d="M18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayersIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3.5 20 8l-8 4.5L4 8l8-4.5Z" />
|
||||
<path d="m4.6 12.4 7.4 4.2 7.4-4.2M4.6 16.4l7.4 4.2 7.4-4.2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4l2 2.5h9A1.5 1.5 0 0 1 21 10v8a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18V7.5Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CameraIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4 8.5A1.5 1.5 0 0 1 5.5 7h1.9l1.1-1.8A1 1 0 0 1 9.4 5h5.2a1 1 0 0 1 .9.5L16.6 7h1.9A1.5 1.5 0 0 1 20 8.5v9A1.5 1.5 0 0 1 18.5 19h-13A1.5 1.5 0 0 1 4 17.5v-9Z" />
|
||||
<circle cx="12" cy="12.8" r="3.2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className} filled>
|
||||
<path d="M8 5.5v13l11-6.5-11-6.5Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3v12M7.5 10.5 12 15l4.5-4.5M4 20h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 16V4M7.5 8.5 12 4l4.5 4.5M4 20h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4v-5H9v5H5a1 1 0 0 1-1-1v-8.5Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckCircleIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8.2 12.4 2.6 2.6 5-5.4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertTriangleIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 4.5 21 19.5H3L12 4.5Z" />
|
||||
<path d="M12 10v4.2M12 17.2v.1" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function XCircleIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m9 9 6 6M15 9l-6 6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShareIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3v12M8.2 6.8 12 3l3.8 3.8" />
|
||||
<path d="M6 12H5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1h-1" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M14.5 5.5 8 12l6.5 6.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m9.5 5.5 6.5 6.5-6.5 6.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RefreshIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M20 12a8 8 0 1 1-2.4-5.7M20 4v4h-4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
151
components/ui.tsx
Normal file
151
components/ui.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { SnowflakeIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "success";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
/**
|
||||
* ChatGPT-flavoured buttons: pill-shaped, near-black primary in light mode and
|
||||
* white in dark mode, quiet secondaries with hairline borders.
|
||||
*/
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: "btn-primary",
|
||||
secondary: "btn-secondary",
|
||||
ghost: "btn-ghost",
|
||||
danger: "btn-primary !bg-rose-600 hover:!bg-rose-700 dark:!bg-rose-500 dark:!text-white dark:hover:!bg-rose-600",
|
||||
success: "btn-primary !bg-emerald-600 hover:!bg-emerald-700 dark:!bg-emerald-500 dark:!text-white dark:hover:!bg-emerald-600",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: "h-9 px-3.5 text-[13px] rounded-full",
|
||||
md: "h-10 px-4 text-sm rounded-full",
|
||||
lg: "h-11 px-5 text-[15px] rounded-full",
|
||||
};
|
||||
|
||||
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, variant = "primary", size = "md", loading, children, disabled, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"inline-flex select-none items-center justify-center whitespace-nowrap font-medium transition disabled:cursor-not-allowed disabled:opacity-40",
|
||||
VARIANTS[variant],
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Spinner className="size-4" /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={cn("animate-spin", className)} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small neutral pill. Colour is reserved for meaning, not decoration. */
|
||||
export function Chip({
|
||||
children,
|
||||
className,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
tone?: "neutral" | "accent" | "success" | "warning" | "danger";
|
||||
}) {
|
||||
const tones: Record<string, string> = {
|
||||
neutral: "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
|
||||
accent: "bg-accent-500/12 text-accent-700 dark:text-accent-200",
|
||||
success: "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
warning: "bg-amber-500/14 text-amber-800 dark:text-amber-300",
|
||||
danger: "bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[12px] font-medium leading-none",
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
value,
|
||||
max,
|
||||
className,
|
||||
label,
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const percent = max <= 0 ? 0 : Math.min(100, Math.max(0, (value / max) * 100));
|
||||
return (
|
||||
<div
|
||||
className={cn("h-1 w-full overflow-hidden rounded-full bg-[var(--surface-sunken)]", className)}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(percent)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label ?? "Progress"}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--text)]/75 transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
children,
|
||||
icon,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="animate-rise flex flex-col items-center px-6 py-16 text-center sm:py-24">
|
||||
<span
|
||||
className="mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-subtle)]"
|
||||
aria-hidden
|
||||
>
|
||||
{icon ?? <SnowflakeIcon className="size-6" />}
|
||||
</span>
|
||||
<h2 className="text-[17px] font-semibold tracking-[-0.01em] text-[var(--text)]">{title}</h2>
|
||||
{children ? (
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-[var(--text-muted)]">{children}</p>
|
||||
) : null}
|
||||
{action ? <div className="mt-6">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue