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