diff --git a/.env.example b/.env.example index 6d9a4bc..cea1779 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,7 @@ TEXT_MODEL=gpt-oss:120b-cloud # RATE_LIMIT_WINDOW_MS=300000 # RATE_LIMIT_GENERATE=12 # RATE_LIMIT_GRADE=40 +# RATE_LIMIT_HOMEWORK=12 # RATE_LIMIT_HEALTH=90 # Optional tuning ------------------------------------------------------------ diff --git a/README.md b/README.md index 77d66af..e212f77 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,21 @@ no database, no data leaving your machine except the requests you send to Ollama and **Regenerate deck** — rebuilds the activities from the stored notes so you can keep drilling until you're ready +**Make my homework** + +- Same intake as a deck — paste the assignment, photograph it, or add a PDF/DOCX/PPTX/ODT/text + file; photos go through the same vision model and language guard that notes do +- Two modes: **Just make it** returns the finished homework (the answer only, ready to copy), and + **Explain how to make this homework** turns it into a guided walkthrough +- The guide is one step per screen in the same snap-feed feel as the study feed: what to do and + why, **three progressive hints** per step (nudge → hint → the full answer), and a **checkpoint** + built from the normal activity components and graded through `/api/grade` +- The last screen assembles the solution from the step answers, with a copy button +- Results live in `freezmo.homework.v1` with a small history you can reopen or delete; **Make it + again** re-runs from the stored assignment text — original files are never saved +- The answer and the guide are always written in the **language of the assignment**, never + translated into the interface language + **Activity types** (each one is its own React component, rendered from JSON by a `type` field) | Type | Interaction | @@ -335,12 +350,13 @@ the two pipeline steps stay separate either way. ## Rate limits -Generation is expensive (two model calls per deck), so the API routes are limited per client IP with -a sliding window. Everything is configurable in `.env.local`: +Generation is expensive (one to two model calls per request), so the API routes are limited per +client IP with a sliding window. Everything is configurable in `.env.local`: | Route | Default | Env var | | --- | --- | --- | | `POST /api/generate` | 12 per 5 minutes | `RATE_LIMIT_GENERATE` | +| `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` | | `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` | | `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` | | window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` | @@ -581,20 +597,23 @@ app/ page.tsx library ("Your icebox") create/page.tsx upload screen deck/[id]/page.tsx study feed for a saved deck + homework/page.tsx “Make my homework” (assignment in, homework or guide out) api/generate/route.ts images/text in → NDJSON progress → deck JSON out + api/homework/route.ts assignment in → NDJSON progress → homework/guide JSON out api/grade/route.ts short-answer grading api/health/route.ts Ollama reachability + model availability components/ activities/ one component per activity type + shared shell Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx + homework/ make-my-homework screens (input, result, guide feed, history) UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx InstallSheet.tsx TabBar.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx lib/ ollama.ts server-only HTTP client (timeouts, auth, error mapping) - prompts.ts the vision, generation, grading and repair prompts - ai.ts the three pipeline steps with zod validation + retry + prompts.ts the vision, generation, grading, homework and repair prompts + ai.ts every pipeline step with zod validation + retry schemas.ts zod schemas and shared types config.ts env config, cloud/direct model-name normalisation documents.ts client-side PDF/DOCX/PPTX/ODT/text extraction @@ -607,6 +626,9 @@ lib/ language.ts script/language checks that catch translated transcriptions ui-events.ts tiny event bus (any screen can open the settings dialog) storage.ts localStorage deck library, import/export + homework.ts localStorage homework results + solution assembly + use-notes-intake.ts shared client-side photo/document intake hook + clipboard.ts copy helper with a non-secure-context fallback image.ts client-side validation, resize and compression client-api.ts browser client for the API routes (NDJSON reader) xp.ts utils.ts deck-ops.ts confetti.ts @@ -629,10 +651,11 @@ scripts/ | Route | Body | Returns | | --- | --- | --- | | `POST /api/generate` | `{ images?: string[], text?, difficulty, count?, focus?, language?, mode?, existing? }` | NDJSON stream: `{type:"stage"}`, `{type:"deck",deck,meta}`, `{type:"error"}` | +| `POST /api/homework` | `{ mode: "make" \| "guide", images?: string[], text?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` | | `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` | | `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` | -All three routes accept an optional **`x-ollama-key`** header (the browser's own key; required in +All four routes accept an optional **`x-ollama-key`** header (the browser's own key; required in `user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers `kind: "auth"` (or `kind: "no_credits"` for HTTP 402), and exceeding a rate limit answers **429** with `Retry-After`. Keys are used for the single request and discarded. @@ -640,9 +663,9 @@ with `Retry-After`. Keys are used for the single request and discarded. Images are sent as base64 data URLs; the server strips the prefix before handing them to Ollama. Uploads are validated for type (photos and the document formats above), count (max 8 photos, 6 documents, 12 images) and size (max ~8 MB per image after compression, 40 MB per document) and the -client compresses aggressively, so a typical upload is 1–3 MB. `language` is optional on both routes: -the vision step fills it in automatically, and the UI passes the stored deck language for grading, -regeneration and “add more”. +client compresses aggressively, so a typical upload is 1–3 MB. `language` is optional wherever it +appears: the vision step fills it in automatically, and the UI passes the stored deck language for +grading, regeneration and “add more” (and the assignment language for homework). --- @@ -870,6 +893,17 @@ Things I chose while building, with the reasoning: (hide the text → screenshot → worst-case background luminance per glyph rect): 0 runs below AA across library and settings, both themes, both languages. +35. **“Make my homework” reuses the study pipeline instead of growing its own.** The intake is the + same client-side parsing as `/create` (lib/documents + lib/image, wrapped in `useNotesIntake`), + the photos go through the same `extractNotes` vision step and language guard, and the guide's + checkpoints are ordinary activity JSON rendered by the existing components — just a + `checkpoint` field on a step. The route streams NDJSON like `/api/generate` and has its own + rate limit. Two things are deliberately different: the finished answer and the guide are + written in the *assignment's* language (the interface language never leaks in), and the final + solution is assembled on the client from the step answers instead of asking the model for a + second copy. Result records store the assignment text — the vision transcription when photos + were used — so “Make it again” works without storing a single original file. + 14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`). Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision diff --git a/app/api/homework/route.ts b/app/api/homework/route.ts new file mode 100644 index 0000000..6cb63ea --- /dev/null +++ b/app/api/homework/route.ts @@ -0,0 +1,225 @@ +import { z } from "zod"; + +import { buildHomeworkGuide, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai"; +import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; +import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; +import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; +import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { translate } from "@/lib/i18n-dict"; +import { homeworkModeSchema, type HomeworkStep } from "@/lib/schemas"; +import { uid } from "@/lib/utils"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +/** ~8 MB per image after client-side resizing; up to 16 images (photos + PDF pages). */ +const MAX_IMAGE_CHARS = 8_000_000; +/** Generous but finite: ~16 compressed images plus JSON overhead. */ +const MAX_BODY_BYTES = 33554432; + +const requestSchema = z.object({ + mode: homeworkModeSchema, + images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + text: z.string().max(120_000).optional(), + /** Language of the assignment (usually detected by the vision step). */ + language: z.string().max(60).optional(), +}); + +type StreamEvent = + | { type: "stage"; stage: PipelineStage; message: string } + | { type: "homework"; homework: unknown; meta: Record } + | { type: "error"; error: string; kind?: string }; + +function encoder() { + const enc = new TextEncoder(); + return (event: StreamEvent) => enc.encode(`${JSON.stringify(event)}\n`); +} + +/** Same pipeline shape as /api/generate: vision (photos only) then one model call. */ +export async function POST(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + const oversized = rejectOversizedBody(request, MAX_BODY_BYTES); + if (oversized) return oversized; + + const config = getServerConfig(); + + // Cheap requests are rejected before any work happens. + const limit = rateLimit(`homework:${clientKey(request)}`, config.rateLimits.homework, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); + } + + const parsed = requestSchema.safeParse(payload); + if (!parsed.success) { + return Response.json( + { + error: translate(locale, "server.invalidRequest", { + detail: parsed.error.issues + .slice(0, 3) + .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`) + .join("; "), + }), + }, + { status: 400 }, + ); + } + + const { mode, images, text, language } = parsed.data; + const imageList = (images ?? []).map(stripDataUrlPrefix).filter(Boolean); + + if (imageList.length === 0 && !text?.trim()) { + return Response.json({ error: translate(locale, "server.needAssignment") }, { status: 400 }); + } + + const encode = encoder(); + const abort = new AbortController(); + // A single hard deadline for the whole pipeline (two sequential model calls). + const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2); + + const stream = new ReadableStream({ + async start(controller) { + const send = (event: StreamEvent) => { + try { + controller.enqueue(encode(event)); + } catch { + /* client went away */ + } + }; + + try { + let assignmentText: string; + let detectedLanguage: string | undefined = language; + let repaired = false; + + if (imageList.length > 0) { + send({ type: "stage", stage: "vision", message: translate(locale, "homework.loading.reading") }); + const extraction = await extractNotes({ + model: config.visionModel, + repairModel: config.textModel, + auth: auth.auth, + images: imageList, + text, + signal: abort.signal, + onProgress: (message) => send({ type: "stage", stage: "vision", message }), + }); + assignmentText = notesToText(extraction.notes); + detectedLanguage = extraction.notes.language || language; + repaired = extraction.repaired || extraction.languageRepaired; + } else { + assignmentText = text!.trim(); + } + + send({ + type: "stage", + stage: "generation", + message: translate(locale, mode === "make" ? "homework.loading.writing" : "homework.loading.planning"), + }); + + const meta = { + repaired, + language: detectedLanguage || null, + visionModel: imageList.length ? resolveModelName(config.visionModel, auth.auth.directCloud) : null, + textModel: resolveModelName(config.textModel, auth.auth.directCloud), + }; + + if (mode === "make") { + const result = await makeHomework({ + model: config.textModel, + auth: auth.auth, + assignment: assignmentText, + language: detectedLanguage, + signal: abort.signal, + }); + send({ + type: "homework", + homework: { + mode: "make", + title: result.title, + language: result.language || detectedLanguage, + answer: result.answer, + // The assignment as text (the vision transcription when photos + // were used) so the client can store it for “Make it again”. + sourceText: assignmentText, + }, + meta, + }); + } else { + const guide = await buildHomeworkGuide({ + model: config.textModel, + auth: auth.auth, + assignment: assignmentText, + language: detectedLanguage, + signal: abort.signal, + }); + const steps: HomeworkStep[] = guide.steps.map((step) => ({ + ...step, + // xp: 0 keeps the shared activity components from rendering their + // "+XP" badge — the guide deliberately has no XP or streak. + checkpoint: step.checkpoint ? { ...step.checkpoint, id: uid("act"), xp: 0 } : undefined, + })); + send({ + type: "homework", + homework: { + mode: "guide", + title: guide.title, + language: guide.language || detectedLanguage, + steps, + sourceText: assignmentText, + }, + meta, + }); + } + } catch (error) { + const message = friendlyErrorMessage(error, auth.auth.baseUrl, locale); + // Never log the request body: it may contain the student's assignment. + console.error("[api/homework]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); + send({ + type: "error", + error: message, + kind: error instanceof OllamaError ? error.kind : "unknown", + }); + } finally { + clearTimeout(globalTimer); + try { + controller.close(); + } catch { + /* already closed */ + } + } + }, + cancel() { + clearTimeout(globalTimer); + abort.abort(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "application/x-ndjson; charset=utf-8", + "Cache-Control": "no-store, no-transform", + Connection: "keep-alive", + }, + }); +} + +function stripDataUrlPrefix(value: string): string { + const commaIndex = value.indexOf(","); + if (value.startsWith("data:") && commaIndex !== -1) return value.slice(commaIndex + 1); + return value; +} diff --git a/app/globals.css b/app/globals.css index 4c09538..42aa009 100644 --- a/app/globals.css +++ b/app/globals.css @@ -338,7 +338,9 @@ body { --ice-blob-opacity: 0.2; --ice-orb-opacity: 0.42; } - .ice-orb-1 { width: 96px; height: 96px; top: 8%; left: -26px; } + /* Tucked up behind the glass header: at top 8% it sat right behind the page + heading and subtitle. */ + .ice-orb-1 { width: 88px; height: 88px; top: -30px; left: -38px; } .ice-orb-2 { width: 72px; height: 72px; left: -32px; } /* Was sitting right behind the bottom-nav labels. */ .ice-orb-3 { width: 116px; height: 116px; bottom: 15vh; right: -46px; } @@ -698,10 +700,15 @@ body { } /* Settings sheet: contained momentum scrolling; the safe-area padding comes - from the pb-safe utility plus a trailing spacer. */ + from the pb-safe utility plus a trailing spacer. The mask fades the last + 36px of *content* (not a coloured overlay, so it blends with the glass in + both themes) — that is the scroll cue; the trailing spacer keeps real + content out of the faded strip once you reach the bottom. */ .sheet-scroll { overscroll-behavior: contain; -webkit-overflow-scrolling: touch; + -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent); + mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent); } } diff --git a/app/homework/page.tsx b/app/homework/page.tsx new file mode 100644 index 0000000..24be85e --- /dev/null +++ b/app/homework/page.tsx @@ -0,0 +1,28 @@ +import { ApiKeyGate } from "@/components/ApiKeyGate"; +import { AppHeader } from "@/components/AppHeader"; +import { HomeworkScreen } from "@/components/homework/HomeworkScreen"; +import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; +import { TabBar } from "@/components/TabBar"; + +export const metadata = { + title: "Make my homework", +}; + +export default function HomeworkPage() { + return ( + +
+ + + +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 7f4c904..d18df27 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -55,8 +55,12 @@ export const viewport: Viewport = { ], }; -/** Applies the saved theme before paint to avoid a flash. */ -const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;}catch(e){}})();`; +/** + * Applies the saved theme before paint to avoid a flash, and keeps the + * `theme-color` meta in step with the *app* theme (the SSR metas follow the + * OS preference, which is wrong once the user has picked a theme here). + */ +const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',d?'#0e1424':'#f4f7fd');}catch(e){}})();`; /** * Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates) diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index 730ae5d..5e61da5 100644 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -257,23 +257,26 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = -

- {t("settings.keyNote")} -

- -
- - - {t("settings.localTipTitle")} - -

- {t("settings.localTipSet")} OLLAMA_AUTH=local {t("settings.keyNoteAfter")}{" "} - .env.local {t("settings.keyNoteEnd")} ollama signin - {t("settings.keyNoteEnd2")} +

+

+ {t("settings.keyNote")}

-
-
+
+ + + {t("settings.localTipTitle")} + +

+ {t("settings.localTipSet")} OLLAMA_AUTH=local{" "} + {t("settings.keyNoteAfter")} .env.local {t("settings.keyNoteEnd")}{" "} + ollama signin + {t("settings.keyNoteEnd2")} +

+
+
+ +
- - {apiKey ? ( - confirmRemove ? ( -
-

- {t("settings.removeKeyConfirm")} -

-
- - -
-
- ) : ( + {apiKey && !confirmRemove ? ( - ) + ) : null} + + + {apiKey && confirmRemove ? ( +
+

+ {t("settings.removeKeyConfirm")} +

+
+ + +
+
) : null} @@ -371,22 +373,21 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = {health ? : null} - {/* breathing room for the scroll cue + safe area */} -
+ {/* keeps real content out of the 36px fade strip at the bottom */} +
- {/* visible scroll cue */} + {/* scroll cue: a bare chevron inside the fade strip, so it never + covers text (the .sheet-scroll mask fades what is underneath) */}
- - - +
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 6dd5a86..2759c3a 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -12,6 +12,7 @@ import { LayersIcon, MoonIcon, PlusIcon, + SparklesIcon, SunIcon, } from "@/components/icons"; import { useTheme } from "@/components/ThemeToggle"; @@ -146,6 +147,17 @@ function NavContent({ {!collapsed ? {t("common.newDeck")} : null} + {/* make my homework: same quiet row --------------------------------- */} + + + {!collapsed ? {t("homework.nav")} : null} + + {/* folders --------------------------------------------------------- */} {/* Mobile keeps the original layout: one Folders label above the list. */} {!desktop && !collapsed ? ( diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index 69bab3e..815ae33 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -8,6 +8,28 @@ import { MoonIcon, SunIcon } from "@/components/icons"; const STORAGE_KEY = "freezmo.theme"; +const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424" } as const; + +/** + * Keeps the first `theme-color` meta (the one the app owns) in step with the + * in-app theme. The SSR metas follow the OS preference; this one always wins + * because it is media-less and first in the head. + */ +export function syncThemeColor(theme: "light" | "dark") { + try { + let meta = document.querySelector('meta[name="theme-color"][data-app]'); + if (!meta) { + meta = document.createElement("meta"); + meta.setAttribute("name", "theme-color"); + meta.setAttribute("data-app", "1"); + document.head.insertBefore(meta, document.head.firstChild); + } + meta.setAttribute("content", THEME_COLORS[theme]); + } catch { + /* private mode / odd embedded browsers */ + } +} + export function useTheme() { const [theme, setTheme] = useState<"light" | "dark">("light"); @@ -17,11 +39,13 @@ export function useTheme() { ? stored === "dark" : window.matchMedia("(prefers-color-scheme: dark)").matches; setTheme(isDark ? "dark" : "light"); + syncThemeColor(isDark ? "dark" : "light"); }, []); const apply = (next: "light" | "dark") => { setTheme(next); document.documentElement.classList.toggle("dark", next === "dark"); + syncThemeColor(next); try { window.localStorage.setItem(STORAGE_KEY, next); } catch { diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index ed448b6..8cbac2c 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -1,6 +1,7 @@ "use client"; import { motion } from "framer-motion"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; @@ -32,7 +33,7 @@ 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 { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons"; +import { CameraIcon, ChevronRightIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons"; import { Button, ProgressBar } from "@/components/ui"; type Phase = "idle" | "preparing" | "loading" | "error"; @@ -320,6 +321,24 @@ export function UploadScreen() {

+ {/* The other thing you can bring here: an assignment. */} + + + + + + {t("homework.entry")} + {t("homework.nav")} → + + + + {error ? (
) { +export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { const { t } = useI18n(); const [value, setValue] = useState(""); const [chosen, setChosen] = useState(null); @@ -185,6 +185,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti explanation={activity.explanation} xp={activity.xp} isLast={isLast} + nextLabel={nextLabel} onNext={onNext} detail={ diff --git a/components/activities/Flashcard.tsx b/components/activities/Flashcard.tsx index 043775b..c9fc8fe 100644 --- a/components/activities/Flashcard.tsx +++ b/components/activities/Flashcard.tsx @@ -18,7 +18,7 @@ import { type FlashcardActivity = Extract; -export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps) { +export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { const { t } = useI18n(); const [flipped, setFlipped] = useState(false); const [result, setResult] = useState(null); @@ -85,6 +85,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp explanation={activity.explanation} xp={activity.xp} isLast={isLast} + nextLabel={nextLabel} headline={result ? t("flashcard.frozenSolid") : t("flashcard.keepPile")} onNext={onNext} /> diff --git a/components/activities/Matching.tsx b/components/activities/Matching.tsx index 2a6a3e8..b9d1ca1 100644 --- a/components/activities/Matching.tsx +++ b/components/activities/Matching.tsx @@ -16,7 +16,7 @@ import { type MatchingActivity = Extract; -export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps) { +export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { const { t, plural } = useI18n(); const terms = useMemo( () => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))), @@ -137,6 +137,7 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo explanation={activity.explanation} xp={activity.xp} isLast={isLast} + nextLabel={nextLabel} onNext={onNext} headline={misses === 0 ? t("matching.perfect") : t("matching.slips")} detail={ diff --git a/components/activities/MultipleChoice.tsx b/components/activities/MultipleChoice.tsx index 30922e2..671d7e5 100644 --- a/components/activities/MultipleChoice.tsx +++ b/components/activities/MultipleChoice.tsx @@ -23,6 +23,7 @@ export function MultipleChoice({ onAnswered, onNext, isLast, + nextLabel, }: ActivityComponentProps) { const { t } = useI18n(); const [selected, setSelected] = useState(null); @@ -86,6 +87,7 @@ export function MultipleChoice({ explanation={activity.explanation} xp={activity.xp} isLast={isLast} + nextLabel={nextLabel} onNext={onNext} detail={ selected === activity.correctIndex ? null : ( diff --git a/components/activities/Ordering.tsx b/components/activities/Ordering.tsx index 056cabf..e256ff4 100644 --- a/components/activities/Ordering.tsx +++ b/components/activities/Ordering.tsx @@ -18,7 +18,7 @@ import { type OrderingActivity = Extract; -export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps) { +export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { const { t } = useI18n(); const initial = useMemo(() => shuffle(activity.items), [activity]); const [items, setItems] = useState(initial); @@ -126,6 +126,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo explanation={activity.explanation} xp={activity.xp} isLast={isLast} + nextLabel={nextLabel} onNext={onNext} /> )} diff --git a/components/activities/ShortAnswer.tsx b/components/activities/ShortAnswer.tsx index bc2de50..8615908 100644 --- a/components/activities/ShortAnswer.tsx +++ b/components/activities/ShortAnswer.tsx @@ -26,7 +26,7 @@ type GradeState = | { status: "error"; message: string } | { status: "self-check" }; -export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps) { +export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { const { t } = useI18n(); const [value, setValue] = useState(""); const [state, setState] = useState({ status: "idle" }); @@ -182,6 +182,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is explanation={activity.explanation} xp={activity.xp} isLast={isLast} + nextLabel={nextLabel} onNext={onNext} /> diff --git a/components/activities/TrueFalse.tsx b/components/activities/TrueFalse.tsx index c92f1a5..b3b868f 100644 --- a/components/activities/TrueFalse.tsx +++ b/components/activities/TrueFalse.tsx @@ -17,7 +17,7 @@ import { type TrueFalseActivity = Extract; -export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps) { +export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { const { t } = useI18n(); const [result, setResult] = useState(null); const x = useMotionValue(0); @@ -84,6 +84,7 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp explanation={activity.explanation} xp={activity.xp} isLast={isLast} + nextLabel={nextLabel} onNext={onNext} detail={ diff --git a/components/activities/shared.tsx b/components/activities/shared.tsx index fc83e8b..b699994 100644 --- a/components/activities/shared.tsx +++ b/components/activities/shared.tsx @@ -18,6 +18,8 @@ export type ActivityComponentProps = { /** Scroll the feed to the next slide. */ onNext: () => void; isLast: boolean; + /** Overrides the feedback button label (the homework guide uses this). */ + nextLabel?: string; }; export const TYPE_META: Record = { @@ -133,6 +135,7 @@ export function FeedbackPanel({ xp, onNext, isLast, + nextLabel, headline, detail, children, @@ -142,6 +145,8 @@ export function FeedbackPanel({ xp?: number; onNext: () => void; isLast?: boolean; + /** Overrides the default “Next” / “See results” label. */ + nextLabel?: string; headline?: string; detail?: React.ReactNode; children?: React.ReactNode; @@ -193,7 +198,7 @@ export function FeedbackPanel({
diff --git a/components/homework/CopyButton.tsx b/components/homework/CopyButton.tsx new file mode 100644 index 0000000..76b02d9 --- /dev/null +++ b/components/homework/CopyButton.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui"; +import { copyText } from "@/lib/clipboard"; +import { useI18n } from "@/lib/i18n"; +import { cn } from "@/lib/utils"; + +/** Copy-to-clipboard with a short “Copied!” state and a manual fallback note. */ +export function CopyButton({ text, className }: { text: string; className?: string }) { + const { t } = useI18n(); + const [state, setState] = useState<"idle" | "copied" | "failed">("idle"); + + const copy = async () => { + const ok = await copyText(text); + setState(ok ? "copied" : "failed"); + window.setTimeout(() => setState("idle"), 2200); + }; + + return ( + + + {state === "failed" ? ( + + {t("homework.result.copyFailed")} + + ) : null} + + ); +} diff --git a/components/homework/HomeworkGuide.tsx b/components/homework/HomeworkGuide.tsx new file mode 100644 index 0000000..b172fbf --- /dev/null +++ b/components/homework/HomeworkGuide.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { motion } from "framer-motion"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { ActivityRenderer } from "@/components/activities"; +import { PromptCard, QuestionText } from "@/components/activities/shared"; +import { XIcon } from "@/components/icons"; +import { Button, ProgressBar } from "@/components/ui"; +import { assembleSolution } from "@/lib/homework"; +import { useI18n } from "@/lib/i18n"; +import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas"; + +import { CopyButton } from "./CopyButton"; + +/** + * Mode 2: one step per screen in the same snap-feed as the study feed. + * Each step explains what to do, reveals hints one at a time (nudge → hint → + * the full answer), and can carry a checkpoint rendered by the shared activity + * components. The last screen assembles the solution from the step answers. + */ +export function HomeworkGuide({ + record, + onClose, + onAgain, + onNew, + againLoading, +}: { + record: HomeworkGuideRecord; + onClose: () => void; + onAgain: () => void; + onNew: () => void; + againLoading: boolean; +}) { + const { t } = useI18n(); + const steps = record.steps; + const slideCount = steps.length + 1; // + the solution slide + const [index, setIndex] = useState(0); + const [revealed, setRevealed] = useState>({}); + const scrollRef = useRef(null); + + 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], + ); + + // Track the visible slide (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, like the study feed. + 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(); + goTo(index + 1); + } else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") { + event.preventDefault(); + goTo(index - 1); + } else if (event.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [goTo, index, onClose]); + + const solution = useMemo(() => assembleSolution(record), [record]); + const progressValue = Math.min(index + 1, slideCount); + const onSolution = index >= steps.length; + + const revealedCount = (stepIndex: number) => revealed[stepIndex] ?? 0; + + const revealNext = (stepIndex: number, hints: string[]) => + setRevealed((current) => ({ + ...current, + [stepIndex]: Math.min((current[stepIndex] ?? 0) + 1, hints.length), + })); + + const hintButtonLabel = (step: HomeworkStep, stepIndex: number) => { + const count = revealedCount(stepIndex); + if (count === 0) return t("homework.guide.hint"); + if (count === step.hints.length - 1) return t("homework.guide.hintAnswer"); + return t("homework.guide.hintMore"); + }; + + return ( +
+ {/* chrome ------------------------------------------------------- */} +
+
+
+ +
+ +
+
+
+ {record.title} + + {onSolution ? t("homework.guide.solution") : t("homework.guide.stepOf", { n: index + 1, total: steps.length })} + +
+
+
+ + {/* slides ------------------------------------------------------- */} +
+ {steps.map((step, stepIndex) => ( +
+
+
+ + {t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })} + +
+ + + {step.title} +

+ {step.explanation} +

+
+ + {/* progressive hints */} +
+ {step.hints.slice(0, revealedCount(stepIndex)).map((hint, hintIndex) => ( + + {t("homework.guide.hintN", { n: hintIndex + 1 })}: + {hint} + + ))} + {revealedCount(stepIndex) < step.hints.length ? ( + + ) : null} +
+ + {/* checkpoint or a plain next button */} + {step.checkpoint ? ( +
+

+ {t("homework.guide.tryIt")} +

+ { + /* checkpoints are for the student, not for scoring */ + }} + onNext={() => goTo(stepIndex + 1)} + /> +
+ ) : ( + + )} +
+
+ ))} + + {/* the assembled solution -------------------------------------- */} +
+
+
+ + {t("homework.guide.solution")} + +
+ +

+ {solution} +

+
+

{t("homework.guide.solutionNote")}

+
+ + + +
+
+
+
+
+ ); +} diff --git a/components/homework/HomeworkHistory.tsx b/components/homework/HomeworkHistory.tsx new file mode 100644 index 0000000..c9dfcc4 --- /dev/null +++ b/components/homework/HomeworkHistory.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; + +import { ChevronRightIcon, XIcon } from "@/components/icons"; +import { Button } from "@/components/ui"; +import { homeworkModeLabelKey } from "@/lib/homework"; +import { useI18n } from "@/lib/i18n"; +import type { HomeworkRecord } from "@/lib/schemas"; + +/** Small history list under the input: open a past result or delete it. */ +export function HomeworkHistory({ + records, + onOpen, + onDelete, +}: { + records: HomeworkRecord[]; + onOpen: (record: HomeworkRecord) => void; + onDelete: (id: string) => void; +}) { + const { locale, t } = useI18n(); + const [confirmId, setConfirmId] = useState(null); + + if (records.length === 0) return null; + + return ( +
+

+ {t("homework.history.title")} +

+
    + {records.map((record) => + confirmId === record.id ? ( +
  • + + {t("homework.history.deleteConfirm")} + + + + + +
  • + ) : ( +
  • + + +
  • + ), + )} +
+
+ ); +} diff --git a/components/homework/HomeworkInput.tsx b/components/homework/HomeworkInput.tsx new file mode 100644 index 0000000..4afb90a --- /dev/null +++ b/components/homework/HomeworkInput.tsx @@ -0,0 +1,339 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons"; +import { Button } from "@/components/ui"; +import { + DOCUMENT_ACCEPT_ATTR, + documentsToText, + type ExtractedDocument, +} from "@/lib/documents"; +import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas"; +import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image"; +import { useI18n } from "@/lib/i18n"; +import type { useNotesIntake } from "@/lib/use-notes-intake"; +import { openSettings } from "@/lib/ui-events"; +import { cn } from "@/lib/utils"; + +import { HomeworkHistory } from "./HomeworkHistory"; + +/** Same wording the create screen uses for an extracted document. */ +function describeExtraction( + document: ExtractedDocument, + tr: (key: string, vars?: Record) => string, + pl: (n: number, base: string, vars?: Record) => string, +): string { + const parts = [ + pl(document.words, "upload.words", { n: document.words.toLocaleString() }), + document.pages ? pl(document.pages, "upload.pages") : "", + document.images.length ? pl(document.images.length, "upload.pageImages") : "", + document.truncated ? tr("upload.truncated") : "", + ]; + return parts.filter(Boolean).join(" · "); +} + +const MODES: Array<{ id: HomeworkMode; icon: React.ReactNode; titleKey: string; subtitleKey: string }> = [ + { id: "make", icon: , titleKey: "homework.mode.make.title", subtitleKey: "homework.mode.make.subtitle" }, + { id: "guide", icon: , titleKey: "homework.mode.guide.title", subtitleKey: "homework.mode.guide.subtitle" }, +]; + +export function HomeworkInput({ + mode, + onModeChange, + text, + onTextChange, + intake, + onSubmit, + error, + history, + onOpen, + onDelete, +}: { + mode: HomeworkMode; + onModeChange: (mode: HomeworkMode) => void; + text: string; + onTextChange: (text: string) => void; + intake: ReturnType; + onSubmit: () => void; + error: { message: string; kind?: string } | null; + history: HomeworkRecord[]; + onOpen: (record: HomeworkRecord) => void; + onDelete: (id: string) => void; +}) { + const { t, plural } = useI18n(); + const fileInputRef = useRef(null); + const cameraInputRef = useRef(null); + + // Clipboard: screenshots become photos, long copied text becomes the assignment. + useEffect(() => { + const onPaste = (event: ClipboardEvent) => { + 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 intake.addFiles(files); + return; + } + if (!isTextField) { + const pasted = event.clipboardData?.getData("text/plain")?.trim(); + if (pasted && pasted.length > 40) { + event.preventDefault(); + onTextChange(text ? `${text}\n${pasted}` : pasted); + } + } + }; + window.addEventListener("paste", onPaste); + return () => window.removeEventListener("paste", onPaste); + }, [intake, onTextChange, text]); + + const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted); + const documentsBusy = intake.documents.some((entry) => entry.status === "extracting"); + const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images); + const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!)); + const totalBytes = intake.images.reduce((sum, image) => sum + image.bytes, 0); + + const hasContent = + intake.images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0; + const canSubmit = hasContent && !intake.busy && !documentsBusy; + const keyError = error?.kind === "needs_key" || error?.kind === "auth"; + + return ( +
+
+

+ + {t("homework.title")} +

+

{t("homework.subtitle")}

+
+ + {error ? ( +
+

{keyError ? t("upload.errAuth") : t("homework.error.title")}

+

{error.message || t("homework.error.body")}

+
+ {keyError ? ( + + ) : null} + +
+
+ ) : null} + + {/* ------------------------------------------------ the assignment */} +
+
+
+

+ {t("homework.source.title")} +

+

{t("homework.source.help")}

+
+
+ + +
+
+ + { + void intake.addFiles(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + /> + { + void intake.addFiles(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + /> + + {intake.images.length === 0 && intake.documents.length === 0 ? ( + + ) : ( +
+ {intake.images.length > 0 ? ( +
+ {intake.images.map((image) => ( + + {/* eslint-disable-next-line @next/next/no-img-element -- local thumbnail, no optimisation needed */} + + + + ))} +
+ ) : null} + + {intake.documents.length > 0 ? ( +
    + {intake.documents.map((entry) => ( +
  • + + {entry.name.split(".").pop()?.slice(0, 4) ?? "doc"} + + + {entry.name} + + {entry.status === "extracting" + ? (entry.progress ?? t("upload.reading")) + : entry.status === "error" + ? entry.error + : entry.extracted + ? describeExtraction(entry.extracted, t, plural) + : ""} + + + +
  • + ))} +
+ ) : null} +
+ )} + + +