diff --git a/README.md b/README.md index c6fa488..d80e47b 100644 --- a/README.md +++ b/README.md @@ -454,31 +454,10 @@ 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 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. +client IP is taken from `x-forwarded-for` / `x-real-ip` when present (reverse proxy), otherwise all +local clients 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 @@ -679,19 +658,6 @@ 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 @@ -704,10 +670,8 @@ 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 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. +- 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. - 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 279e4f4..4b93cfa 100644 --- a/app/api/chat/extract/route.ts +++ b/app/api/chat/extract/route.ts @@ -5,8 +5,6 @@ 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"; @@ -18,7 +16,7 @@ const MAX_BODY_BYTES = 33554432; const MAX_IMAGE_CHARS = 8_000_000; const requestSchema = z.object({ - images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), }); @@ -68,8 +66,6 @@ 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 { @@ -101,7 +97,6 @@ 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 ae86e43..9e03022 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -4,7 +4,6 @@ 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"; @@ -132,11 +131,6 @@ 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); @@ -176,7 +170,6 @@ export async function POST(request: Request) { } } finally { clearTimeout(globalTimer); - gate.gate.release(); try { controller.close(); } catch { @@ -186,7 +179,6 @@ 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 82c3951..7c5e9e3 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -5,9 +5,8 @@ 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 { dataUrlSchema, difficultySchema } from "@/lib/schemas"; +import { difficultySchema } from "@/lib/schemas"; import { uid } from "@/lib/utils"; import { xpForActivity } from "@/lib/xp"; @@ -19,7 +18,7 @@ export const maxDuration = 300; const MAX_IMAGE_CHARS = 8_000_000; const requestSchema = z.object({ - images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(z.string().min(32).max(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(), @@ -102,11 +101,6 @@ 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(); @@ -207,7 +201,6 @@ export async function POST(request: Request) { }); } finally { clearTimeout(globalTimer); - gate.gate.release(); try { controller.close(); } catch { @@ -217,7 +210,6 @@ 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 8590364..a04c46c 100644 --- a/app/api/grade/route.ts +++ b/app/api/grade/route.ts @@ -5,7 +5,6 @@ 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"; @@ -55,8 +54,6 @@ 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, @@ -75,7 +72,5 @@ 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 0435e98..8fc04ad 100644 --- a/app/api/homework/refine/route.ts +++ b/app/api/homework/refine/route.ts @@ -6,7 +6,6 @@ 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"; @@ -65,8 +64,6 @@ 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, @@ -89,7 +86,5 @@ 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 761a603..1a0a535 100644 --- a/app/api/homework/route.ts +++ b/app/api/homework/route.ts @@ -5,9 +5,8 @@ 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 { dataUrlSchema, homeworkModeSchema, type HomeworkStep } from "@/lib/schemas"; +import { homeworkModeSchema, type HomeworkStep } from "@/lib/schemas"; import { uid } from "@/lib/utils"; export const runtime = "nodejs"; @@ -21,12 +20,12 @@ const MAX_BODY_BYTES = 33554432; const requestSchema = z.object({ mode: homeworkModeSchema, - images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(z.string().min(32).max(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(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), + solutionImages: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), /** Language of the assignment (usually detected by the vision step). */ language: z.string().max(60).optional(), }); @@ -96,11 +95,6 @@ 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). @@ -263,7 +257,6 @@ export async function POST(request: Request) { }); } finally { clearTimeout(globalTimer); - gate.gate.release(); try { controller.close(); } catch { @@ -273,7 +266,6 @@ 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 d8c8451..e7b8c78 100644 --- a/app/api/push/route.ts +++ b/app/api/push/route.ts @@ -16,23 +16,6 @@ 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({ @@ -113,21 +96,9 @@ 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, 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" } }); + const count = await addSubscription(config.push.dir, parsed.data); + return Response.json({ ok: true, 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 bb92e7e..263d246 100644 --- a/app/api/reports/route.ts +++ b/app/api/reports/route.ts @@ -51,16 +51,7 @@ 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 }); - 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"); + await fs.appendFile(path.join(config.reports.dir, "reports.jsonl"), `${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 5927fad..bb6eac8 100644 --- a/app/deck/[id]/page.tsx +++ b/app/deck/[id]/page.tsx @@ -72,10 +72,7 @@ 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 c5d4249..4357eb8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -647,28 +647,6 @@ 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 26d61e4..205c105 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: "New homework", short_name: "Homework", url: "/homework" }, + { name: "Make my 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 1bf771d..bc33178 100644 --- a/components/DataSection.tsx +++ b/components/DataSection.tsx @@ -78,8 +78,6 @@ export function DataSection() { void pick(event.target.files?.[0])} diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx index 3a8621a..e42fce4 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 c085181..43019e7 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 0043eaf..ebe20ef 100644 --- a/components/LearningSection.tsx +++ b/components/LearningSection.tsx @@ -25,12 +25,7 @@ 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. */ -/** - * `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 } = {}) { +export function LearningSection() { const { t } = useI18n(); const dailyGoal = useSyncExternalStore( settingsStore.subscribe, @@ -159,11 +154,7 @@ export function LearningSection({ level = 2 }: { level?: 2 | 3 } = {}) { return (

- {level === 3 ? ( -

{t("settings.learning")}

- ) : ( -

{t("settings.learning")}

- )} +

{t("settings.learning")}

{t("settings.dailyGoal")}

diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index 4e1bba6..ac7cc77 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -220,8 +220,6 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | { diff --git a/components/ReportQuestionButton.tsx b/components/ReportQuestionButton.tsx index a2cef9c..b26686a 100644 --- a/components/ReportQuestionButton.tsx +++ b/components/ReportQuestionButton.tsx @@ -3,31 +3,22 @@ import { haptic } from "@/lib/haptics"; import { useI18n } from "@/lib/i18n"; import { reportQuestion, unreportQuestion } from "@/lib/question-reports"; -import type { Activity } from "@/lib/schemas"; +import type { Activity, Deck } 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 — 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. + * 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. */ export function ReportQuestionButton({ - deckId, - deckTitle, + deck, activity, onSkip, - labelKey = "feed.report", }: { - deckId: string; - deckTitle?: string; + deck: Deck; activity: Activity; - onSkip?: () => void; - /** i18n key for the accessible name ("Report question" / "Report exercise"). */ - labelKey?: string; + onSkip: () => void; }) { const { t } = useI18n(); @@ -36,16 +27,16 @@ export function ReportQuestionButton({ type="button" onClick={() => { haptic("tap"); - reportQuestion({ deckId, deckTitle, activity }); + reportQuestion({ deck, activity }); showToast({ message: t("feed.reported"), actionLabel: t("toast.undo"), - onAction: () => unreportQuestion(deckId, activity.id), + onAction: () => unreportQuestion(deck.id, activity.id), }); - onSkip?.(); + onSkip(); }} - aria-label={t(labelKey)} - title={t(labelKey)} + aria-label={t("feed.report")} + title={t("feed.report")} 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 dfb4598..aabd2e6 100644 --- a/components/ReviewClient.tsx +++ b/components/ReviewClient.tsx @@ -81,39 +81,36 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?: const sessionItems: SessionItem[] = session.map(({ deck, activity }) => ({ deck, activity })); return ( - // 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: "" })} -

-
- -
+ 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 40dc1f5..78b5cf8 100644 --- a/components/SessionFeed.tsx +++ b/components/SessionFeed.tsx @@ -221,8 +221,7 @@ export function SessionFeed({
{ setSkipped((current) => [...current, item.activity.id]); diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index 8db0245..3096328 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 bba0f29..0950082 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -137,24 +137,23 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) { {!collapsed ? {t("sidebar.collapse")} : null} - {/* new deck: a button, not a nav row — it stays visible when the - library is already full (see .nav-action in globals.css) --------- */} + {/* new deck: a quiet row, not a competing primary button ---------- */} - + {!collapsed ? {t("common.newDeck")} : null} - {/* new homework: the same button ------------------------------------ */} + {/* make my homework: same quiet row --------------------------------- */} - + {!collapsed ? {t("homework.nav")} : null} diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx index b9d8a6d..b86aa45 100644 --- a/components/StudyFeed.tsx +++ b/components/StudyFeed.tsx @@ -332,8 +332,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
{ setSkipped((current) => [...current, activity.id]); diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 7dca7bb..4558cae 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -650,8 +650,6 @@ export function UploadScreen() { { diff --git a/components/homework/HomeworkGuide.tsx b/components/homework/HomeworkGuide.tsx index 0ad75ef..a7af976 100644 --- a/components/homework/HomeworkGuide.tsx +++ b/components/homework/HomeworkGuide.tsx @@ -6,7 +6,6 @@ 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"; @@ -247,7 +246,7 @@ export function HomeworkGuide({ {/* checkpoint or a plain next button */} {step.checkpoint ? ( -
+

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

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