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 }) => (
-
{
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). */}
+