diff --git a/README.md b/README.md index d80e47b..c6fa488 100644 --- a/README.md +++ b/README.md @@ -454,10 +454,31 @@ client IP with a sliding window. Everything is configurable in `.env.local`: | `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` | | window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` | +On top of the per-route windows there is one **account-level guard**, so no flood — from one client +or from many — can get the Ollama key throttled or banned: + +| Guard | Default | Env var | +| --- | --- | --- | +| all model calls together (generate, homework, refine, chat, extract, grade) | 60 per window | `RATE_LIMIT_MODEL_TOTAL` | +| model calls in flight per client IP | 3 | `RATE_LIMIT_CONCURRENT` | +| model calls in flight across all clients | 6 | `RATE_LIMIT_CONCURRENT_TOTAL` | + +A request over the concurrency cap gets a **429 `kind: "busy"`** (“Freezmo is already working on +something — try again in a moment”), not a punishment: the slot frees as soon as the model call +finishes, and the client's Retry button works. Slots are taken *after* every cheap rejection, so a +malformed request can never hold one. + +Two more disk guards: `REPORTS_MAX_BYTES` (default 8 MB) stops appending to `reports.jsonl` once it +is full, and `PUSH_MAX_SUBSCRIPTIONS` (default 50) refuses new reminder devices over the cap. +Reminder endpoints must also be a real push service — `fcm.googleapis.com`, +`updates.push.services.mozilla.com`, `*.push.apple.com`, `*.notify.windows.com` — or a host you add +with `PUSH_ALLOWED_HOSTS`, because the server POSTs to whatever is stored. + When a limit is hit the route answers **429** with a `Retry-After` header and a friendly message (“Too many requests — try again in 42s”); the UI shows that message and keeps the Retry button. The -client IP is taken from `x-forwarded-for` / `x-real-ip` when present (reverse proxy), otherwise all -local clients share one bucket. +client IP is taken from the **last** `x-forwarded-for` hop (the one your reverse proxy appended) or +from `x-real-ip`; taking the first hop would let a client spoof its own identity and dodge every +per-IP limit. Requests without those headers share one bucket. Caveats, stated honestly: counters live **in memory**, so they reset on restart and are not shared between multiple instances or Node workers. That is fine for a self-hosted single-instance app; if @@ -658,6 +679,19 @@ self-hosted study app with no accounts, no database and no server-side state. parsing, on top of the zod limits on image count, text length and field sizes. - **Rate limiting** per client IP; see [Rate limits](#rate-limits). Auth is checked *before* parsing request bodies, so unauthenticated traffic costs almost nothing. +- **An account-level model budget and concurrency caps** sit in front of every route that spends an + Ollama call (`lib/model-guard.ts`): 60 calls per window across all clients, 3 in flight per IP, 6 + in flight in total. A flood cannot pile up parallel calls or burn the account — the thing that + gets a key throttled or banned. Slots are released when the call finishes, including a client that + disconnects mid-stream. +- **Images must be base64 data URLs** (`dataUrlSchema`). A plain URL in an `images` field would make + Ollama fetch an arbitrary address on our behalf, so the shape is part of the contract. +- **Reminder endpoints must be real push services** (an allowlist, `PUSH_ALLOWED_HOSTS`): the server + POSTs to whatever is stored, so an arbitrary URL there would be an SSRF primitive. +- **Security headers** on every response: `X-Frame-Options: DENY`, `Content-Security-Policy: + frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, a referrer policy and a + Permissions-Policy that turns off camera/microphone/geolocation/payment (the camera button is a + file input with `capture`, which is not gated by that policy). - **No CSRF surface**: no cookies, no sessions, no server-side mutations. - **Prompt injection is low impact by design.** Your notes could contain text that tries to manipulate the model, but the model can only return a JSON deck (validated per activity, dropped if @@ -670,8 +704,10 @@ self-hosted study app with no accounts, no database and no server-side state. notes. - Anything stored in `localStorage` is readable by JavaScript on the same origin. Freezmo has no XSS vector today, but that is the standard risk of browser-stored credentials. -- Rate limiting trusts `x-forwarded-for` when present (correct behind a proxy, spoofable when - exposed directly) and keeps counters in memory: single instance only, reset on restart. +- Rate limiting keeps counters in memory: single instance only, reset on restart. The client IP is + read from the **last** `x-forwarded-for` hop, so a client cannot spoof its identity — but behind + more than one proxy the "last hop" is the proxy closest to Freezmo, which means those clients + share one bucket. Point `x-real-ip` at the real client if you need per-client buckets there. - Ollama cloud usage is billed to whoever's key is used, so treat a shared Freezmo URL as shared key access in `server` mode. - No accounts means no per-user isolation: anyone who can reach the URL can use the app (and, in diff --git a/app/api/chat/extract/route.ts b/app/api/chat/extract/route.ts index 4b93cfa..279e4f4 100644 --- a/app/api/chat/extract/route.ts +++ b/app/api/chat/extract/route.ts @@ -5,6 +5,8 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; +import { dataUrlSchema } from "@/lib/schemas"; import { translate } from "@/lib/i18n-dict"; export const runtime = "nodejs"; @@ -16,7 +18,7 @@ const MAX_BODY_BYTES = 33554432; const MAX_IMAGE_CHARS = 8_000_000; const requestSchema = z.object({ - images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), }); @@ -66,6 +68,8 @@ export async function POST(request: Request) { return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 }); } + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), config.timeoutMs); try { @@ -97,6 +101,7 @@ export async function POST(request: Request) { ); } finally { clearTimeout(timer); + gate.gate.release(); } } diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 9e03022..ae86e43 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -4,6 +4,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; import { buildChatSystemPrompt } from "@/lib/prompts"; import { estimateTokens, trimHistory } from "@/lib/chat-trim"; @@ -131,6 +132,11 @@ export async function POST(request: Request) { })), }); + // Concurrency + the account-wide model budget (lib/model-guard), taken + // after every cheap rejection so a 400 can never leak a slot. + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; + const encode = encoder(); const abort = new AbortController(); const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs); @@ -170,6 +176,7 @@ export async function POST(request: Request) { } } finally { clearTimeout(globalTimer); + gate.gate.release(); try { controller.close(); } catch { @@ -179,6 +186,7 @@ export async function POST(request: Request) { }, cancel() { clearTimeout(globalTimer); + gate.gate.release(); abort.abort(); }, }); diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index 7c5e9e3..82c3951 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; -import { difficultySchema } from "@/lib/schemas"; +import { dataUrlSchema, difficultySchema } from "@/lib/schemas"; import { uid } from "@/lib/utils"; import { xpForActivity } from "@/lib/xp"; @@ -18,7 +19,7 @@ export const maxDuration = 300; const MAX_IMAGE_CHARS = 8_000_000; const requestSchema = z.object({ - images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), difficulty: difficultySchema.default("medium"), count: z.number().int().min(4).max(18).optional(), @@ -101,6 +102,11 @@ export async function POST(request: Request) { ); } + // Concurrency + the account-wide model budget (lib/model-guard). Taken + // only now, after every cheap rejection, so a 400 can never leak a slot. + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; + const requestedCount = count ?? config.defaultActivityCount; const encode = encoder(); const abort = new AbortController(); @@ -201,6 +207,7 @@ export async function POST(request: Request) { }); } finally { clearTimeout(globalTimer); + gate.gate.release(); try { controller.close(); } catch { @@ -210,6 +217,7 @@ export async function POST(request: Request) { }, cancel() { clearTimeout(globalTimer); + gate.gate.release(); abort.abort(); }, }); diff --git a/app/api/grade/route.ts b/app/api/grade/route.ts index a04c46c..8590364 100644 --- a/app/api/grade/route.ts +++ b/app/api/grade/route.ts @@ -5,6 +5,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; export const runtime = "nodejs"; @@ -54,6 +55,8 @@ export async function POST(request: Request) { return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 }); } + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; try { const result = await gradeShortAnswer({ model: config.textModel, @@ -72,5 +75,7 @@ export async function POST(request: Request) { }, { status: 502 }, ); + } finally { + gate.gate.release(); } } diff --git a/app/api/homework/refine/route.ts b/app/api/homework/refine/route.ts index 8fc04ad..0435e98 100644 --- a/app/api/homework/refine/route.ts +++ b/app/api/homework/refine/route.ts @@ -6,6 +6,7 @@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { translate } from "@/lib/i18n-dict"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -64,6 +65,8 @@ export async function POST(request: Request) { ); } + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; try { const text = await refineHomeworkText({ model: config.textModel, @@ -86,5 +89,7 @@ export async function POST(request: Request) { }, { status: 502 }, ); + } finally { + gate.gate.release(); } } diff --git a/app/api/homework/route.ts b/app/api/homework/route.ts index 1a0a535..761a603 100644 --- a/app/api/homework/route.ts +++ b/app/api/homework/route.ts @@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; -import { homeworkModeSchema, type HomeworkStep } from "@/lib/schemas"; +import { dataUrlSchema, homeworkModeSchema, type HomeworkStep } from "@/lib/schemas"; import { uid } from "@/lib/utils"; export const runtime = "nodejs"; @@ -20,12 +21,12 @@ 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(), + images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), /** Mode "check": the student's own attempt at the assignment. */ solution: z.string().max(120_000).optional(), /** Mode "check": photos of the attempt (handwritten work, scanned pages). */ - solutionImages: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + solutionImages: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), /** Language of the assignment (usually detected by the vision step). */ language: z.string().max(60).optional(), }); @@ -95,6 +96,11 @@ export async function POST(request: Request) { return Response.json({ error: translate(locale, "server.needSolution") }, { status: 400 }); } + // Concurrency + the account-wide model budget (lib/model-guard), taken + // after every cheap rejection so a 400 can never leak a slot. + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; + const encode = encoder(); const abort = new AbortController(); // A single hard deadline for the whole pipeline (two sequential model calls). @@ -257,6 +263,7 @@ export async function POST(request: Request) { }); } finally { clearTimeout(globalTimer); + gate.gate.release(); try { controller.close(); } catch { @@ -266,6 +273,7 @@ export async function POST(request: Request) { }, cancel() { clearTimeout(globalTimer); + gate.gate.release(); abort.abort(); }, }); diff --git a/app/api/push/route.ts b/app/api/push/route.ts index e7b8c78..d8c8451 100644 --- a/app/api/push/route.ts +++ b/app/api/push/route.ts @@ -16,6 +16,23 @@ export const dynamic = "force-dynamic"; * is false — no half-configured UI ever shows. */ +/** + * The server POSTs to the stored endpoint with VAPID headers, so it must be a + * real push service: an arbitrary URL here would be an SSRF primitive. The + * allowed hosts come from the config (PUSH_ALLOWED_HOSTS), defaulting to the + * real services. + */ +function isPushEndpoint(value: string, allowedHosts: string[]): boolean { + try { + const url = new URL(value); + if (url.protocol !== "https:") return false; + const host = url.hostname.toLowerCase(); + return allowedHosts.some((entry) => (entry.startsWith(".") ? host.endsWith(entry) : host === entry)); + } catch { + return false; + } +} + const subscriptionSchema = z.object({ endpoint: z.string().url().max(2000), keys: z.object({ @@ -96,9 +113,21 @@ export async function POST(request: Request) { { status: 400 }, ); } + if (!isPushEndpoint(parsed.data.endpoint, config.push.allowedHosts)) { + return Response.json( + { error: translate(locale, "server.pushEndpoint"), kind: "bad_endpoint" }, + { status: 400 }, + ); + } - const count = await addSubscription(config.push.dir, parsed.data); - return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } }); + const count = await addSubscription(config.push.dir, parsed.data, config.push.maxSubscriptions); + if (!count.accepted) { + return Response.json( + { error: translate(locale, "server.pushTooMany", { max: config.push.maxSubscriptions }), kind: "too_many" }, + { status: 429 }, + ); + } + return Response.json({ ok: true, count: count.count }, { headers: { "Cache-Control": "no-store" } }); } export async function DELETE(request: Request) { diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts index 263d246..bb92e7e 100644 --- a/app/api/reports/route.ts +++ b/app/api/reports/route.ts @@ -51,7 +51,16 @@ export async function POST(request: Request) { const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() }); try { await fs.mkdir(config.reports.dir, { recursive: true }); - await fs.appendFile(path.join(config.reports.dir, "reports.jsonl"), `${line}\n`, "utf8"); + const file = path.join(config.reports.dir, "reports.jsonl"); + /* Disk guard: reports are a convenience for the operator, so once the + file passes the cap the app stops appending (the browser's own list is + still the source of truth for skipping). */ + const size = await fs.stat(file).then((stats) => stats.size).catch(() => 0); + if (size >= config.reports.maxBytes) { + console.warn("[api/reports] reports.jsonl is at its size cap — not appending"); + return Response.json({ ok: true }); + } + await fs.appendFile(file, `${line}\n`, "utf8"); } catch { return Response.json({ error: "could not store the report" }, { status: 500 }); } diff --git a/app/deck/[id]/page.tsx b/app/deck/[id]/page.tsx index bb6eac8..5927fad 100644 --- a/app/deck/[id]/page.tsx +++ b/app/deck/[id]/page.tsx @@ -72,7 +72,10 @@ export default function DeckPage() { return ( - + {/* The feed is a fixed overlay, but its content still needs a landmark. */} +
+ +
); } diff --git a/app/globals.css b/app/globals.css index 4357eb8..c5d4249 100644 --- a/app/globals.css +++ b/app/globals.css @@ -647,6 +647,28 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) { background: var(--hover); color: var(--text); } + +/* The rail's two creation actions ("New deck", "New homework") are buttons, + not nav rows: once the library is full the empty-state CTAs are long gone, + and on desktop the rail is the only place left to start something. */ +.nav-action { + display: flex; + align-items: center; + gap: 0.625rem; + width: 100%; + border-radius: 0.75rem; + padding: 0.5rem 0.625rem; + font-size: 13.5px; + font-weight: 600; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--glass-border); + transition: background-color 160ms ease, border-color 160ms ease; +} +.nav-action:hover { + background: var(--hover); + border-color: var(--nav-active-border); +} .nav-row[aria-current="page"], .nav-row[aria-pressed="true"] { background: var(--nav-active-bg); diff --git a/app/manifest.ts b/app/manifest.ts index 205c105..26d61e4 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -55,7 +55,7 @@ export default function manifest(): MetadataRoute.Manifest { ], shortcuts: [ { name: "New deck", short_name: "New", url: "/create" }, - { name: "Make my homework", short_name: "Homework", url: "/homework" }, + { name: "New homework", short_name: "Homework", url: "/homework" }, { name: "Library", short_name: "Library", url: "/" }, ], /* Share text and links from other apps (GET). Files are POSTed to the diff --git a/components/DataSection.tsx b/components/DataSection.tsx index bc33178..1bf771d 100644 --- a/components/DataSection.tsx +++ b/components/DataSection.tsx @@ -78,6 +78,8 @@ export function DataSection() { void pick(event.target.files?.[0])} diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx index e42fce4..3a8621a 100644 --- a/components/DeckCard.tsx +++ b/components/DeckCard.tsx @@ -182,12 +182,12 @@ export function DeckCard({ ) : ( -

{truncate(deck.title, 90)} -

+ )} {/* Phones: one compact meta line. */}

diff --git a/components/HomeworkCard.tsx b/components/HomeworkCard.tsx index 43019e7..c085181 100644 --- a/components/HomeworkCard.tsx +++ b/components/HomeworkCard.tsx @@ -62,12 +62,12 @@ export function HomeworkCard({

-

{truncate(record.title, 90)} -

+

{record.mode === "make" ? : } diff --git a/components/LearningSection.tsx b/components/LearningSection.tsx index ebe20ef..0043eaf 100644 --- a/components/LearningSection.tsx +++ b/components/LearningSection.tsx @@ -25,7 +25,12 @@ function urlBase64ToUint8Array(base64: string): Uint8Array { * the server has VAPID keys configured. Without them the reminder block is * hidden completely, exactly as if the feature did not exist. */ -export function LearningSection() { +/** + * `level` keeps the heading order right in both places it is used: h2 on the + * library page (under its h1) and h3 inside the settings dialog (under its h2 + * title). + */ +export function LearningSection({ level = 2 }: { level?: 2 | 3 } = {}) { const { t } = useI18n(); const dailyGoal = useSyncExternalStore( settingsStore.subscribe, @@ -154,7 +159,11 @@ export function LearningSection() { return (

-

{t("settings.learning")}

+ {level === 3 ? ( +

{t("settings.learning")}

+ ) : ( +

{t("settings.learning")}

+ )}

{t("settings.dailyGoal")}

diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index ac7cc77..4e1bba6 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -220,6 +220,8 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | { diff --git a/components/ReportQuestionButton.tsx b/components/ReportQuestionButton.tsx index b26686a..a2cef9c 100644 --- a/components/ReportQuestionButton.tsx +++ b/components/ReportQuestionButton.tsx @@ -3,22 +3,31 @@ import { haptic } from "@/lib/haptics"; import { useI18n } from "@/lib/i18n"; import { reportQuestion, unreportQuestion } from "@/lib/question-reports"; -import type { Activity, Deck } from "@/lib/schemas"; +import type { Activity } from "@/lib/schemas"; import { showToast } from "@/lib/toast"; /** * The red "!" report control that sits under the card (bottom-right of the - * exercise): records the question, sends the operator a copy and skips past - * it. Undo lives in the toast. 44px, because it is a thumb target. + * exercise): records the question, sends the operator a copy and — in the + * study feeds — skips past it. Undo lives in the toast. 44px, because it is a + * thumb target. + * + * `deckId` is a deck id in the feeds and the homework record's id for a + * homework exercise; `onSkip` is omitted where there is no queue to skip. */ export function ReportQuestionButton({ - deck, + deckId, + deckTitle, activity, onSkip, + labelKey = "feed.report", }: { - deck: Deck; + deckId: string; + deckTitle?: string; activity: Activity; - onSkip: () => void; + onSkip?: () => void; + /** i18n key for the accessible name ("Report question" / "Report exercise"). */ + labelKey?: string; }) { const { t } = useI18n(); @@ -27,16 +36,16 @@ export function ReportQuestionButton({ type="button" onClick={() => { haptic("tap"); - reportQuestion({ deck, activity }); + reportQuestion({ deckId, deckTitle, activity }); showToast({ message: t("feed.reported"), actionLabel: t("toast.undo"), - onAction: () => unreportQuestion(deck.id, activity.id), + onAction: () => unreportQuestion(deckId, activity.id), }); - onSkip(); + onSkip?.(); }} - aria-label={t("feed.report")} - title={t("feed.report")} + aria-label={t(labelKey)} + title={t(labelKey)} className="grid size-11 shrink-0 place-items-center rounded-xl border-2 border-rose-400/80 text-base font-black leading-none text-rose-600 transition hover:bg-rose-50 dark:border-rose-400/50 dark:text-rose-300 dark:hover:bg-rose-400/10" > ! diff --git a/components/ReviewClient.tsx b/components/ReviewClient.tsx index aabd2e6..dfb4598 100644 --- a/components/ReviewClient.tsx +++ b/components/ReviewClient.tsx @@ -81,36 +81,39 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?: const sessionItems: SessionItem[] = session.map(({ deck, activity }) => ({ deck, activity })); return ( - router.push("/")} - onAnswered={(item, correct) => { - haptic(correct ? "success" : "error"); - recordAnswer({ - deckId: item.deck.id, - deckTitle: item.deck.title, - activityId: item.activity.id, - type: item.activity.type, - topic: item.activity.topic, - correct, - }); - }} - renderResults={({ correct, total, score }) => ( -
-

{title}

-

{score}%

-

- {t("results.gotOf", { correct, total, retry: "" })} -

-
- + // The feed is a fixed overlay, but its content still needs a landmark. +
+ router.push("/")} + onAnswered={(item, correct) => { + haptic(correct ? "success" : "error"); + recordAnswer({ + deckId: item.deck.id, + deckTitle: item.deck.title, + activityId: item.activity.id, + type: item.activity.type, + topic: item.activity.topic, + correct, + }); + }} + renderResults={({ correct, total, score }) => ( +
+

{title}

+

{score}%

+

+ {t("results.gotOf", { correct, total, retry: "" })} +

+
+ +
-
- )} - /> + )} + /> + ); } diff --git a/components/SessionFeed.tsx b/components/SessionFeed.tsx index 78b5cf8..40dc1f5 100644 --- a/components/SessionFeed.tsx +++ b/components/SessionFeed.tsx @@ -221,7 +221,8 @@ export function SessionFeed({
{ setSkipped((current) => [...current, item.activity.id]); diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index 3096328..8db0245 100644 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -407,7 +407,7 @@ export function SettingsDialog({
{/* ------------------------------------------- data & sync */} - + @@ -746,7 +746,7 @@ export function SettingsDialog({ {/* ------------------------------------------- data & sync */} - + diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 0950082..bba0f29 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -137,23 +137,24 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) { {!collapsed ? {t("sidebar.collapse")} : null} - {/* new deck: a quiet row, not a competing primary button ---------- */} + {/* new deck: a button, not a nav row — it stays visible when the + library is already full (see .nav-action in globals.css) --------- */} - + {!collapsed ? {t("common.newDeck")} : null} - {/* make my homework: same quiet row --------------------------------- */} + {/* new homework: the same button ------------------------------------ */} - + {!collapsed ? {t("homework.nav")} : null} diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx index b86aa45..b9d8a6d 100644 --- a/components/StudyFeed.tsx +++ b/components/StudyFeed.tsx @@ -332,7 +332,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
{ setSkipped((current) => [...current, activity.id]); diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 4558cae..7dca7bb 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -650,6 +650,8 @@ export function UploadScreen() { { diff --git a/components/homework/HomeworkGuide.tsx b/components/homework/HomeworkGuide.tsx index a7af976..0ad75ef 100644 --- a/components/homework/HomeworkGuide.tsx +++ b/components/homework/HomeworkGuide.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityRenderer } from "@/components/activities"; import { PromptCard, QuestionText } from "@/components/activities/shared"; import { AiDisclaimer } from "@/components/AiDisclaimer"; +import { ReportQuestionButton } from "@/components/ReportQuestionButton"; import { XIcon } from "@/components/icons"; import { Button, ProgressBar } from "@/components/ui"; import { AskButton } from "@/components/chat/AskButton"; @@ -246,7 +247,7 @@ export function HomeworkGuide({ {/* checkpoint or a plain next button */} {step.checkpoint ? ( -
+

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

@@ -263,6 +264,16 @@ export function HomeworkGuide({ }} onNext={() => goTo(stepIndex + 1)} /> + {/* Same report control as the study feeds, under the + exercise (the homework record is the "deck" here). */} +
+ +
) : (