Add an English/Czech interface language switch
English stays the default; Czech is a full translation. Only the app chrome is translated - notes and generated decks keep their own language, which is a core feature, not a setting. i18n - lib/i18n-dict.ts: flat en/cs dictionaries (441 keys each), translate() and translatePlural() with real Czech plural rules (1 sada / 2-4 sady / 5+ sad). English is the default and the fallback for a missing key. - lib/i18n.ts: browser store (freezmo.locale.v1), useI18n() hook, and the x-freezmo-locale header. The hook's t/plural are bound to the locale React is rendering with, so the first client render matches the server HTML and then re-renders in the stored language; module-level t is only for event handlers and non-React helpers. - <html lang> follows the choice (inline script + setLocale). UI - Settings -> Language segmented control (English / Cestina), plus an EN/CS button on the key gate for first run. - Every screen translated: sidebar, header, tab bar, library, deck cards, folder bar, create flow, all seven activities, study chrome, results, settings + health panel, API-key gate and help, install sheet, offline and the deck-not-found page. - Plural-aware counts everywhere (decks, folders, activities, runs, photos, documents, pages, words, wrong attempts). Server - API routes and guards (generate, grade, health, http-guard, rate-limit, ollama error mapping) answer in the request's language, so error cards and the health panel never fall back to English mid-sentence. Fixes - A hydration mismatch when a non-default locale was stored silently dropped the .dark class after hydration; render-time t() is now bound to the hook's locale (UploadScreen.describeExtraction, ResultsScreen.answerFor, DeckCard's folder fallback). - Bumped the service worker cache to freezmo-v4 for this deploy. Tests: i18n 27/27 (gate toggle, persistence, html lang, Czech plurals, settings switch) and a rebuilt regression suite 72/72 (English default, library, create, all 7 activity types, results, settings, install sheet, offline, PWA, security, Czech smoke).
This commit is contained in:
parent
4c2834edac
commit
618ad5fb9c
42 changed files with 1892 additions and 509 deletions
17
README.md
17
README.md
|
|
@ -64,7 +64,10 @@ no database, no data leaving your machine except the requests you send to Ollama
|
||||||
restores it before any activities are written — the UI shows *“Restoring the notes' language…”*.
|
restores it before any activities are written — the UI shows *“Restoring the notes' language…”*.
|
||||||
On good transcriptions the guard stays silent and costs nothing.
|
On good transcriptions the guard stays silent and costs nothing.
|
||||||
- The deck stores its language, so short answers are graded and feedback written in the same language.
|
- The deck stores its language, so short answers are graded and feedback written in the same language.
|
||||||
- The Freezmo interface itself stays English (only your content is language-matched).
|
- The **Freezmo interface** has its own language switch: **English (default)** and a full **Czech**
|
||||||
|
translation, picked in **Settings → Language** or from the `EN`/`CS` button on the key gate. The
|
||||||
|
chosen locale is remembered per browser and also decides the language of server messages (API
|
||||||
|
errors, health notes), so nothing falls back to English mid-sentence.
|
||||||
|
|
||||||
**Study feed**
|
**Study feed**
|
||||||
|
|
||||||
|
|
@ -123,7 +126,7 @@ no database, no data leaving your machine except the requests you send to Ollama
|
||||||
- Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour:
|
- Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour:
|
||||||
green/amber/red) → summary → a single **Study** button, with everything else in the `⋯` menu
|
green/amber/red) → summary → a single **Study** button, with everything else in the `⋯` menu
|
||||||
- **Frosted UI**: the sticky header, tab bar, sheets and panels use real `backdrop-filter` blur over
|
- **Frosted UI**: the sticky header, tab bar, sheets and panels use real `backdrop-filter` blur over
|
||||||
a faint viewport-fixed aurora wash (sky/lilac/mint) — depth without noise
|
the fixed ice scene (drifting glacier blobs, glossy orbs, a whisper of grain) — depth without noise
|
||||||
- **Pill language**: primary CTAs are near-black in light mode and white in dark mode; secondaries
|
- **Pill language**: primary CTAs are near-black in light mode and white in dark mode; secondaries
|
||||||
are outlined or ghost. Colour is reserved for meaning — activity tints (sky/lilac/mint/peach/rose),
|
are outlined or ghost. Colour is reserved for meaning — activity tints (sky/lilac/mint/peach/rose),
|
||||||
correct/wrong feedback, key status
|
correct/wrong feedback, key status
|
||||||
|
|
@ -839,6 +842,16 @@ Things I chose while building, with the reasoning:
|
||||||
soft radial gradients with no filter — visually the same, 4-5x cheaper — phones get two blobs
|
soft radial gradients with no filter — visually the same, 4-5x cheaper — phones get two blobs
|
||||||
instead of four and a 16px (instead of 24px) glass blur, and nested glass never blurs. Result:
|
instead of four and a 16px (instead of 24px) glass blur, and nested glass never blurs. Result:
|
||||||
60fps idle and ~17ms median frames while scrolling under 4x throttle.
|
60fps idle and ~17ms median frames while scrolling under 4x throttle.
|
||||||
|
33. **Interface copy lives in two flat dictionaries, not in a framework.** `lib/i18n-dict.ts` holds
|
||||||
|
the `en` and `cs` tables plus `translate()` / `translatePlural()`; `lib/i18n.ts` adds the browser
|
||||||
|
store (`freezmo.locale.v1`), the `useI18n()` hook and the `x-freezmo-locale` header that API
|
||||||
|
routes read to answer in the same language. English is the default and the fallback for any
|
||||||
|
missing key. Czech uses the real plural rules (1 sada / 2–4 sady / 5+ sad). Crucially, the hook's
|
||||||
|
`t` is *bound to the locale React is rendering with* — the first client render matches the
|
||||||
|
server HTML, then re-renders in the stored language; using the module-level `t` during render
|
||||||
|
instead caused a hydration mismatch that silently dropped the `dark` class. Module-level `t` is
|
||||||
|
only for event handlers and non-React helpers (documents, folders, API errors). The notes' own
|
||||||
|
language is untouched by this — decks still come back in the language of your notes.
|
||||||
|
|
||||||
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
|
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
|
||||||
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
|
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai";
|
import { extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai";
|
||||||
import { AUTH_ERROR_MESSAGES, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
|
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
|
||||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||||
import { rejectCrossOrigin, rejectOversizedBody } from "@/lib/http-guard";
|
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||||
|
import { translate } from "@/lib/i18n-dict";
|
||||||
import { difficultySchema } from "@/lib/schemas";
|
import { difficultySchema } from "@/lib/schemas";
|
||||||
import { uid } from "@/lib/utils";
|
import { uid } from "@/lib/utils";
|
||||||
import { xpForActivity } from "@/lib/xp";
|
import { xpForActivity } from "@/lib/xp";
|
||||||
|
|
@ -48,6 +49,7 @@ function encoder() {
|
||||||
const MAX_BODY_BYTES = 33554432;
|
const MAX_BODY_BYTES = 33554432;
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const locale = requestLocale(request);
|
||||||
const crossOrigin = rejectCrossOrigin(request);
|
const crossOrigin = rejectCrossOrigin(request);
|
||||||
if (crossOrigin) return crossOrigin;
|
if (crossOrigin) return crossOrigin;
|
||||||
const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
|
const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
|
||||||
|
|
@ -57,12 +59,12 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
// Cheap requests are rejected before any work happens.
|
// Cheap requests are rejected before any work happens.
|
||||||
const limit = rateLimit(`generate:${clientKey(request)}`, config.rateLimits.generate, config.rateLimits.windowMs);
|
const limit = rateLimit(`generate:${clientKey(request)}`, config.rateLimits.generate, config.rateLimits.windowMs);
|
||||||
if (!limit.allowed) return rateLimitResponse(limit);
|
if (!limit.allowed) return rateLimitResponse(limit, locale);
|
||||||
|
|
||||||
const auth = resolveAuth(config, request);
|
const auth = resolveAuth(config, request);
|
||||||
if ("error" in auth) {
|
if ("error" in auth) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: AUTH_ERROR_MESSAGES[auth.error], kind: authErrorKind(auth.error) },
|
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -71,17 +73,19 @@ export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
payload = await request.json();
|
payload = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
return Response.json({ error: "Request body was not valid JSON." }, { status: 400 });
|
return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = requestSchema.safeParse(payload);
|
const parsed = requestSchema.safeParse(payload);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
error: `Invalid request: ${parsed.error.issues
|
error: translate(locale, "server.invalidRequest", {
|
||||||
.slice(0, 3)
|
detail: parsed.error.issues
|
||||||
.map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
|
.slice(0, 3)
|
||||||
.join("; ")}`,
|
.map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
|
||||||
|
.join("; "),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
|
|
@ -92,7 +96,7 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
if (imageList.length === 0 && !text?.trim()) {
|
if (imageList.length === 0 && !text?.trim()) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Add at least one photo of your notes or paste some text to build a deck." },
|
{ error: translate(locale, "server.needContent") },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +191,7 @@ export async function POST(request: Request) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = friendlyErrorMessage(error, auth.auth.baseUrl);
|
const message = friendlyErrorMessage(error, auth.auth.baseUrl, locale);
|
||||||
// Never log the request body: it may contain the user's notes.
|
// Never log the request body: it may contain the user's notes.
|
||||||
console.error("[api/generate]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
|
console.error("[api/generate]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
|
||||||
send({
|
send({
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { gradeShortAnswer } from "@/lib/ai";
|
import { gradeShortAnswer } from "@/lib/ai";
|
||||||
import { AUTH_ERROR_MESSAGES, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config";
|
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config";
|
||||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||||
import { rejectCrossOrigin, rejectOversizedBody } from "@/lib/http-guard";
|
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||||
|
import { translate } from "@/lib/i18n-dict";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
@ -22,6 +23,7 @@ const requestSchema = z.object({
|
||||||
const MAX_BODY_BYTES = 65536;
|
const MAX_BODY_BYTES = 65536;
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const locale = requestLocale(request);
|
||||||
const crossOrigin = rejectCrossOrigin(request);
|
const crossOrigin = rejectCrossOrigin(request);
|
||||||
if (crossOrigin) return crossOrigin;
|
if (crossOrigin) return crossOrigin;
|
||||||
const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
|
const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
|
||||||
|
|
@ -30,12 +32,12 @@ export async function POST(request: Request) {
|
||||||
const config = getServerConfig();
|
const config = getServerConfig();
|
||||||
|
|
||||||
const limit = rateLimit(`grade:${clientKey(request)}`, config.rateLimits.grade, config.rateLimits.windowMs);
|
const limit = rateLimit(`grade:${clientKey(request)}`, config.rateLimits.grade, config.rateLimits.windowMs);
|
||||||
if (!limit.allowed) return rateLimitResponse(limit);
|
if (!limit.allowed) return rateLimitResponse(limit, locale);
|
||||||
|
|
||||||
const auth = resolveAuth(config, request);
|
const auth = resolveAuth(config, request);
|
||||||
if ("error" in auth) {
|
if ("error" in auth) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: AUTH_ERROR_MESSAGES[auth.error], kind: authErrorKind(auth.error) },
|
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -44,12 +46,12 @@ export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
payload = await request.json();
|
payload = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
return Response.json({ error: "Request body was not valid JSON." }, { status: 400 });
|
return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = requestSchema.safeParse(payload);
|
const parsed = requestSchema.safeParse(payload);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return Response.json({ error: "Invalid grading request." }, { status: 400 });
|
return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -64,7 +66,10 @@ export async function POST(request: Request) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[api/grade]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
|
console.error("[api/grade]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: friendlyErrorMessage(error, auth.auth.baseUrl), kind: error instanceof OllamaError ? error.kind : "unknown" },
|
{
|
||||||
|
error: friendlyErrorMessage(error, auth.auth.baseUrl, locale),
|
||||||
|
kind: error instanceof OllamaError ? error.kind : "unknown",
|
||||||
|
},
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { AUTH_ERROR_MESSAGES, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName, type AuthMode } from "@/lib/config";
|
import { authErrorMessage, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName, type AuthMode } from "@/lib/config";
|
||||||
import { friendlyErrorMessage, ollamaListModels, ollamaVerifyKey, OllamaError } from "@/lib/ollama";
|
import { friendlyErrorMessage, ollamaListModels, ollamaVerifyKey, OllamaError } from "@/lib/ollama";
|
||||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||||
|
import { requestLocale } from "@/lib/http-guard";
|
||||||
|
import { translate } from "@/lib/i18n-dict";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
@ -33,10 +35,11 @@ export type HealthResponse = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
const locale = requestLocale(request);
|
||||||
const config = getServerConfig();
|
const config = getServerConfig();
|
||||||
|
|
||||||
const limit = rateLimit(`health:${clientKey(request)}`, config.rateLimits.health, config.rateLimits.windowMs);
|
const limit = rateLimit(`health:${clientKey(request)}`, config.rateLimits.health, config.rateLimits.windowMs);
|
||||||
if (!limit.allowed) return rateLimitResponse(limit);
|
if (!limit.allowed) return rateLimitResponse(limit, locale);
|
||||||
|
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const resolvedAuth = resolveAuth(config, request);
|
const resolvedAuth = resolveAuth(config, request);
|
||||||
|
|
@ -69,8 +72,8 @@ export async function GET(request: Request) {
|
||||||
if ("error" in resolvedAuth) {
|
if ("error" in resolvedAuth) {
|
||||||
health.error =
|
health.error =
|
||||||
resolvedAuth.error === "missing_key"
|
resolvedAuth.error === "missing_key"
|
||||||
? "No Ollama API key yet. Add your key in the settings dialog — it is stored only in this browser."
|
? translate(locale, "server.healthNoKey")
|
||||||
: AUTH_ERROR_MESSAGES[resolvedAuth.error];
|
: authErrorMessage(locale, resolvedAuth.error);
|
||||||
health.kind = authErrorKind(resolvedAuth.error);
|
health.kind = authErrorKind(resolvedAuth.error);
|
||||||
return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } });
|
return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } });
|
||||||
}
|
}
|
||||||
|
|
@ -87,12 +90,12 @@ export async function GET(request: Request) {
|
||||||
const probe = await ollamaVerifyKey(resolvedAuth.auth, config.textModel, config.healthTimeoutMs * 3);
|
const probe = await ollamaVerifyKey(resolvedAuth.auth, config.textModel, config.healthTimeoutMs * 3);
|
||||||
health.keyAccepted = probe.valid;
|
health.keyAccepted = probe.valid;
|
||||||
if (probe.valid === false) {
|
if (probe.valid === false) {
|
||||||
health.error = "Ollama rejected this API key. Create a new one at ollama.com/settings/keys and paste it again.";
|
health.error = translate(locale, "server.healthKeyRejected");
|
||||||
health.kind = "auth";
|
health.kind = "auth";
|
||||||
return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } });
|
return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } });
|
||||||
}
|
}
|
||||||
if (probe.reason === "no_credits") {
|
if (probe.reason === "no_credits") {
|
||||||
health.error = `The key works, but ${config.textModel} is not included in this account's plan. Add usage credits or switch to a free-tier model in .env.local.`;
|
health.error = translate(locale, "server.healthNoCredits", { model: config.textModel });
|
||||||
health.kind = "no_credits";
|
health.kind = "no_credits";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -106,17 +109,17 @@ export async function GET(request: Request) {
|
||||||
if (!listed) {
|
if (!listed) {
|
||||||
entry.note =
|
entry.note =
|
||||||
health.mode === "local-daemon"
|
health.mode === "local-daemon"
|
||||||
? `Not in the daemon's model list. Run \`ollama pull ${entry.resolved}\` or check your sign-in.`
|
? translate(locale, "server.healthModelNoteLocal", { model: entry.resolved })
|
||||||
: `Not returned by ${config.baseUrl}/api/tags. Check the model name and your key's plan.`;
|
: translate(locale, "server.healthModelNoteCloud", { url: config.baseUrl });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false);
|
health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false);
|
||||||
if (!health.ok && !health.error) {
|
if (!health.ok && !health.error) {
|
||||||
health.error = "Ollama is reachable, but at least one configured model is not available.";
|
health.error = translate(locale, "server.healthModelsMissing");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
health.error = friendlyErrorMessage(error, config.baseUrl);
|
health.error = friendlyErrorMessage(error, config.baseUrl, locale);
|
||||||
health.kind = error instanceof OllamaError ? error.kind : "unknown";
|
health.kind = error instanceof OllamaError ? error.kind : "unknown";
|
||||||
if (error instanceof OllamaError && (error.kind === "auth" || error.kind === "http")) {
|
if (error instanceof OllamaError && (error.kind === "auth" || error.kind === "http")) {
|
||||||
health.keyAccepted = false;
|
health.keyAccepted = false;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
|
||||||
import { ApiKeyGate } from "@/components/ApiKeyGate";
|
import { ApiKeyGate } from "@/components/ApiKeyGate";
|
||||||
import { StudyFeed } from "@/components/StudyFeed";
|
import { StudyFeed } from "@/components/StudyFeed";
|
||||||
import { Button, Spinner } from "@/components/ui";
|
import { Button, Spinner } from "@/components/ui";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Deck } from "@/lib/schemas";
|
import type { Deck } from "@/lib/schemas";
|
||||||
import { getDeck } from "@/lib/storage";
|
import { getDeck } from "@/lib/storage";
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ export default function DeckPage() {
|
||||||
const id = typeof params.id === "string" ? params.id : "";
|
const id = typeof params.id === "string" ? params.id : "";
|
||||||
const [deck, setDeck] = useState<Deck | null>(null);
|
const [deck, setDeck] = useState<Deck | null>(null);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|
@ -29,7 +31,7 @@ export default function DeckPage() {
|
||||||
return (
|
return (
|
||||||
<main className="frost-bg grid min-h-dvh place-items-center">
|
<main className="frost-bg grid min-h-dvh place-items-center">
|
||||||
<div className="relative flex items-center gap-3 text-sm font-semibold text-frost-800 dark:text-frost-100">
|
<div className="relative flex items-center gap-3 text-sm font-semibold text-frost-800 dark:text-frost-100">
|
||||||
<Spinner className="size-5" /> Loading your deck…
|
<Spinner className="size-5" /> {t("deckPage.loading")}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
@ -42,20 +44,19 @@ export default function DeckPage() {
|
||||||
<p className="text-4xl" aria-hidden>
|
<p className="text-4xl" aria-hidden>
|
||||||
🧊
|
🧊
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-xl font-extrabold text-frost-950 dark:text-frost-50">Deck not found</h1>
|
<h1 className="text-xl font-extrabold text-frost-950 dark:text-frost-50">{t("deckPage.notFound")}</h1>
|
||||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||||
This deck isn't in this browser's library. It may have been deleted, or it was
|
{t("deckPage.notFoundBody")}
|
||||||
created in a different browser profile.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-2 pt-1">
|
<div className="flex justify-center gap-2 pt-1">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="inline-flex items-center gap-2 rounded-2xl bg-accent-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-700"
|
className="inline-flex items-center gap-2 rounded-2xl bg-accent-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-700"
|
||||||
>
|
>
|
||||||
Back to library
|
{t("deckPage.back")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/create">
|
<Link href="/create">
|
||||||
<Button variant="secondary">Create a deck</Button>
|
<Button variant="secondary">{t("deckPage.create")}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export const viewport: Viewport = {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Applies the saved theme before paint to avoid a flash. */
|
/** Applies the saved theme before paint to avoid a flash. */
|
||||||
const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}})();`;
|
const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;}catch(e){}})();`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates)
|
* Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { HomeIcon } from "@/components/icons";
|
"use client";
|
||||||
|
|
||||||
export const metadata = { title: "Offline" };
|
import { HomeIcon } from "@/components/icons";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
/** Shown by the service worker when a navigation can't reach the network. */
|
/** Shown by the service worker when a navigation can't reach the network. */
|
||||||
export default function OfflinePage() {
|
export default function OfflinePage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="frost-bg grid min-h-dvh place-items-center px-4">
|
<main className="frost-bg grid min-h-dvh place-items-center px-4">
|
||||||
<div className="card relative max-w-sm space-y-3 p-6 text-center">
|
<div className="card relative max-w-sm space-y-3 p-6 text-center">
|
||||||
<HomeIcon className="mx-auto size-8 text-frost-400" />
|
<HomeIcon className="mx-auto size-8 text-frost-400" />
|
||||||
<h1 className="text-xl font-semibold text-frost-950 dark:text-frost-50">You're offline</h1>
|
<h1 className="text-xl font-semibold text-frost-950 dark:text-frost-50">{t("offline.title")}</h1>
|
||||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
<p className="text-sm text-frost-600 dark:text-frost-300">{t("offline.body")}</p>
|
||||||
Freezmo needs a connection for generating decks and grading answers. Your saved decks are
|
|
||||||
still in this browser — reconnect and reload.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import { Spinner } from "@/components/ui";
|
import { Spinner } from "@/components/ui";
|
||||||
|
import { LOCALES, useI18n } from "@/lib/i18n";
|
||||||
import { verifyOllamaKey } from "@/lib/client-api";
|
import { verifyOllamaKey } from "@/lib/client-api";
|
||||||
import { saveSettings, settingsStore } from "@/lib/settings";
|
import { saveSettings, settingsStore } from "@/lib/settings";
|
||||||
import { useHealth } from "@/lib/use-health";
|
import { useHealth } from "@/lib/use-health";
|
||||||
|
|
@ -78,6 +79,7 @@ function CornerButton({
|
||||||
function GateScreen() {
|
function GateScreen() {
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
|
const { locale, setLocale, t } = useI18n();
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [reveal, setReveal] = useState(false);
|
const [reveal, setReveal] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
@ -192,17 +194,30 @@ function GateScreen() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CornerButton
|
<CornerButton
|
||||||
label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
label={theme === "dark" ? t("common.switchToLight") : t("common.switchToDark")}
|
||||||
title={theme === "dark" ? "Light mode" : "Dark mode"}
|
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? <SunIcon className="size-[18px]" /> : <MoonIcon className="size-[18px]" />}
|
{theme === "dark" ? <SunIcon className="size-[18px]" /> : <MoonIcon className="size-[18px]" />}
|
||||||
</CornerButton>
|
</CornerButton>
|
||||||
|
|
||||||
|
<CornerButton
|
||||||
|
label={t("language.label")}
|
||||||
|
title={t("language.label")}
|
||||||
|
onClick={() => {
|
||||||
|
const index = LOCALES.findIndex((entry) => entry.id === locale);
|
||||||
|
setLocale(LOCALES[(index + 1) % LOCALES.length].id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[12px] font-bold tracking-wide">
|
||||||
|
{LOCALES.find((entry) => entry.id === locale)?.short ?? "EN"}
|
||||||
|
</span>
|
||||||
|
</CornerButton>
|
||||||
|
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<CornerButton
|
<CornerButton
|
||||||
label="How do I get an Ollama API key?"
|
label={t("gate.howGetAria")}
|
||||||
title="How do I get a key?"
|
title={t("gate.howGet")}
|
||||||
onClick={() => setHelpOpen(true)}
|
onClick={() => setHelpOpen(true)}
|
||||||
>
|
>
|
||||||
<QuestionIcon className="size-[18px]" />
|
<QuestionIcon className="size-[18px]" />
|
||||||
|
|
@ -211,7 +226,7 @@ function GateScreen() {
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
className="pointer-events-none absolute right-0 top-full mt-2 whitespace-nowrap rounded-lg border border-[var(--hairline)] bg-[var(--gate-card)] px-2.5 py-1.5 text-[11.5px] text-[var(--text-muted)] opacity-0 shadow-[var(--shadow-card)] transition group-hover:opacity-100 group-focus-within:opacity-100"
|
className="pointer-events-none absolute right-0 top-full mt-2 whitespace-nowrap rounded-lg border border-[var(--hairline)] bg-[var(--gate-card)] px-2.5 py-1.5 text-[11.5px] text-[var(--text-muted)] opacity-0 shadow-[var(--shadow-card)] transition group-hover:opacity-100 group-focus-within:opacity-100"
|
||||||
>
|
>
|
||||||
How do I get a key?
|
{t("gate.howGet")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -241,16 +256,16 @@ function GateScreen() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h1 className="mt-6 text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[28px]">
|
<h1 className="mt-6 text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[28px]">
|
||||||
Connect your Ollama key
|
{t("gate.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mx-auto mt-1.5 max-w-[300px] text-balance text-[15px] leading-relaxed text-[var(--text-subtle)]">
|
<p className="mx-auto mt-1.5 max-w-[300px] text-balance text-[15px] leading-relaxed text-[var(--text-subtle)]">
|
||||||
One step to freeze your first deck.
|
{t("gate.subtitle")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form className="mt-6" onSubmit={submit} noValidate>
|
<form className="mt-6" onSubmit={submit} noValidate>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="sr-only" htmlFor="gate-api-key">
|
<label className="sr-only" htmlFor="gate-api-key">
|
||||||
Ollama API key
|
{t("gate.apiKeyLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|
@ -261,7 +276,7 @@ function GateScreen() {
|
||||||
setDraft(event.target.value);
|
setDraft(event.target.value);
|
||||||
if (error) setError(null);
|
if (error) setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Paste your Ollama API key"
|
placeholder={t("gate.placeholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
|
@ -273,7 +288,7 @@ function GateScreen() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setReveal((value) => !value)}
|
onClick={() => setReveal((value) => !value)}
|
||||||
aria-label={reveal ? "Hide API key" : "Show API key"}
|
aria-label={reveal ? t("gate.hide") : t("gate.show")}
|
||||||
aria-pressed={reveal}
|
aria-pressed={reveal}
|
||||||
className="absolute right-1.5 top-1/2 grid size-9 -translate-y-1/2 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="absolute right-1.5 top-1/2 grid size-9 -translate-y-1/2 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
|
|
@ -307,10 +322,10 @@ function GateScreen() {
|
||||||
>
|
>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<>
|
<>
|
||||||
<Spinner className="size-4" /> Checking…
|
<Spinner className="size-4" /> {t("gate.checking")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Continue"
|
t("gate.continue")
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -322,12 +337,12 @@ function GateScreen() {
|
||||||
className="inline-flex items-center gap-1.5 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline"
|
className="inline-flex items-center gap-1.5 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline"
|
||||||
>
|
>
|
||||||
<QuestionIcon className="size-3.5" />
|
<QuestionIcon className="size-3.5" />
|
||||||
How do I get a key?
|
{t("gate.howGet")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mx-auto mt-4 max-w-[320px] text-balance text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
<p className="mx-auto mt-4 max-w-[320px] text-balance text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
||||||
Your key stays in this browser. Our server never stores it.
|
{t("gate.stays")}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -343,6 +358,7 @@ function GateScreen() {
|
||||||
|
|
||||||
function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
@ -370,12 +386,12 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
id="gate-help-title"
|
id="gate-help-title"
|
||||||
className="text-[19px] font-semibold leading-snug tracking-[-0.02em] text-[var(--text)]"
|
className="text-[19px] font-semibold leading-snug tracking-[-0.02em] text-[var(--text)]"
|
||||||
>
|
>
|
||||||
How to get your Ollama API key
|
{t("gate.helpTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label={t("common.close")}
|
||||||
className="grid size-9 shrink-0 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="grid size-9 shrink-0 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
|
|
@ -385,33 +401,33 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
<div className="mt-4 flex items-start gap-2.5 rounded-xl border border-[var(--gate-callout-border)] bg-[var(--gate-callout-bg)] px-3.5 py-3">
|
<div className="mt-4 flex items-start gap-2.5 rounded-xl border border-[var(--gate-callout-border)] bg-[var(--gate-callout-bg)] px-3.5 py-3">
|
||||||
<CheckCircleIcon className="mt-px size-[18px] shrink-0 text-[var(--text-muted)]" />
|
<CheckCircleIcon className="mt-px size-[18px] shrink-0 text-[var(--text-muted)]" />
|
||||||
<p className="text-[12.5px] leading-relaxed text-[var(--text-muted)]">
|
<p className="text-[12.5px] leading-relaxed text-[var(--text-muted)]">
|
||||||
You only need the{" "}
|
{t("gate.freeTier.before")}{" "}
|
||||||
<strong className="font-semibold text-[var(--text)]">free cloud tier</strong>. No
|
<strong className="font-semibold text-[var(--text)]">{t("gate.freeTier.strong")}</strong>
|
||||||
payment and no credit card required.
|
{t("gate.freeTier.after")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol className="mt-5 space-y-4">
|
<ol className="mt-5 space-y-4">
|
||||||
<Step index={1}>
|
<Step index={1}>
|
||||||
Go to{" "}
|
{t("gate.step1.before")}{" "}
|
||||||
<ExternalLink href="https://ollama.com">ollama.com</ExternalLink> and sign in, or
|
<ExternalLink href="https://ollama.com">ollama.com</ExternalLink>{" "}
|
||||||
create a free account.
|
{t("gate.step1.after")}
|
||||||
</Step>
|
</Step>
|
||||||
<Step index={2}>
|
<Step index={2}>
|
||||||
Open Settings → API keys at{" "}
|
{t("gate.step2.before")}{" "}
|
||||||
<ExternalLink href={KEYS_URL}>ollama.com/settings/keys</ExternalLink>.
|
<ExternalLink href={KEYS_URL}>ollama.com/settings/keys</ExternalLink>
|
||||||
|
{t("gate.step2.after")}
|
||||||
</Step>
|
</Step>
|
||||||
<Step index={3}>
|
<Step index={3}>
|
||||||
Click{" "}
|
{t("gate.step3.before")}{" "}
|
||||||
<span className="font-medium text-[var(--text-muted)]">“Create new key”</span>,
|
<span className="font-medium text-[var(--text-muted)]">{t("gate.step3.strong")}</span>
|
||||||
give it any name, and copy it.
|
{t("gate.step3.after")}
|
||||||
</Step>
|
</Step>
|
||||||
<Step index={4}>Paste it into Freezmo and press Continue.</Step>
|
<Step index={4}>{t("gate.step4")}</Step>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<p className="mt-5 text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
<p className="mt-5 text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
||||||
The free tier is enough for generating decks. You can swap or remove your key anytime in
|
{t("gate.footnote")}
|
||||||
Settings.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -419,7 +435,7 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="btn-primary mt-5 h-11 w-full rounded-[13px] text-[14px] font-medium"
|
className="btn-primary mt-5 h-11 w-full rounded-[13px] text-[14px] font-medium"
|
||||||
>
|
>
|
||||||
Got it
|
{t("gate.gotIt")}
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
|
||||||
import { GearIcon, MenuIcon } from "@/components/icons";
|
import { GearIcon, MenuIcon } from "@/components/icons";
|
||||||
import { SettingsDialog } from "@/components/SettingsDialog";
|
import { SettingsDialog } from "@/components/SettingsDialog";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { sidebarOpen } from "@/lib/nav";
|
import { sidebarOpen } from "@/lib/nav";
|
||||||
import { OPEN_SETTINGS_EVENT } from "@/lib/ui-events";
|
import { OPEN_SETTINGS_EVENT } from "@/lib/ui-events";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -16,6 +17,7 @@ import { cn } from "@/lib/utils";
|
||||||
* for navigation.
|
* for navigation.
|
||||||
*/
|
*/
|
||||||
export function AppHeader({ className, compact = false }: { className?: string; compact?: boolean }) {
|
export function AppHeader({ className, compact = false }: { className?: string; compact?: boolean }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -41,12 +43,12 @@ export function AppHeader({ className, compact = false }: { className?: string;
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => sidebarOpen.toggle()}
|
onClick={() => sidebarOpen.toggle()}
|
||||||
aria-label="Open menu"
|
aria-label={t("header.openMenu")}
|
||||||
className="mr-1 grid size-9 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="mr-1 grid size-9 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
<MenuIcon className="size-5" />
|
<MenuIcon className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
<Link href="/" className="group flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label="Freezmo home">
|
<Link href="/" className="group flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label={t("header.home")}>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
|
||||||
<img
|
<img
|
||||||
src="/icons/icon-192.png"
|
src="/icons/icon-192.png"
|
||||||
|
|
@ -60,7 +62,7 @@ export function AppHeader({ className, compact = false }: { className?: string;
|
||||||
</span>
|
</span>
|
||||||
{!compact ? (
|
{!compact ? (
|
||||||
<span className="hidden text-[13px] text-[var(--text-subtle)] sm:inline">
|
<span className="hidden text-[13px] text-[var(--text-subtle)] sm:inline">
|
||||||
study feed
|
{t("header.tagline")}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -70,8 +72,8 @@ export function AppHeader({ className, compact = false }: { className?: string;
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSettingsOpen(true)}
|
onClick={() => setSettingsOpen(true)}
|
||||||
aria-label="Open settings and Ollama status"
|
aria-label={t("header.openSettings")}
|
||||||
title="Settings"
|
title={t("common.settings")}
|
||||||
className="inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
<GearIcon className="size-[18px]" />
|
<GearIcon className="size-[18px]" />
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { useState } from "react";
|
||||||
|
|
||||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||||
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
|
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
|
||||||
import { createFolder as makeFolder, folderName, listFolders } from "@/lib/folders";
|
import { createFolder as makeFolder, listFolders } from "@/lib/folders";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||||
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
|
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
|
||||||
import { cn, formatDate, truncate } from "@/lib/utils";
|
import { cn, formatDate, truncate } from "@/lib/utils";
|
||||||
|
|
@ -21,6 +22,7 @@ const ADD_OPTIONS = [3, 5, 8];
|
||||||
* prominent Study CTA with quiet secondary actions.
|
* prominent Study CTA with quiet secondary actions.
|
||||||
*/
|
*/
|
||||||
export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: string | undefined) => void }) {
|
export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: string | undefined) => void }) {
|
||||||
|
const { t, plural } = useI18n();
|
||||||
const folders = listFolders();
|
const folders = listFolders();
|
||||||
const [busy, setBusy] = useState<Busy>(null);
|
const [busy, setBusy] = useState<Busy>(null);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
@ -44,7 +46,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
|
|
||||||
const addMore = async () => {
|
const addMore = async () => {
|
||||||
if (!hasSource) {
|
if (!hasSource) {
|
||||||
setError("This deck has no stored source notes, so it can't be extended.");
|
setError(t("deck.noSourceExtend"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy("more");
|
setBusy("more");
|
||||||
|
|
@ -59,11 +61,11 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length },
|
existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length },
|
||||||
});
|
});
|
||||||
appendActivities(deck.id, materialiseActivities(generated.activities, deck.difficulty));
|
appendActivities(deck.id, materialiseActivities(generated.activities, deck.difficulty));
|
||||||
setNotice(`Added ${generated.activities.length} activities`);
|
setNotice(plural(generated.activities.length, "deck.added"));
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
window.setTimeout(() => setNotice(null), 3000);
|
window.setTimeout(() => setNotice(null), 3000);
|
||||||
} catch (addError) {
|
} catch (addError) {
|
||||||
setError(addError instanceof ApiError ? addError.message : "Could not add more activities.");
|
setError(addError instanceof ApiError ? addError.message : t("deck.couldNotAdd"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +73,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
|
|
||||||
const regenerate = async () => {
|
const regenerate = async () => {
|
||||||
if (!hasSource) {
|
if (!hasSource) {
|
||||||
setError("This deck has no stored source notes, so it can't be regenerated.");
|
setError(t("deck.noSourceRegenerate"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy("regenerate");
|
setBusy("regenerate");
|
||||||
|
|
@ -94,11 +96,11 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
bestScore: undefined,
|
bestScore: undefined,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
setNotice("Deck regenerated");
|
setNotice(t("deck.regenerated"));
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
window.setTimeout(() => setNotice(null), 3000);
|
window.setTimeout(() => setNotice(null), 3000);
|
||||||
} catch (regenerateError) {
|
} catch (regenerateError) {
|
||||||
setError(regenerateError instanceof ApiError ? regenerateError.message : "Could not regenerate the deck.");
|
setError(regenerateError instanceof ApiError ? regenerateError.message : t("deck.couldNotRegenerate"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +125,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
{renaming ? (
|
{renaming ? (
|
||||||
<div className="relative z-10 flex items-center gap-2">
|
<div className="relative z-10 flex items-center gap-2">
|
||||||
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
|
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
|
||||||
Deck title
|
{t("deck.titleLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={`rename-${deck.id}`}
|
id={`rename-${deck.id}`}
|
||||||
|
|
@ -141,7 +143,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
|
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={saveTitle}>
|
<Button size="sm" onClick={saveTitle}>
|
||||||
Save
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -158,28 +160,26 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
{formatDate(deck.updatedAt)}
|
{formatDate(deck.updatedAt)}
|
||||||
<span aria-hidden> · </span>
|
<span aria-hidden> · </span>
|
||||||
</span>
|
</span>
|
||||||
{deck.activities.length} activities
|
{plural(deck.activities.length, "deck.activities")}
|
||||||
<span className="lg:hidden">
|
<span className="lg:hidden">
|
||||||
{deck.attempts > 0 ? (
|
{deck.attempts > 0 ? (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden> · </span>
|
<span aria-hidden> · </span>
|
||||||
{deck.attempts} {deck.attempts === 1 ? "run" : "runs"}
|
{plural(deck.attempts, "deck.runs")}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
{/* Desktop always shows the same two fields. */}
|
{/* Desktop always shows the same two fields. */}
|
||||||
<span className="hidden lg:inline">
|
<span className="hidden lg:inline">
|
||||||
<span aria-hidden> · </span>
|
<span aria-hidden> · </span>
|
||||||
{deck.attempts > 0
|
{deck.attempts > 0 ? plural(deck.attempts, "deck.runs") : t("deck.notPlayed")}
|
||||||
? `${deck.attempts} ${deck.attempts === 1 ? "run" : "runs"}`
|
|
||||||
: "Not played yet"}
|
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Deck actions"
|
aria-label={t("deck.actions")}
|
||||||
aria-expanded={menuOpen}
|
aria-expanded={menuOpen}
|
||||||
onClick={() => setMenuOpen((value) => !value)}
|
onClick={() => setMenuOpen((value) => !value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -196,21 +196,28 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||||
{deck.subject ? <Tag>{deck.subject}</Tag> : null}
|
{deck.subject ? <Tag>{deck.subject}</Tag> : null}
|
||||||
<Tag>
|
<Tag>
|
||||||
<span className={difficultyClass(deck.difficulty)}>{deck.difficulty}</span>
|
<span className={difficultyClass(deck.difficulty)}>
|
||||||
|
{deck.difficulty === "easy"
|
||||||
|
? t("upload.easy")
|
||||||
|
: deck.difficulty === "hard"
|
||||||
|
? t("upload.hard")
|
||||||
|
: t("upload.medium")}
|
||||||
|
</span>
|
||||||
</Tag>
|
</Tag>
|
||||||
{deck.folderId ? (
|
{deck.folderId ? (
|
||||||
<Tag>
|
<Tag>
|
||||||
<FolderIcon className="size-3.5 opacity-70" />
|
<FolderIcon className="size-3.5 opacity-70" />
|
||||||
{folderName(deck.folderId)}
|
{folders.find((entry) => entry.id === deck.folderId)?.name ?? t("common.unorganized")}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
{deck.bestScore !== undefined ? (
|
{deck.bestScore !== undefined ? (
|
||||||
<>
|
<>
|
||||||
<Tag className="lg:hidden">
|
<Tag className="lg:hidden">
|
||||||
Best <span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
|
{t("deck.bestLabel")}{" "}
|
||||||
|
<span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
|
||||||
</Tag>
|
</Tag>
|
||||||
<span className="hidden text-[12px] font-medium tabular-nums text-[var(--text-muted)] lg:ml-1 lg:inline">
|
<span className="hidden text-[12px] font-medium tabular-nums text-[var(--text-muted)] lg:ml-1 lg:inline">
|
||||||
Best {deck.bestScore}%
|
{t("deck.bestLabel")} {deck.bestScore}%
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -226,9 +233,13 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
{/* status ------------------------------------------------------- */}
|
{/* status ------------------------------------------------------- */}
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
<ProgressBar value={1} max={2} label={busy === "more" ? "Adding activities" : "Regenerating deck"} />
|
<ProgressBar
|
||||||
|
value={1}
|
||||||
|
max={2}
|
||||||
|
label={busy === "more" ? t("deck.addingActivities") : t("deck.regeneratingDeck")}
|
||||||
|
/>
|
||||||
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">
|
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">
|
||||||
{busy === "more" ? "Adding new activities…" : "Regenerating the whole deck…"}
|
{busy === "more" ? t("deck.addingNew") : t("deck.regeneratingWhole")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -248,12 +259,12 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
<div className="mt-5 lg:mt-4">
|
<div className="mt-5 lg:mt-4">
|
||||||
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm lg:hidden">
|
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm lg:hidden">
|
||||||
<PlayIcon className="size-4" />
|
<PlayIcon className="size-4" />
|
||||||
Study
|
{t("deck.study")}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="relative z-10 hidden lg:flex lg:justify-end">
|
<div className="relative z-10 hidden lg:flex lg:justify-end">
|
||||||
<Link href={`/deck/${deck.id}`} className="btn-secondary h-9 px-4 text-[13px]">
|
<Link href={`/deck/${deck.id}`} className="btn-secondary h-9 px-4 text-[13px]">
|
||||||
<PlayIcon className="size-4" />
|
<PlayIcon className="size-4" />
|
||||||
Study
|
{t("deck.study")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -268,11 +279,11 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]">
|
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]">
|
||||||
✎
|
✎
|
||||||
</span>
|
</span>
|
||||||
Rename
|
{t("common.rename")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={menuRow} onClick={() => downloadDeck(deck)} disabled={Boolean(busy)}>
|
<button type="button" className={menuRow} onClick={() => downloadDeck(deck)} disabled={Boolean(busy)}>
|
||||||
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
|
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
|
||||||
Export JSON
|
{t("deck.export")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -281,16 +292,16 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
disabled={Boolean(busy) || !hasSource}
|
disabled={Boolean(busy) || !hasSource}
|
||||||
>
|
>
|
||||||
<RefreshIcon className="size-4 text-[var(--text-subtle)]" />
|
<RefreshIcon className="size-4 text-[var(--text-subtle)]" />
|
||||||
Regenerate deck
|
{t("deck.regenerate")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{confirmDelete ? (
|
{confirmDelete ? (
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
|
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
|
||||||
Really delete
|
{t("deck.reallyDelete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
|
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||||
Cancel
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -298,17 +309,17 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
<span aria-hidden className="w-4 text-center">
|
<span aria-hidden className="w-4 text-center">
|
||||||
⌫
|
⌫
|
||||||
</span>
|
</span>
|
||||||
Delete deck
|
{t("deck.delete")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* folder ---------------------------------------------------- */}
|
{/* folder ---------------------------------------------------- */}
|
||||||
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||||
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||||
Folder
|
{t("common.folder")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...folders].map((folder) => {
|
{[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...folders].map((folder) => {
|
||||||
const current = (deck.folderId ?? UNORGANIZED_ID) === folder.id;
|
const current = (deck.folderId ?? UNORGANIZED_ID) === folder.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -336,7 +347,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
onClick={() => setNewFolder("")}
|
onClick={() => setNewFolder("")}
|
||||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
+ New folder
|
{t("deck.newFolder")}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-0.5 pl-2.5 pr-1">
|
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-0.5 pl-2.5 pr-1">
|
||||||
|
|
@ -358,8 +369,8 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="New folder name"
|
aria-label={t("common.newFolderName")}
|
||||||
placeholder="Folder name"
|
placeholder={t("common.folderName")}
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
className="w-24 bg-transparent text-[12px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
|
className="w-24 bg-transparent text-[12px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -377,7 +388,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add
|
{t("common.add")}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -387,7 +398,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
{/* add more -------------------------------------------------- */}
|
{/* add more -------------------------------------------------- */}
|
||||||
<div className="mt-1 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
<div className="mt-1 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||||
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||||
Add more activities
|
{t("deck.addMore")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{ADD_OPTIONS.map((option) => (
|
{ADD_OPTIONS.map((option) => (
|
||||||
|
|
@ -407,12 +418,12 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<Button size="sm" onClick={() => void addMore()} disabled={Boolean(busy) || !hasSource} loading={busy === "more"}>
|
<Button size="sm" onClick={() => void addMore()} disabled={Boolean(busy) || !hasSource} loading={busy === "more"}>
|
||||||
Generate
|
{t("deck.generate")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{!hasSource ? (
|
{!hasSource ? (
|
||||||
<p className="px-1 pt-2 text-[11px] text-amber-700 dark:text-amber-300">
|
<p className="px-1 pt-2 text-[11px] text-amber-700 dark:text-amber-300">
|
||||||
No stored source text for this deck (imported decks don't carry it).
|
{t("deck.noSource")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useRef, useState } from "react";
|
||||||
|
|
||||||
import { FolderIcon, PlusIcon } from "@/components/icons";
|
import { FolderIcon, PlusIcon } from "@/components/icons";
|
||||||
import { createFolder } from "@/lib/folders";
|
import { createFolder } from "@/lib/folders";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Folder } from "@/lib/schemas";
|
import type { Folder } from "@/lib/schemas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ export function FolderBar({
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const result = createFolder(draft);
|
const result = createFolder(draft);
|
||||||
|
|
@ -62,7 +64,7 @@ export function FolderBar({
|
||||||
aria-pressed={selected === ALL_FOLDERS}
|
aria-pressed={selected === ALL_FOLDERS}
|
||||||
className={pill(selected === ALL_FOLDERS)}
|
className={pill(selected === ALL_FOLDERS)}
|
||||||
>
|
>
|
||||||
All decks
|
{t("common.allDecks")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{folders.map((folder) => (
|
{folders.map((folder) => (
|
||||||
|
|
@ -97,8 +99,8 @@ export function FolderBar({
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Folder name"
|
placeholder={t("common.folderName")}
|
||||||
aria-label="New folder name"
|
aria-label={t("common.newFolderName")}
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
className="w-28 bg-transparent text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
|
className="w-28 bg-transparent text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -107,7 +109,7 @@ export function FolderBar({
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
className="btn-primary h-7 px-3 text-[12px]"
|
className="btn-primary h-7 px-3 text-[12px]"
|
||||||
>
|
>
|
||||||
Add
|
{t("common.add")}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -117,7 +119,7 @@ export function FolderBar({
|
||||||
className="inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
New folder
|
{t("common.newFolder")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import { Spinner } from "@/components/ui";
|
import { Spinner } from "@/components/ui";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import {
|
import {
|
||||||
detectBrowser,
|
detectBrowser,
|
||||||
detectPlatform,
|
detectPlatform,
|
||||||
|
|
@ -52,6 +53,7 @@ export function InstallSheet() {
|
||||||
const [installing, setInstalling] = useState(false);
|
const [installing, setInstalling] = useState(false);
|
||||||
const dragControls = useDragControls();
|
const dragControls = useDragControls();
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
|
const { t } = useI18n();
|
||||||
const domain = typeof window === "undefined" ? "" : window.location.hostname;
|
const domain = typeof window === "undefined" ? "" : window.location.hostname;
|
||||||
|
|
||||||
/** Everything needed to render the sheet, or null if it shouldn't appear. */
|
/** Everything needed to render the sheet, or null if it shouldn't appear. */
|
||||||
|
|
@ -148,55 +150,64 @@ export function InstallSheet() {
|
||||||
if (info.browser !== "safari") {
|
if (info.browser !== "safari") {
|
||||||
steps.push(
|
steps.push(
|
||||||
<>
|
<>
|
||||||
Open this page in <Chip icon={<CompassIcon className="size-4" />}>Safari</Chip>
|
{t("install.openIn")} <Chip icon={<CompassIcon className="size-4" />}>Safari</Chip>
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
steps.push(
|
steps.push(
|
||||||
<>
|
<>
|
||||||
Tap <IconChip icon={<EllipsisIcon className="size-4" />} label="More options" /> in the
|
{t("install.tap")}{" "}
|
||||||
browser bar
|
<IconChip icon={<EllipsisIcon className="size-4" />} label={t("install.moreOptions")} />{" "}
|
||||||
|
{t("install.inBrowserBar")}
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
steps.push(
|
steps.push(
|
||||||
<>
|
<>
|
||||||
Tap <Chip icon={<ShareIcon className="size-4" />}>Share</Chip>, then{" "}
|
{t("install.tap")} <Chip icon={<ShareIcon className="size-4" />}>{t("install.share")}</Chip>
|
||||||
<Chip icon={<ChevronDownIcon className="size-4" />}>More</Chip>
|
{t("install.then")} <Chip icon={<ChevronDownIcon className="size-4" />}>{t("install.more")}</Chip>
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
steps.push(
|
steps.push(
|
||||||
<>
|
<>
|
||||||
Choose <Chip icon={<AddToHomeIcon className="size-4" />}>Add to Home Screen</Chip>
|
{t("install.choose")}{" "}
|
||||||
|
<Chip icon={<AddToHomeIcon className="size-4" />}>{t("install.addToHome")}</Chip>
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
steps.push(
|
steps.push(
|
||||||
<>
|
<>
|
||||||
Tap <AccentPill icon={<PlusIcon className="size-4" />}>Add</AccentPill> to confirm
|
{t("install.tapConfirm")}{" "}
|
||||||
|
<AccentPill icon={<PlusIcon className="size-4" />}>{t("install.add")}</AccentPill>{" "}
|
||||||
|
{t("install.toConfirm")}
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
} else if (info?.platform === "android") {
|
} else if (info?.platform === "android") {
|
||||||
if (info.browser !== "chrome") {
|
if (info.browser !== "chrome") {
|
||||||
steps.push(
|
steps.push(
|
||||||
<>
|
<>
|
||||||
Open this page in <Chip icon={<ChromeIcon className="size-4" />}>Chrome</Chip>
|
{t("install.openIn")} <Chip icon={<ChromeIcon className="size-4" />}>Chrome</Chip>
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
steps.push(
|
steps.push(
|
||||||
<>
|
<>
|
||||||
Tap <IconChip icon={<EllipsisIcon className="size-4" />} label="More options" /> in the top
|
{t("install.tap")}{" "}
|
||||||
right of the browser
|
<IconChip icon={<EllipsisIcon className="size-4" />} label={t("install.moreOptions")} />{" "}
|
||||||
|
{t("install.inTopRight")}
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
steps.push(
|
steps.push(
|
||||||
<>
|
<>
|
||||||
Choose <Chip icon={<DownloadIcon className="size-4" />}>Install app</Chip> or{" "}
|
{t("install.choose")}{" "}
|
||||||
<Chip icon={<AddToHomeIcon className="size-4" />}>Add to Home screen</Chip>
|
<Chip icon={<DownloadIcon className="size-4" />}>{t("install.installApp")}</Chip>{" "}
|
||||||
|
{t("install.or")}{" "}
|
||||||
|
<Chip icon={<AddToHomeIcon className="size-4" />}>{t("install.addToHomeScreen")}</Chip>
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
steps.push(
|
steps.push(
|
||||||
<>
|
<>
|
||||||
Tap <AccentPill icon={<DownloadIcon className="size-4" />}>Install</AccentPill> to confirm
|
{t("install.tapConfirm")}{" "}
|
||||||
|
<AccentPill icon={<DownloadIcon className="size-4" />}>{t("install.install")}</AccentPill>{" "}
|
||||||
|
{t("install.toConfirm")}
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -252,14 +263,12 @@ export function InstallSheet() {
|
||||||
id="install-sheet-title"
|
id="install-sheet-title"
|
||||||
className="text-[17px] font-semibold tracking-[-0.02em] text-[var(--text)]"
|
className="text-[17px] font-semibold tracking-[-0.02em] text-[var(--text)]"
|
||||||
>
|
>
|
||||||
{info.platform === "ios"
|
{info.platform === "ios" ? t("install.titleIos") : t("install.titleAndroid")}
|
||||||
? "Install Freezmo on iPhone"
|
|
||||||
: "Install Freezmo on Android"}
|
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
aria-label="Close"
|
aria-label={t("common.close")}
|
||||||
className="grid size-10 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="grid size-10 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
<XIcon className="size-[18px]" />
|
<XIcon className="size-[18px]" />
|
||||||
|
|
@ -294,11 +303,11 @@ export function InstallSheet() {
|
||||||
>
|
>
|
||||||
{installing ? (
|
{installing ? (
|
||||||
<>
|
<>
|
||||||
<Spinner className="size-4" /> Installing…
|
<Spinner className="size-4" /> {t("install.installing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DownloadIcon className="size-4" /> Install
|
<DownloadIcon className="size-4" /> {t("install.install")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -306,7 +315,7 @@ export function InstallSheet() {
|
||||||
|
|
||||||
{showNativePrompt ? (
|
{showNativePrompt ? (
|
||||||
<p className="mt-4 text-center text-[12px] text-[var(--text-subtle)]">
|
<p className="mt-4 text-center text-[12px] text-[var(--text-subtle)]">
|
||||||
Or install manually:
|
{t("install.orManually")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -329,7 +338,7 @@ export function InstallSheet() {
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className="mt-6 w-full rounded-[14px] py-3 text-center text-[15px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="mt-6 w-full rounded-[14px] py-3 text-center text-[15px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
Maybe later
|
{t("install.maybeLater")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { FolderBar } from "@/components/FolderBar";
|
||||||
import { FolderIcon, UploadIcon } from "@/components/icons";
|
import { FolderIcon, UploadIcon } from "@/components/icons";
|
||||||
import { Button, EmptyState } from "@/components/ui";
|
import { Button, EmptyState } from "@/components/ui";
|
||||||
import { folderStore, renameFolder } from "@/lib/folders";
|
import { folderStore, renameFolder } from "@/lib/folders";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
|
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
|
||||||
import { openSettings } from "@/lib/ui-events";
|
import { openSettings } from "@/lib/ui-events";
|
||||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||||
|
|
@ -27,6 +28,7 @@ export function LibraryScreen() {
|
||||||
);
|
);
|
||||||
const selected = useSelectedFolder();
|
const selected = useSelectedFolder();
|
||||||
const setSelected = folderSelection.set;
|
const setSelected = folderSelection.set;
|
||||||
|
const { t, plural } = useI18n();
|
||||||
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
||||||
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
|
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
|
@ -51,7 +53,7 @@ export function LibraryScreen() {
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const unorganized: { id: string; name: string; decks: Deck[]; virtual: boolean } = {
|
const unorganized: { id: string; name: string; decks: Deck[]; virtual: boolean } = {
|
||||||
id: UNORGANIZED_ID,
|
id: UNORGANIZED_ID,
|
||||||
name: "Unorganized",
|
name: t("common.unorganized"),
|
||||||
decks: sorted.filter((deck) => !deck.folderId),
|
decks: sorted.filter((deck) => !deck.folderId),
|
||||||
virtual: true,
|
virtual: true,
|
||||||
};
|
};
|
||||||
|
|
@ -72,7 +74,7 @@ export function LibraryScreen() {
|
||||||
virtual: false,
|
virtual: false,
|
||||||
}));
|
}));
|
||||||
return [...named, unorganized];
|
return [...named, unorganized];
|
||||||
}, [folderList, selected, sorted]);
|
}, [folderList, selected, sorted, t]);
|
||||||
|
|
||||||
const totalActivities = decks.reduce((sum, deck) => sum + deck.activities.length, 0);
|
const totalActivities = decks.reduce((sum, deck) => sum + deck.activities.length, 0);
|
||||||
const bestEver = decks.reduce((best, deck) => Math.max(best, deck.bestScore ?? 0), 0);
|
const bestEver = decks.reduce((best, deck) => Math.max(best, deck.bestScore ?? 0), 0);
|
||||||
|
|
@ -84,9 +86,9 @@ export function LibraryScreen() {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const result = importDeck(text);
|
const result = importDeck(text);
|
||||||
if ("error" in result) setMessage({ tone: "error", text: result.error });
|
if ("error" in result) setMessage({ tone: "error", text: result.error });
|
||||||
else setMessage({ tone: "ok", text: `Imported “${result.deck.title}”.` });
|
else setMessage({ tone: "ok", text: t("library.imported", { title: result.deck.title }) });
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ tone: "error", text: "Could not read that file." });
|
setMessage({ tone: "error", text: t("library.couldNotRead") });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -94,7 +96,7 @@ export function LibraryScreen() {
|
||||||
deleteFolder(folderId);
|
deleteFolder(folderId);
|
||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
setSelected(ALL_FOLDERS);
|
setSelected(ALL_FOLDERS);
|
||||||
setMessage({ tone: "ok", text: "Folder deleted — its decks moved to Unorganized." });
|
setMessage({ tone: "ok", text: t("library.folderDeleted") });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEmpty = sorted.length === 0 && folderList.length === 0;
|
const isEmpty = sorted.length === 0 && folderList.length === 0;
|
||||||
|
|
@ -109,10 +111,10 @@ export function LibraryScreen() {
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
|
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
|
||||||
Your icebox
|
{t("library.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
|
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
|
||||||
Decks frozen from your notes, saved in this browser.
|
{t("library.subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -124,7 +126,7 @@ export function LibraryScreen() {
|
||||||
onClick={() => importRef.current?.click()}
|
onClick={() => importRef.current?.click()}
|
||||||
>
|
>
|
||||||
<UploadIcon className="size-4" />
|
<UploadIcon className="size-4" />
|
||||||
Import JSON
|
{t("library.import")}
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -132,7 +134,7 @@ export function LibraryScreen() {
|
||||||
className="hidden items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline lg:inline-flex"
|
className="hidden items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline lg:inline-flex"
|
||||||
>
|
>
|
||||||
<UploadIcon className="size-4" />
|
<UploadIcon className="size-4" />
|
||||||
Import JSON
|
{t("library.import")}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={importRef}
|
ref={importRef}
|
||||||
|
|
@ -151,15 +153,15 @@ export function LibraryScreen() {
|
||||||
where the sidebar already shows per-folder counts. */}
|
where the sidebar already shows per-folder counts. */}
|
||||||
{decks.length > 0 ? (
|
{decks.length > 0 ? (
|
||||||
<p className="text-[13px] text-[var(--text-subtle)] lg:hidden">
|
<p className="text-[13px] text-[var(--text-subtle)] lg:hidden">
|
||||||
{decks.length} {decks.length === 1 ? "deck" : "decks"}
|
{plural(decks.length, "stats.decks")}
|
||||||
<span aria-hidden> · </span>
|
<span aria-hidden> · </span>
|
||||||
{folderList.length} {folderList.length === 1 ? "folder" : "folders"}
|
{plural(folderList.length, "stats.folders")}
|
||||||
<span aria-hidden> · </span>
|
<span aria-hidden> · </span>
|
||||||
{totalActivities} activities
|
{plural(totalActivities, "stats.activities")}
|
||||||
{played ? (
|
{played ? (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden> · </span>
|
<span aria-hidden> · </span>
|
||||||
best {bestEver}%
|
{t("library.stats.best", { n: bestEver })}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -178,7 +180,7 @@ export function LibraryScreen() {
|
||||||
setSelected(id);
|
setSelected(id);
|
||||||
setMessage({
|
setMessage({
|
||||||
tone: "ok",
|
tone: "ok",
|
||||||
text: "Folder created — decks you freeze while it's selected land here.",
|
text: t("library.folderCreated"),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -200,22 +202,22 @@ export function LibraryScreen() {
|
||||||
{/* deck grid ------------------------------------------------------ */}
|
{/* deck grid ------------------------------------------------------ */}
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No decks yet"
|
title={t("library.noDecks")}
|
||||||
action={
|
action={
|
||||||
<Link href="/create" className="btn-primary h-10 px-4 text-sm">
|
<Link href="/create" className="btn-primary h-10 px-4 text-sm">
|
||||||
Make your first deck
|
{t("library.makeFirst")}
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Freeze a page of notes into a feed of flashcards and quizzes.
|
{t("library.emptyBody")}
|
||||||
<span className="mt-3 block text-[12.5px] text-[var(--text-subtle)]">
|
<span className="mt-3 block text-[12.5px] text-[var(--text-subtle)]">
|
||||||
Using your own Ollama models —{" "}
|
{t("library.emptyHint")}{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openSettings}
|
onClick={openSettings}
|
||||||
className="font-medium text-[var(--text-muted)] underline underline-offset-2 hover:text-[var(--text)]"
|
className="font-medium text-[var(--text-muted)] underline underline-offset-2 hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
check the connection in Settings
|
{t("library.emptySettings")}
|
||||||
</button>
|
</button>
|
||||||
.
|
.
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -245,7 +247,7 @@ export function LibraryScreen() {
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") setEditingFolder(null);
|
if (event.key === "Escape") setEditingFolder(null);
|
||||||
}}
|
}}
|
||||||
aria-label="Folder name"
|
aria-label={t("library.renameFolderAria")}
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
className="min-w-32 max-w-xs flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
|
className="min-w-32 max-w-xs flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
|
||||||
/>
|
/>
|
||||||
|
|
@ -257,7 +259,7 @@ export function LibraryScreen() {
|
||||||
else setEditingFolder(null);
|
else setEditingFolder(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Save
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -280,15 +282,15 @@ export function LibraryScreen() {
|
||||||
onClick={() => setEditingFolder({ id: group.id, name: group.name })}
|
onClick={() => setEditingFolder({ id: group.id, name: group.name })}
|
||||||
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
Rename
|
{t("common.rename")}
|
||||||
</button>
|
</button>
|
||||||
{confirmDelete === group.id ? (
|
{confirmDelete === group.id ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="danger" onClick={() => removeFolderEverywhere(group.id)}>
|
<Button size="sm" variant="danger" onClick={() => removeFolderEverywhere(group.id)}>
|
||||||
Delete
|
{t("common.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(null)}>
|
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(null)}>
|
||||||
Cancel
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -297,7 +299,7 @@ export function LibraryScreen() {
|
||||||
onClick={() => setConfirmDelete(group.id)}
|
onClick={() => setConfirmDelete(group.id)}
|
||||||
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
|
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
|
||||||
>
|
>
|
||||||
Delete
|
{t("common.delete")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -307,22 +309,19 @@ export function LibraryScreen() {
|
||||||
{/* One quiet label replaces the redundant single-group heading. */}
|
{/* One quiet label replaces the redundant single-group heading. */}
|
||||||
{!hasFolders ? (
|
{!hasFolders ? (
|
||||||
<p className="mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
|
<p className="mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
|
||||||
All decks
|
{t("common.allDecks")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{confirmDelete === group.id ? (
|
{confirmDelete === group.id ? (
|
||||||
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
||||||
The {group.decks.length} deck{group.decks.length === 1 ? "" : "s"} inside will move to
|
{plural(group.decks.length, "library.deleteWarning")}
|
||||||
Unorganized — nothing is deleted.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{group.decks.length === 0 ? (
|
{group.decks.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-[var(--hairline)] px-5 py-8 text-center text-[13px] text-[var(--text-subtle)]">
|
<div className="rounded-2xl border border-dashed border-[var(--hairline)] px-5 py-8 text-center text-[13px] text-[var(--text-subtle)]">
|
||||||
{group.virtual
|
{group.virtual ? t("library.nothingUnfiled") : t("library.emptyFolder")}
|
||||||
? "Nothing unfiled right now."
|
|
||||||
: "Empty folder — freeze a deck into it or move one over from Unorganized."}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
|
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
|
||||||
|
|
@ -334,7 +333,7 @@ export function LibraryScreen() {
|
||||||
upsertDeck({ ...deck, folderId });
|
upsertDeck({ ...deck, folderId });
|
||||||
setMessage({
|
setMessage({
|
||||||
tone: "ok",
|
tone: "ok",
|
||||||
text: folderId ? "Deck moved." : "Deck moved to Unorganized.",
|
text: folderId ? t("library.deckMoved") : t("library.deckMovedUnorganized"),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity, Deck } from "@/lib/schemas";
|
import type { Activity, Deck } from "@/lib/schemas";
|
||||||
import { downloadDeck } from "@/lib/storage";
|
import { downloadDeck } from "@/lib/storage";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -11,14 +12,16 @@ import { Button, Chip } from "@/components/ui";
|
||||||
|
|
||||||
import type { AttemptResult } from "./StudyFeed";
|
import type { AttemptResult } from "./StudyFeed";
|
||||||
|
|
||||||
export function answerFor(activity: Activity): string {
|
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
export function answerFor(activity: Activity, tr: Translator): string {
|
||||||
switch (activity.type) {
|
switch (activity.type) {
|
||||||
case "flashcard":
|
case "flashcard":
|
||||||
return activity.back;
|
return activity.back;
|
||||||
case "multiple_choice":
|
case "multiple_choice":
|
||||||
return activity.options[activity.correctIndex];
|
return activity.options[activity.correctIndex];
|
||||||
case "true_false":
|
case "true_false":
|
||||||
return activity.answer ? "True" : "False";
|
return activity.answer ? tr("results.true") : tr("results.false");
|
||||||
case "fill_blank":
|
case "fill_blank":
|
||||||
return activity.answer;
|
return activity.answer;
|
||||||
case "matching":
|
case "matching":
|
||||||
|
|
@ -78,6 +81,7 @@ export function ResultsScreen({
|
||||||
regenerating: boolean;
|
regenerating: boolean;
|
||||||
regenerateError: string | null;
|
regenerateError: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
|
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||||
const perfect = missed.length === 0 && total > 0;
|
const perfect = missed.length === 0 && total > 0;
|
||||||
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
|
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
|
||||||
|
|
@ -91,26 +95,37 @@ export function ResultsScreen({
|
||||||
>
|
>
|
||||||
<ScoreRing percent={percent} tone={tone} />
|
<ScoreRing percent={percent} tone={tone} />
|
||||||
<h1 className="text-2xl font-semibold text-frost-950 text-balance dark:text-frost-50">
|
<h1 className="text-2xl font-semibold text-frost-950 text-balance dark:text-frost-50">
|
||||||
{perfect ? "Perfect freeze! 🧊" : percent >= 60 ? "Nicely done!" : "Good start — keep going!"}
|
{perfect ? t("results.perfect") : percent >= 60 ? t("results.nice") : t("results.start")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||||
{perfect
|
{perfect
|
||||||
? `You aced all ${total} activities in “${deck.title}”.`
|
? t("results.acedAll", { n: total, title: deck.title })
|
||||||
: `You got ${correct} of ${total} right${retryMode ? " on the retry run" : ""}.`}
|
: t("results.gotOf", {
|
||||||
|
correct,
|
||||||
|
total,
|
||||||
|
retry: retryMode ? t("results.retrySuffix") : "",
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-1 flex flex-wrap justify-center gap-2">
|
<div className="mt-1 flex flex-wrap justify-center gap-2">
|
||||||
<Chip tone="accent">
|
<Chip tone="accent">
|
||||||
<BoltIcon className="size-3.5" /> {xp} XP earned
|
<BoltIcon className="size-3.5" /> {t("results.xpEarned", { n: xp })}
|
||||||
</Chip>
|
</Chip>
|
||||||
<Chip tone="warning">
|
<Chip tone="warning">
|
||||||
<FlameIcon className="size-3.5" /> Best streak {bestStreak}
|
<FlameIcon className="size-3.5" /> {t("results.bestStreak", { n: bestStreak })}
|
||||||
</Chip>
|
</Chip>
|
||||||
<Chip tone="neutral">
|
<Chip tone="neutral">
|
||||||
{[deck.difficulty].map((value) => value[0].toUpperCase() + value.slice(1))[0]} difficulty
|
{t("results.difficulty", {
|
||||||
|
level:
|
||||||
|
deck.difficulty === "easy"
|
||||||
|
? t("upload.easy")
|
||||||
|
: deck.difficulty === "hard"
|
||||||
|
? t("upload.hard")
|
||||||
|
: t("upload.medium"),
|
||||||
|
})}
|
||||||
</Chip>
|
</Chip>
|
||||||
{deck.bestScore !== undefined && !retryMode ? (
|
{deck.bestScore !== undefined && !retryMode ? (
|
||||||
<Chip tone="success">🏆 Best {deck.bestScore}%</Chip>
|
<Chip tone="success">{t("results.best", { n: deck.bestScore })}</Chip>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -118,7 +133,7 @@ export function ResultsScreen({
|
||||||
{missed.length > 0 ? (
|
{missed.length > 0 ? (
|
||||||
<div className="card space-y-3 p-5">
|
<div className="card space-y-3 p-5">
|
||||||
<h2 className="flex items-center gap-2 text-base font-bold text-frost-950 dark:text-frost-50">
|
<h2 className="flex items-center gap-2 text-base font-bold text-frost-950 dark:text-frost-50">
|
||||||
<SnowflakeIcon className="size-4" /> Missed items ({missed.length})
|
<SnowflakeIcon className="size-4" /> {t("results.missed", { n: missed.length })}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{missed.map((activity) => (
|
{missed.map((activity) => (
|
||||||
|
|
@ -129,12 +144,12 @@ export function ResultsScreen({
|
||||||
<p className="font-semibold text-frost-950 dark:text-frost-50">{promptFor(activity)}</p>
|
<p className="font-semibold text-frost-950 dark:text-frost-50">{promptFor(activity)}</p>
|
||||||
{results[activity.id]?.userAnswer ? (
|
{results[activity.id]?.userAnswer ? (
|
||||||
<p className="mt-1 text-xs text-rose-600 dark:text-rose-300">
|
<p className="mt-1 text-xs text-rose-600 dark:text-rose-300">
|
||||||
Your answer: {results[activity.id]?.userAnswer}
|
{t("results.yourAnswer", { answer: results[activity.id]?.userAnswer ?? "" })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-300">
|
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-300">
|
||||||
<span className="font-bold">Answer: </span>
|
<span className="font-bold">{t("results.answer")} </span>
|
||||||
{answerFor(activity)}
|
{answerFor(activity, t)}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -145,16 +160,16 @@ export function ResultsScreen({
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{missed.length > 0 ? (
|
{missed.length > 0 ? (
|
||||||
<Button size="lg" onClick={onRetryMissed}>
|
<Button size="lg" onClick={onRetryMissed}>
|
||||||
<RefreshIcon className="size-5" /> Retry missed ones ({missed.length})
|
<RefreshIcon className="size-5" /> {t("results.retryMissed", { n: missed.length })}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button size="lg" variant="secondary" onClick={onRestart}>
|
<Button size="lg" variant="secondary" onClick={onRestart}>
|
||||||
<RefreshIcon className="size-5" /> Study this deck again
|
<RefreshIcon className="size-5" /> {t("results.studyAgain")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{missed.length > 0 ? (
|
{missed.length > 0 ? (
|
||||||
<Button size="lg" variant="secondary" onClick={onRestart}>
|
<Button size="lg" variant="secondary" onClick={onRestart}>
|
||||||
<RefreshIcon className="size-5" /> Study the whole deck again
|
<RefreshIcon className="size-5" /> {t("results.studyWhole")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -165,7 +180,7 @@ export function ResultsScreen({
|
||||||
disabled={regenerating}
|
disabled={regenerating}
|
||||||
>
|
>
|
||||||
<SparklesIcon className="size-5" />
|
<SparklesIcon className="size-5" />
|
||||||
{regenerating ? "Regenerating…" : "Regenerate deck"}
|
{regenerating ? t("results.regenerating") : t("results.regenerate")}
|
||||||
</Button>
|
</Button>
|
||||||
{regenerateError ? (
|
{regenerateError ? (
|
||||||
<p className="text-center text-xs text-rose-600 dark:text-rose-300" role="alert">
|
<p className="text-center text-xs text-rose-600 dark:text-rose-300" role="alert">
|
||||||
|
|
@ -174,13 +189,13 @@ export function ResultsScreen({
|
||||||
) : null}
|
) : null}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
|
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
|
||||||
<DownloadIcon className="size-4" /> Export JSON
|
<DownloadIcon className="size-4" /> {t("results.export")}
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-white/80 px-4 py-2.5 text-sm font-semibold text-frost-800 transition hover:bg-white dark:bg-white/10 dark:text-frost-100 dark:hover:bg-white/20"
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-white/80 px-4 py-2.5 text-sm font-semibold text-frost-800 transition hover:bg-white dark:bg-white/10 dark:text-frost-100 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<HomeIcon className="size-4" /> Library
|
<HomeIcon className="size-4" /> {t("common.library")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -189,6 +204,7 @@ export function ResultsScreen({
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "frost" | "amber" }) {
|
function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "frost" | "amber" }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const radius = 52;
|
const radius = 52;
|
||||||
const circumference = 2 * Math.PI * radius;
|
const circumference = 2 * Math.PI * radius;
|
||||||
const offset = circumference * (1 - Math.min(100, Math.max(0, percent)) / 100);
|
const offset = circumference * (1 - Math.min(100, Math.max(0, percent)) / 100);
|
||||||
|
|
@ -218,7 +234,7 @@ function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "fros
|
||||||
<div className="absolute flex flex-col items-center">
|
<div className="absolute flex flex-col items-center">
|
||||||
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{percent}%</span>
|
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{percent}%</span>
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-frost-500 dark:text-frost-400">
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-frost-500 dark:text-frost-400">
|
||||||
score
|
{t("results.score")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { LOCALES, useI18n } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
|
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
|
||||||
import { clearApiKey, saveSettings } from "@/lib/settings";
|
import { clearApiKey, saveSettings } from "@/lib/settings";
|
||||||
|
|
@ -13,6 +14,7 @@ import { Button, Spinner } from "@/components/ui";
|
||||||
export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const { health, loading, error, refresh, apiKey } = useHealth();
|
const { health, loading, error, refresh, apiKey } = useHealth();
|
||||||
const installAvailable = useInstallAvailable();
|
const installAvailable = useInstallAvailable();
|
||||||
|
const { locale, setLocale, t } = useI18n();
|
||||||
const [keyDraft, setKeyDraft] = useState(apiKey);
|
const [keyDraft, setKeyDraft] = useState(apiKey);
|
||||||
const [reveal, setReveal] = useState(false);
|
const [reveal, setReveal] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
@ -66,40 +68,63 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
||||||
<div className="mb-4 flex items-start justify-between gap-4">
|
<div className="mb-4 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="settings-title" className="text-lg font-semibold tracking-[-0.02em] text-frost-950 dark:text-frost-50">
|
<h2 id="settings-title" className="text-lg font-semibold tracking-[-0.02em] text-frost-950 dark:text-frost-50">
|
||||||
Settings
|
{t("settings.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||||
Models come from <code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code> on
|
{t("settings.subtitle.before")}{" "}
|
||||||
the server; your API key stays in this browser.
|
<code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code>{" "}
|
||||||
|
{t("settings.subtitle.after")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Close settings">
|
<Button variant="ghost" size="sm" onClick={onClose} aria-label={t("settings.close")}>
|
||||||
✕
|
✕
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* --------------------------------------------- language */}
|
||||||
|
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||||
|
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
|
||||||
|
{t("language.label")}
|
||||||
|
</h3>
|
||||||
|
<div className="segmented" role="group" aria-label={t("language.label")}>
|
||||||
|
{LOCALES.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={locale === entry.id}
|
||||||
|
onClick={() => setLocale(entry.id)}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
|
||||||
|
{t("language.note")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ------------------------------------------------ API key */}
|
{/* ------------------------------------------------ API key */}
|
||||||
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">Ollama API key</h3>
|
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.apiKey")}</h3>
|
||||||
{apiKey ? (
|
{apiKey ? (
|
||||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200">
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200">
|
||||||
saved in this browser
|
{t("settings.savedInBrowser")}
|
||||||
</span>
|
</span>
|
||||||
) : needsKeyMode ? (
|
) : needsKeyMode ? (
|
||||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
||||||
required
|
{t("settings.required")}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-frost-600 dark:text-frost-300">
|
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-frost-600 dark:text-frost-300">
|
||||||
not needed
|
{t("settings.notNeeded")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<label className="sr-only" htmlFor="ollama-api-key">
|
<label className="sr-only" htmlFor="ollama-api-key">
|
||||||
Ollama API key
|
{t("settings.apiKey")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="ollama-api-key"
|
id="ollama-api-key"
|
||||||
|
|
@ -110,38 +135,37 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.key === "Enter") save();
|
if (event.key === "Enter") save();
|
||||||
}}
|
}}
|
||||||
placeholder="paste your key from ollama.com/settings/keys"
|
placeholder={t("settings.pasteKey")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="min-w-40 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-frost-400 focus:border-accent-500 dark:text-frost-50"
|
className="min-w-40 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-frost-400 focus:border-accent-500 dark:text-frost-50"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="secondary" onClick={() => setReveal((value) => !value)}>
|
<Button size="sm" variant="secondary" onClick={() => setReveal((value) => !value)}>
|
||||||
{reveal ? "Hide" : "Show"}
|
{reveal ? t("settings.hide") : t("settings.show")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={save} disabled={keyDraft.trim() === apiKey}>
|
<Button size="sm" onClick={save} disabled={keyDraft.trim() === apiKey}>
|
||||||
Save
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
|
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
|
||||||
Stored only in this browser's local storage and sent with each request so the server can talk to
|
{t("settings.keyNote")} <code>OLLAMA_AUTH=local</code> {t("settings.keyNoteAfter")}{" "}
|
||||||
Ollama for you. It is never written to the server, never logged and never put in a cookie. Prefer the
|
<code>.env.local</code> {t("settings.keyNoteEnd")} <code>ollama signin</code>
|
||||||
local daemon? Set <code>OLLAMA_AUTH=local</code> in <code>.env.local</code> and use{" "}
|
{t("settings.keyNoteEnd2")}
|
||||||
<code>ollama signin</code> instead.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||||
<Button size="sm" variant="secondary" onClick={() => void refresh()} loading={loading}>
|
<Button size="sm" variant="secondary" onClick={() => void refresh()} loading={loading}>
|
||||||
Test connection
|
{t("settings.testConnection")}
|
||||||
</Button>
|
</Button>
|
||||||
{apiKey ? (
|
{apiKey ? (
|
||||||
<Button size="sm" variant="ghost" onClick={forget}>
|
<Button size="sm" variant="ghost" onClick={forget}>
|
||||||
Remove key from this browser
|
{t("settings.removeKey")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{saved ? (
|
{saved ? (
|
||||||
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
|
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
|
||||||
Saved — testing…
|
{t("settings.savedTesting")}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -166,10 +190,10 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
|
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
|
||||||
Install app
|
{t("settings.installApp")}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[12px] text-[var(--text-subtle)]">
|
<span className="block text-[12px] text-[var(--text-subtle)]">
|
||||||
Add Freezmo to your home screen
|
{t("settings.addToHome")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
||||||
|
|
@ -179,7 +203,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
||||||
|
|
||||||
{loading && !health ? (
|
{loading && !health ? (
|
||||||
<div className="flex items-center gap-3 py-6 text-sm text-frost-600 dark:text-frost-300">
|
<div className="flex items-center gap-3 py-6 text-sm text-frost-600 dark:text-frost-300">
|
||||||
<Spinner className="size-5" /> Checking Ollama…
|
<Spinner className="size-5" /> {t("settings.checking")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -198,6 +222,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
||||||
}
|
}
|
||||||
|
|
||||||
function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"] }) {
|
function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
if (!health) return null;
|
if (!health) return null;
|
||||||
const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose";
|
const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose";
|
||||||
const toneClasses: Record<string, string> = {
|
const toneClasses: Record<string, string> = {
|
||||||
|
|
@ -220,59 +245,62 @@ function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{health.ok
|
{health.ok
|
||||||
? "Ollama is reachable and both models are available."
|
? t("health.ok")
|
||||||
: health.needsKey
|
: health.needsKey
|
||||||
? "Waiting for your API key."
|
? t("health.needsKey")
|
||||||
: health.keyAccepted === false
|
: health.keyAccepted === false
|
||||||
? "Ollama rejected your API key."
|
? t("health.rejected")
|
||||||
: health.reachable
|
: health.reachable
|
||||||
? "Ollama is reachable, but a configured model is missing."
|
? t("health.modelMissing")
|
||||||
: "Ollama isn't reachable."}
|
: t("health.unreachable")}
|
||||||
{health.error ? <p className="mt-1 font-normal">{health.error}</p> : null}
|
{health.error ? <p className="mt-1 font-normal">{health.error}</p> : null}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||||
<Info
|
<Info
|
||||||
label="Auth"
|
label={t("health.auth")}
|
||||||
value={
|
value={
|
||||||
health.authMode === "user-key"
|
health.authMode === "user-key"
|
||||||
? "Your own key"
|
? t("health.authUserKey")
|
||||||
: health.authMode === "server"
|
: health.authMode === "server"
|
||||||
? "Server key (.env)"
|
? t("health.authServer")
|
||||||
: "Local daemon sign-in"
|
: t("health.authLocal")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Info label="Endpoint" value={health.baseUrl} mono />
|
<Info label={t("health.endpoint")} value={health.baseUrl} mono />
|
||||||
<Info label="Mode" value={health.mode === "direct-cloud" ? "Direct cloud API" : "Local daemon"} />
|
|
||||||
<Info
|
<Info
|
||||||
label="Key"
|
label={t("health.mode")}
|
||||||
|
value={health.mode === "direct-cloud" ? t("health.modeCloud") : t("health.modeLocal")}
|
||||||
|
/>
|
||||||
|
<Info
|
||||||
|
label={t("health.key")}
|
||||||
value={
|
value={
|
||||||
health.needsKey
|
health.needsKey
|
||||||
? "missing"
|
? t("health.keyMissing")
|
||||||
: health.keyAccepted === true
|
: health.keyAccepted === true
|
||||||
? "accepted"
|
? t("health.keyAccepted")
|
||||||
: health.keyAccepted === false
|
: health.keyAccepted === false
|
||||||
? "rejected"
|
? t("health.keyRejected")
|
||||||
: health.authMode === "local"
|
: health.authMode === "local"
|
||||||
? "not needed"
|
? t("health.keyNotNeeded")
|
||||||
: "unknown"
|
: t("health.keyUnknown")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<ModelRow title="Vision model (reads photos)" model={health.vision} />
|
<ModelRow title={t("health.visionModel")} model={health.vision} />
|
||||||
<ModelRow title="Text model (builds & grades)" model={health.text} />
|
<ModelRow title={t("health.textModel")} model={health.text} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{health.mode === "direct-cloud" && health.needsKey ? (
|
{health.mode === "direct-cloud" && health.needsKey ? (
|
||||||
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-frost-700 dark:text-frost-200">
|
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-frost-700 dark:text-frost-200">
|
||||||
Create a key at{" "}
|
{t("health.createKey.before")}{" "}
|
||||||
<a className="font-semibold underline underline-offset-2" href="https://ollama.com/settings/keys" target="_blank" rel="noreferrer">
|
<a className="font-semibold underline underline-offset-2" href="https://ollama.com/settings/keys" target="_blank" rel="noreferrer">
|
||||||
ollama.com/settings/keys
|
ollama.com/settings/keys
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
and paste it above. Tip: with the local daemon you can run <code>ollama signin</code> instead and set{" "}
|
{t("health.createKey.after")} <code>ollama signin</code> {t("health.createKey.end")}{" "}
|
||||||
<code>OLLAMA_AUTH=local</code>.
|
<code>OLLAMA_AUTH=local</code>.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -296,12 +324,13 @@ function ModelRow({
|
||||||
title: string;
|
title: string;
|
||||||
model: { configured: string; resolved: string; available: boolean | null; note?: string };
|
model: { configured: string; resolved: string; available: boolean | null; note?: string };
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const badge =
|
const badge =
|
||||||
model.available === true
|
model.available === true
|
||||||
? { text: "available", className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
|
? { text: t("health.available"), className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
|
||||||
: model.available === false
|
: model.available === false
|
||||||
? { text: "not found", className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
|
? { text: t("health.notFound"), className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
|
||||||
: { text: "unknown", className: "bg-[var(--surface-sunken)] text-frost-600 dark:text-frost-300" };
|
: { text: t("health.unknown"), className: "bg-[var(--surface-sunken)] text-frost-600 dark:text-frost-300" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||||
|
|
@ -313,7 +342,7 @@ function ModelRow({
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 font-mono text-sm font-semibold">{model.resolved}</p>
|
<p className="mt-1 font-mono text-sm font-semibold">{model.resolved}</p>
|
||||||
{model.configured !== model.resolved ? (
|
{model.configured !== model.resolved ? (
|
||||||
<p className="text-xs text-frost-500 dark:text-frost-400">configured as {model.configured}</p>
|
<p className="text-xs text-frost-500 dark:text-frost-400">{t("health.configuredAs", { model: model.configured })}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{model.note ? <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{model.note}</p> : null}
|
{model.note ? <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{model.note}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import { useTheme } from "@/components/ThemeToggle";
|
import { useTheme } from "@/components/ThemeToggle";
|
||||||
import { createFolder, folderStore } from "@/lib/folders";
|
import { createFolder, folderStore } from "@/lib/folders";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
|
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
|
||||||
import { UNORGANIZED_ID } from "@/lib/schemas";
|
import { UNORGANIZED_ID } from "@/lib/schemas";
|
||||||
import { deckStore } from "@/lib/storage";
|
import { deckStore } from "@/lib/storage";
|
||||||
|
|
@ -42,6 +43,7 @@ function NavContent({
|
||||||
);
|
);
|
||||||
const selected = useSelectedFolder();
|
const selected = useSelectedFolder();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
|
|
@ -106,7 +108,7 @@ function NavContent({
|
||||||
href="/"
|
href="/"
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className={cn("nav-row gap-2.5", collapsed ? "w-auto px-2" : "flex-1")}
|
className={cn("nav-row gap-2.5", collapsed ? "w-auto px-2" : "flex-1")}
|
||||||
aria-label="Freezmo home"
|
aria-label={t("header.home")}
|
||||||
title="Freezmo"
|
title="Freezmo"
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
|
||||||
|
|
@ -124,8 +126,8 @@ function NavContent({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => sidebarCollapse.set(true)}
|
onClick={() => sidebarCollapse.set(true)}
|
||||||
aria-label="Collapse sidebar"
|
aria-label={t("sidebar.collapse")}
|
||||||
title="Collapse sidebar"
|
title={t("sidebar.collapse")}
|
||||||
className="grid size-8 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="grid size-8 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="size-4" />
|
<ChevronLeftIcon className="size-4" />
|
||||||
|
|
@ -138,10 +140,10 @@ function NavContent({
|
||||||
href="/create"
|
href="/create"
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
||||||
title="New deck"
|
title={t("common.newDeck")}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4 shrink-0" />
|
<PlusIcon className="size-4 shrink-0" />
|
||||||
{!collapsed ? <span className="flex-1 truncate text-left">New deck</span> : null}
|
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.newDeck")}</span> : null}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* folders --------------------------------------------------------- */}
|
{/* folders --------------------------------------------------------- */}
|
||||||
|
|
@ -149,13 +151,13 @@ function NavContent({
|
||||||
{!desktop && !collapsed ? (
|
{!desktop && !collapsed ? (
|
||||||
<div className="flex items-center justify-between px-2 pb-1">
|
<div className="flex items-center justify-between px-2 pb-1">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||||
Folders
|
{t("common.folders")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCreating(true)}
|
onClick={() => setCreating(true)}
|
||||||
aria-label="New folder"
|
aria-label={t("common.newFolder")}
|
||||||
title="New folder"
|
title={t("common.newFolder")}
|
||||||
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
|
|
@ -164,15 +166,15 @@ function NavContent({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
aria-label={desktop ? "Library" : "Folders"}
|
aria-label={desktop ? t("sidebar.library") : t("common.folders")}
|
||||||
className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto"
|
className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto"
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => sidebarCollapse.set(false)}
|
onClick={() => sidebarCollapse.set(false)}
|
||||||
aria-label="Expand sidebar"
|
aria-label={t("sidebar.expand")}
|
||||||
title="Expand sidebar"
|
title={t("sidebar.expand")}
|
||||||
className="nav-row justify-center px-2"
|
className="nav-row justify-center px-2"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="size-4" />
|
<ChevronRightIcon className="size-4" />
|
||||||
|
|
@ -185,13 +187,13 @@ function NavContent({
|
||||||
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
|
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
|
||||||
{viewRow(
|
{viewRow(
|
||||||
ALL_FOLDERS,
|
ALL_FOLDERS,
|
||||||
"All decks",
|
t("common.allDecks"),
|
||||||
<LayersIcon className="size-4 shrink-0 opacity-70" />,
|
<LayersIcon className="size-4 shrink-0 opacity-70" />,
|
||||||
count(decks.length),
|
count(decks.length),
|
||||||
)}
|
)}
|
||||||
{viewRow(
|
{viewRow(
|
||||||
UNORGANIZED_ID,
|
UNORGANIZED_ID,
|
||||||
"Unorganized",
|
t("common.unorganized"),
|
||||||
<FolderIcon className="size-4 shrink-0 opacity-70" />,
|
<FolderIcon className="size-4 shrink-0 opacity-70" />,
|
||||||
null,
|
null,
|
||||||
)}
|
)}
|
||||||
|
|
@ -200,13 +202,13 @@ function NavContent({
|
||||||
<>
|
<>
|
||||||
{viewRow(
|
{viewRow(
|
||||||
ALL_FOLDERS,
|
ALL_FOLDERS,
|
||||||
"All decks",
|
t("common.allDecks"),
|
||||||
<LayersIcon className="size-4 shrink-0 opacity-70" />,
|
<LayersIcon className="size-4 shrink-0 opacity-70" />,
|
||||||
count(decks.length),
|
count(decks.length),
|
||||||
)}
|
)}
|
||||||
{viewRow(
|
{viewRow(
|
||||||
UNORGANIZED_ID,
|
UNORGANIZED_ID,
|
||||||
"Unorganized",
|
t("common.unorganized"),
|
||||||
<FolderIcon className="size-4 shrink-0 opacity-70" />,
|
<FolderIcon className="size-4 shrink-0 opacity-70" />,
|
||||||
count(counts[UNORGANIZED_ID] ?? 0),
|
count(counts[UNORGANIZED_ID] ?? 0),
|
||||||
)}
|
)}
|
||||||
|
|
@ -217,13 +219,13 @@ function NavContent({
|
||||||
{desktop && !collapsed ? (
|
{desktop && !collapsed ? (
|
||||||
<div className="flex items-center justify-between px-2 pb-1 pt-1">
|
<div className="flex items-center justify-between px-2 pb-1 pt-1">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||||
Folders
|
{t("common.folders")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCreating(true)}
|
onClick={() => setCreating(true)}
|
||||||
aria-label="New folder"
|
aria-label={t("common.newFolder")}
|
||||||
title="New folder"
|
title={t("common.newFolder")}
|
||||||
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
|
|
@ -261,13 +263,13 @@ function NavContent({
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Folder name"
|
placeholder={t("common.folderName")}
|
||||||
aria-label="New folder name"
|
aria-label={t("common.newFolderName")}
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-2.5 py-1.5 text-[13px] text-[var(--text)] outline-none focus:border-[var(--text-subtle)]"
|
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-2.5 py-1.5 text-[13px] text-[var(--text)] outline-none focus:border-[var(--text-subtle)]"
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={submit} className="btn-primary h-8 px-3 text-[12px]">
|
<button type="button" onClick={submit} className="btn-primary h-8 px-3 text-[12px]">
|
||||||
Add
|
{t("common.add")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -280,16 +282,16 @@ function NavContent({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openSettings}
|
onClick={openSettings}
|
||||||
title="Settings"
|
title={t("common.settings")}
|
||||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||||
>
|
>
|
||||||
<GearIcon className="size-4 shrink-0 opacity-70" />
|
<GearIcon className="size-4 shrink-0 opacity-70" />
|
||||||
{!collapsed ? <span className="flex-1 truncate text-left">Settings</span> : null}
|
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.settings")}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
title={theme === "dark" ? "Light mode" : "Dark mode"}
|
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
|
||||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? (
|
||||||
|
|
@ -298,7 +300,9 @@ function NavContent({
|
||||||
<MoonIcon className="size-4 shrink-0 opacity-70" />
|
<MoonIcon className="size-4 shrink-0 opacity-70" />
|
||||||
)}
|
)}
|
||||||
{!collapsed ? (
|
{!collapsed ? (
|
||||||
<span className="flex-1 truncate text-left">{theme === "dark" ? "Light mode" : "Dark mode"}</span>
|
<span className="flex-1 truncate text-left">
|
||||||
|
{theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -357,10 +361,11 @@ function useCollapsed(): boolean {
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const isCollapsed = useCollapsed();
|
const isCollapsed = useCollapsed();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="Sidebar"
|
aria-label={t("sidebar.label")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-y-0 left-0 z-40 hidden p-3 transition-[width] duration-200 lg:flex",
|
"fixed inset-y-0 left-0 z-40 hidden p-3 transition-[width] duration-200 lg:flex",
|
||||||
isCollapsed ? "w-[84px]" : "w-[268px]",
|
isCollapsed ? "w-[84px]" : "w-[268px]",
|
||||||
|
|
@ -376,6 +381,7 @@ export function Sidebar() {
|
||||||
|
|
||||||
export function MobileSidebar() {
|
export function MobileSidebar() {
|
||||||
const open = useSidebarOpen();
|
const open = useSidebarOpen();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
@ -388,12 +394,12 @@ export function MobileSidebar() {
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close menu"
|
aria-label={t("sidebar.closeMenu")}
|
||||||
onClick={() => sidebarOpen.set(false)}
|
onClick={() => sidebarOpen.set(false)}
|
||||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||||
/>
|
/>
|
||||||
<motion.aside
|
<motion.aside
|
||||||
aria-label="Menu"
|
aria-label={t("common.menu")}
|
||||||
initial={{ x: -280 }}
|
initial={{ x: -280 }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: -280 }}
|
exit={{ x: -280 }}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { ActivityRenderer } from "@/components/activities";
|
||||||
import { celebrate } from "@/lib/confetti";
|
import { celebrate } from "@/lib/confetti";
|
||||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||||
import { materialiseActivities } from "@/lib/deck-ops";
|
import { materialiseActivities } from "@/lib/deck-ops";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity, Deck } from "@/lib/schemas";
|
import type { Activity, Deck } from "@/lib/schemas";
|
||||||
import { recordAttempt, upsertDeck } from "@/lib/storage";
|
import { recordAttempt, upsertDeck } from "@/lib/storage";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -19,6 +20,7 @@ import { ResultsScreen } from "./ResultsScreen";
|
||||||
export type AttemptResult = { correct: boolean; userAnswer?: string };
|
export type AttemptResult = { correct: boolean; userAnswer?: string };
|
||||||
|
|
||||||
export function StudyFeed({ deck }: { deck: Deck }) {
|
export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [currentDeck, setCurrentDeck] = useState(deck);
|
const [currentDeck, setCurrentDeck] = useState(deck);
|
||||||
const [queue, setQueue] = useState<Activity[]>(deck.activities);
|
const [queue, setQueue] = useState<Activity[]>(deck.activities);
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
|
|
@ -168,7 +170,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
if (regenerating) return;
|
if (regenerating) return;
|
||||||
const source = currentDeck.sourceText?.trim();
|
const source = currentDeck.sourceText?.trim();
|
||||||
if (!source) {
|
if (!source) {
|
||||||
setRegenerateError("This deck has no stored source notes, so it can't be regenerated.");
|
setRegenerateError(t("deck.noSourceRegenerate"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRegenerating(true);
|
setRegenerating(true);
|
||||||
|
|
@ -196,11 +198,11 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
setCurrentDeck(updated);
|
setCurrentDeck(updated);
|
||||||
restart(activities, false);
|
restart(activities, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRegenerateError(error instanceof ApiError ? error.message : "Could not regenerate the deck.");
|
setRegenerateError(error instanceof ApiError ? error.message : t("deck.couldNotRegenerate"));
|
||||||
} finally {
|
} finally {
|
||||||
setRegenerating(false);
|
setRegenerating(false);
|
||||||
}
|
}
|
||||||
}, [currentDeck, regenerating, restart]);
|
}, [currentDeck, regenerating, restart, t]);
|
||||||
|
|
||||||
const progressValue = Math.min(index + 1, slideCount);
|
const progressValue = Math.min(index + 1, slideCount);
|
||||||
const progressMax = slideCount;
|
const progressMax = slideCount;
|
||||||
|
|
@ -213,7 +215,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
aria-label="Back to your library"
|
aria-label={t("feed.back")}
|
||||||
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||||||
>
|
>
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
|
|
@ -222,7 +224,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
value={progressValue}
|
value={progressValue}
|
||||||
max={progressMax}
|
max={progressMax}
|
||||||
label={`Activity ${progressValue} of ${progressMax}`}
|
label={t("feed.activityOf", { n: progressValue, total: progressMax })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
|
@ -233,16 +235,16 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
? "tint-peach"
|
? "tint-peach"
|
||||||
: "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
|
: "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
|
||||||
)}
|
)}
|
||||||
title={`Answer streak: ${streak}`}
|
title={t("feed.streak", { n: streak })}
|
||||||
aria-label={`Answer streak: ${streak}`}
|
aria-label={t("feed.streak", { n: streak })}
|
||||||
>
|
>
|
||||||
<FlameIcon className="size-3.5" />
|
<FlameIcon className="size-3.5" />
|
||||||
{streak}
|
{streak}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 rounded-xl bg-[var(--surface-sunken)] px-2 py-1 text-xs font-extrabold text-[var(--text-muted)]"
|
className="inline-flex items-center gap-1 rounded-xl bg-[var(--surface-sunken)] px-2 py-1 text-xs font-extrabold text-[var(--text-muted)]"
|
||||||
title={`XP earned: ${xp}`}
|
title={t("feed.xp", { n: xp })}
|
||||||
aria-label={`XP earned: ${xp}`}
|
aria-label={t("feed.xp", { n: xp })}
|
||||||
>
|
>
|
||||||
<BoltIcon className="size-3.5" />
|
<BoltIcon className="size-3.5" />
|
||||||
{xp}
|
{xp}
|
||||||
|
|
@ -252,7 +254,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
|
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
|
||||||
<span className="min-w-0 truncate">{currentDeck.title}</span>
|
<span className="min-w-0 truncate">{currentDeck.title}</span>
|
||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
{retryMode ? "Retry · " : ""}
|
{retryMode ? t("feed.retry") : ""}
|
||||||
{Math.min(index + 1, total)}/{total}
|
{Math.min(index + 1, total)}/{total}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -268,7 +270,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
{queue.map((activity, activityIndex) => (
|
{queue.map((activity, activityIndex) => (
|
||||||
<section
|
<section
|
||||||
key={`${activity.id}-${runId}`}
|
key={`${activity.id}-${runId}`}
|
||||||
aria-label={`Activity ${activityIndex + 1} of ${total}`}
|
aria-label={t("feed.activityOf", { n: activityIndex + 1, total })}
|
||||||
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
|
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||||
|
|
@ -285,7 +287,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<section
|
<section
|
||||||
aria-label="Results"
|
aria-label={t("feed.results")}
|
||||||
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
|
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||||
|
|
@ -318,7 +320,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
className="pointer-events-none absolute inset-x-0 bottom-6 z-20 flex justify-center sm:hidden"
|
className="pointer-events-none absolute inset-x-0 bottom-6 z-20 flex justify-center sm:hidden"
|
||||||
>
|
>
|
||||||
<span className="rounded-full bg-frost-950/70 px-4 py-2 text-xs font-semibold text-white backdrop-blur">
|
<span className="rounded-full bg-frost-950/70 px-4 py-2 text-xs font-semibold text-white backdrop-blur">
|
||||||
Swipe up for the next activity ↑
|
{t("feed.swipeUp")}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -327,13 +329,13 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
{/* Desktop next/prev buttons --------------------------------- */}
|
{/* Desktop next/prev buttons --------------------------------- */}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden justify-center pb-4 sm:flex">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden justify-center pb-4 sm:flex">
|
||||||
<div className="study-chrome pointer-events-auto flex items-center gap-2 rounded-full px-2 py-1.5">
|
<div className="study-chrome pointer-events-auto flex items-center gap-2 rounded-full px-2 py-1.5">
|
||||||
<Button variant="ghost" size="sm" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label="Previous activity">
|
<Button variant="ghost" size="sm" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label={t("feed.prev")}>
|
||||||
↑
|
↑
|
||||||
</Button>
|
</Button>
|
||||||
<span className="px-2 text-xs font-semibold text-[var(--text)]">
|
<span className="px-2 text-xs font-semibold text-[var(--text)]">
|
||||||
{index + 1} / {slideCount}
|
{index + 1} / {slideCount}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="ghost" size="sm" onClick={() => goTo(index + 1)} disabled={index >= slideCount - 1} aria-label="Next activity">
|
<Button variant="ghost" size="sm" onClick={() => goTo(index + 1)} disabled={index >= slideCount - 1} aria-label={t("feed.next")}>
|
||||||
↓
|
↓
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
|
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { openSettings } from "@/lib/ui-events";
|
import { openSettings } from "@/lib/ui-events";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ import { cn } from "@/lib/utils";
|
||||||
*/
|
*/
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const item = "flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
|
const item = "flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
|
||||||
const active = "text-[var(--text)]";
|
const active = "text-[var(--text)]";
|
||||||
|
|
@ -21,12 +23,12 @@ export function TabBar() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="Main"
|
aria-label={t("tabbar.main")}
|
||||||
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
|
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
|
||||||
>
|
>
|
||||||
<Link href="/" aria-current={pathname === "/" ? "page" : undefined} className={cn(item, pathname === "/" ? active : idle)}>
|
<Link href="/" aria-current={pathname === "/" ? "page" : undefined} className={cn(item, pathname === "/" ? active : idle)}>
|
||||||
<HomeIcon className="size-6" />
|
<HomeIcon className="size-6" />
|
||||||
Library
|
{t("common.library")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/create"
|
href="/create"
|
||||||
|
|
@ -36,11 +38,11 @@ export function TabBar() {
|
||||||
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
|
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
New deck
|
{t("common.newDeck")}
|
||||||
</Link>
|
</Link>
|
||||||
<button type="button" onClick={openSettings} className={cn(item, idle)}>
|
<button type="button" onClick={openSettings} className={cn(item, idle)}>
|
||||||
<GearIcon className="size-6" />
|
<GearIcon className="size-6" />
|
||||||
Settings
|
{t("common.settings")}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MoonIcon, SunIcon } from "@/components/icons";
|
import { MoonIcon, SunIcon } from "@/components/icons";
|
||||||
|
|
||||||
|
|
@ -33,14 +34,15 @@ export function useTheme() {
|
||||||
|
|
||||||
export function ThemeToggle({ className }: { className?: string }) {
|
export function ThemeToggle({ className }: { className?: string }) {
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
|
const { t } = useI18n();
|
||||||
const dark = theme === "dark";
|
const dark = theme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
aria-label={dark ? t("common.switchToLight") : t("common.switchToDark")}
|
||||||
title={dark ? "Light mode" : "Dark mode"}
|
title={dark ? t("common.lightMode") : t("common.darkMode")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
"inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
type PreparedImage,
|
type PreparedImage,
|
||||||
} from "@/lib/image";
|
} from "@/lib/image";
|
||||||
import { createFolder, folderStore } from "@/lib/folders";
|
import { createFolder, folderStore } from "@/lib/folders";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
||||||
import { upsertDeck } from "@/lib/storage";
|
import { upsertDeck } from "@/lib/storage";
|
||||||
import { openSettings } from "@/lib/ui-events";
|
import { openSettings } from "@/lib/ui-events";
|
||||||
|
|
@ -53,40 +54,38 @@ const MAX_IMAGES_TO_SEND = 12;
|
||||||
/** Cap for the merged text sent to the API. */
|
/** Cap for the merged text sent to the API. */
|
||||||
const MAX_TEXT_TO_SEND = 100_000;
|
const MAX_TEXT_TO_SEND = 100_000;
|
||||||
|
|
||||||
function describeExtraction(document: ExtractedDocument): string {
|
function describeExtraction(
|
||||||
|
document: ExtractedDocument,
|
||||||
|
tr: (key: string, vars?: Record<string, string | number>) => string,
|
||||||
|
pl: (n: number, base: string, vars?: Record<string, string | number>) => string,
|
||||||
|
): string {
|
||||||
const parts = [
|
const parts = [
|
||||||
`${document.words.toLocaleString()} word${document.words === 1 ? "" : "s"}`,
|
pl(document.words, "upload.words", { n: document.words.toLocaleString() }),
|
||||||
document.pages ? `${document.pages} page${document.pages === 1 ? "" : "s"}` : "",
|
document.pages ? pl(document.pages, "upload.pages") : "",
|
||||||
document.images.length
|
document.images.length ? pl(document.images.length, "upload.pageImages") : "",
|
||||||
? `${document.images.length} page image${document.images.length === 1 ? "" : "s"}`
|
document.truncated ? tr("upload.truncated") : "",
|
||||||
: "",
|
|
||||||
document.truncated ? "truncated" : "",
|
|
||||||
];
|
];
|
||||||
return parts.filter(Boolean).join(" · ");
|
return parts.filter(Boolean).join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIFFICULTIES: Array<{ value: Difficulty; label: string; icon: React.ReactNode; blurb: string }> = [
|
const DIFFICULTIES: Array<{ value: Difficulty; labelKey: string; icon: React.ReactNode; blurbKey: string }> = [
|
||||||
|
{ value: "easy", labelKey: "upload.easy", icon: <SunIcon className="size-5" />, blurbKey: "upload.easyBlurb" },
|
||||||
{
|
{
|
||||||
value: "easy",
|
value: "medium",
|
||||||
label: "Easy",
|
labelKey: "upload.medium",
|
||||||
icon: <SunIcon className="size-5" />,
|
icon: <SnowflakeIcon className="size-5" />,
|
||||||
blurb: "Recall & recognition",
|
blurbKey: "upload.mediumBlurb",
|
||||||
},
|
},
|
||||||
{ value: "medium", label: "Medium", icon: <SnowflakeIcon className="size-5" />, blurb: "Understanding & application" },
|
{ value: "hard", labelKey: "upload.hard", icon: <IceCubeIcon className="size-5" />, blurbKey: "upload.hardBlurb" },
|
||||||
{ value: "hard", label: "Hard", icon: <IceCubeIcon className="size-5" />, blurb: "Analysis & tricky edges" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const COUNT_OPTIONS = [8, 10, 12, 15];
|
const COUNT_OPTIONS = [8, 10, 12, 15];
|
||||||
|
|
||||||
const GENERATION_TIPS = [
|
const GENERATION_TIPS = ["upload.tip1", "upload.tip2", "upload.tip3", "upload.tip4"];
|
||||||
"Tip: swipe up and down through your deck like a feed.",
|
|
||||||
"Tip: matching questions only give full XP for a perfect match.",
|
|
||||||
"Tip: your decks are saved in this browser — no account needed.",
|
|
||||||
"Tip: export a deck as JSON to share it with a friend.",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function UploadScreen() {
|
export function UploadScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t, plural } = useI18n();
|
||||||
const [images, setImages] = useState<PreparedImage[]>([]);
|
const [images, setImages] = useState<PreparedImage[]>([]);
|
||||||
const [documents, setDocuments] = useState<DocItem[]>([]);
|
const [documents, setDocuments] = useState<DocItem[]>([]);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
|
|
@ -100,7 +99,7 @@ export function UploadScreen() {
|
||||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||||
const [fileError, setFileError] = useState<string | null>(null);
|
const [fileError, setFileError] = useState<string | null>(null);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "Warming up…" });
|
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "" });
|
||||||
|
|
||||||
const folders = useSyncExternalStore(
|
const folders = useSyncExternalStore(
|
||||||
folderStore.subscribe,
|
folderStore.subscribe,
|
||||||
|
|
@ -149,15 +148,13 @@ export function UploadScreen() {
|
||||||
if (problem) problems.push(problem);
|
if (problem) problems.push(problem);
|
||||||
else documentFiles.push(file);
|
else documentFiles.push(file);
|
||||||
} else {
|
} else {
|
||||||
problems.push(
|
problems.push(t("upload.unsupported", { name: file.name }));
|
||||||
`"${file.name}" is not a supported format. Add photos or a PDF, DOCX, PPTX, ODT/ODP, TXT or MD file.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentRoom = Math.max(0, MAX_DOCUMENTS - documentsRef.current.length);
|
const documentRoom = Math.max(0, MAX_DOCUMENTS - documentsRef.current.length);
|
||||||
const acceptedDocuments = documentFiles.slice(0, documentRoom);
|
const acceptedDocuments = documentFiles.slice(0, documentRoom);
|
||||||
if (documentFiles.length > documentRoom) problems.push(`Up to ${MAX_DOCUMENTS} documents per deck.`);
|
if (documentFiles.length > documentRoom) problems.push(t("upload.tooManyDocs", { n: MAX_DOCUMENTS }));
|
||||||
|
|
||||||
// Documents are read asynchronously (pdf.js can take a while for scans).
|
// Documents are read asynchronously (pdf.js can take a while for scans).
|
||||||
if (acceptedDocuments.length > 0) {
|
if (acceptedDocuments.length > 0) {
|
||||||
|
|
@ -179,7 +176,7 @@ export function UploadScreen() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
update({
|
update({
|
||||||
status: "error",
|
status: "error",
|
||||||
error: error instanceof Error ? error.message : "Could not read this file.",
|
error: error instanceof Error ? error.message : t("upload.couldNotReadDoc"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -187,14 +184,14 @@ export function UploadScreen() {
|
||||||
|
|
||||||
const photoRoom = Math.max(0, MAX_IMAGES - imagesRef.current.length);
|
const photoRoom = Math.max(0, MAX_IMAGES - imagesRef.current.length);
|
||||||
const acceptedImages = imageFiles.slice(0, photoRoom);
|
const acceptedImages = imageFiles.slice(0, photoRoom);
|
||||||
if (imageFiles.length > photoRoom) problems.push(`Up to ${MAX_IMAGES} photos per deck.`);
|
if (imageFiles.length > photoRoom) problems.push(t("upload.tooManyPhotos", { n: MAX_IMAGES }));
|
||||||
|
|
||||||
const prepared: PreparedImage[] = [];
|
const prepared: PreparedImage[] = [];
|
||||||
for (const file of acceptedImages) {
|
for (const file of acceptedImages) {
|
||||||
try {
|
try {
|
||||||
prepared.push(await prepareImage(file));
|
prepared.push(await prepareImage(file));
|
||||||
} catch {
|
} catch {
|
||||||
problems.push(`Could not read "${file.name}".`);
|
problems.push(t("upload.couldNotReadFile", { name: file.name }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (prepared.length > 0) {
|
if (prepared.length > 0) {
|
||||||
|
|
@ -204,7 +201,7 @@ export function UploadScreen() {
|
||||||
|
|
||||||
if (problems.length) setFileError(problems.slice(0, 3).join(" "));
|
if (problems.length) setFileError(problems.slice(0, 3).join(" "));
|
||||||
setPhase("idle");
|
setPhase("idle");
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
// Clipboard paste (Ctrl/⌘ + V) of screenshots or copied photos.
|
// Clipboard paste (Ctrl/⌘ + V) of screenshots or copied photos.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -261,7 +258,7 @@ export function UploadScreen() {
|
||||||
setPhase("loading");
|
setPhase("loading");
|
||||||
setProgress({
|
setProgress({
|
||||||
stage: imagesToSend.length ? "vision" : "generation",
|
stage: imagesToSend.length ? "vision" : "generation",
|
||||||
message: imagesToSend.length ? "Reading your notes…" : "Finding the key ideas…",
|
message: imagesToSend.length ? t("upload.vision1") : t("upload.gen1"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
@ -297,7 +294,7 @@ export function UploadScreen() {
|
||||||
message:
|
message:
|
||||||
generateError instanceof ApiError
|
generateError instanceof ApiError
|
||||||
? generateError.message
|
? generateError.message
|
||||||
: "Something went wrong while generating your deck. Check your Ollama settings and try again.",
|
: t("upload.genericError"),
|
||||||
kind: generateError instanceof ApiError ? generateError.kind : undefined,
|
kind: generateError instanceof ApiError ? generateError.kind : undefined,
|
||||||
});
|
});
|
||||||
setPhase("error");
|
setPhase("error");
|
||||||
|
|
@ -316,11 +313,10 @@ export function UploadScreen() {
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]">
|
<h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]">
|
||||||
<SnowflakeIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
|
<SnowflakeIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
|
||||||
<span>Turn your notes into a study feed</span>
|
<span>{t("upload.title")}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">
|
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">
|
||||||
Snap or drop photos of your notes. Freezmo reads them with your own Ollama vision model
|
{t("upload.intro")}
|
||||||
and builds a swipeable deck of mini study games.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -332,22 +328,22 @@ export function UploadScreen() {
|
||||||
>
|
>
|
||||||
<p className="font-bold">
|
<p className="font-bold">
|
||||||
{error.kind === "needs_key"
|
{error.kind === "needs_key"
|
||||||
? "API key required"
|
? t("upload.errNeedsKey")
|
||||||
: error.kind === "rate_limited"
|
: error.kind === "rate_limited"
|
||||||
? "Slow down a moment"
|
? t("upload.errRate")
|
||||||
: error.kind === "auth"
|
: error.kind === "auth"
|
||||||
? "Ollama rejected your key"
|
? t("upload.errAuth")
|
||||||
: "Generation failed"}
|
: t("upload.errFailed")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1">{error.message}</p>
|
<p className="mt-1">{error.message}</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{error.kind === "needs_key" || error.kind === "auth" ? (
|
{error.kind === "needs_key" || error.kind === "auth" ? (
|
||||||
<Button size="sm" onClick={openSettings}>
|
<Button size="sm" onClick={openSettings}>
|
||||||
Open settings
|
{t("upload.openSettings")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button size="sm" variant={error.kind === "needs_key" || error.kind === "auth" ? "secondary" : "primary"} onClick={() => void generate()}>
|
<Button size="sm" variant={error.kind === "needs_key" || error.kind === "auth" ? "secondary" : "primary"} onClick={() => void generate()}>
|
||||||
Retry
|
{t("common.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -355,7 +351,7 @@ export function UploadScreen() {
|
||||||
|
|
||||||
{/* ---------------------------------------------------------- */}
|
{/* ---------------------------------------------------------- */}
|
||||||
<section
|
<section
|
||||||
aria-label="Add photos of your notes"
|
aria-label={t("upload.addPhotosAria")}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
|
|
@ -373,19 +369,17 @@ export function UploadScreen() {
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">1. Add your notes</h2>
|
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("upload.step1")}</h2>
|
||||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||||
Drag & drop, paste a screenshot (Ctrl/⌘+V), or use the camera. Photos, PDF, Word,
|
{t("upload.step1Help", { images: MAX_IMAGES, documents: MAX_DOCUMENTS })}
|
||||||
PowerPoint, OpenDocument and text files. Up to {MAX_IMAGES} photos and {MAX_DOCUMENTS}{" "}
|
|
||||||
documents.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||||
<FolderIcon className="size-4" /> Choose files
|
<FolderIcon className="size-4" /> {t("upload.chooseFiles")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => cameraInputRef.current?.click()}>
|
<Button variant="secondary" size="sm" onClick={() => cameraInputRef.current?.click()}>
|
||||||
<CameraIcon className="size-4" /> Camera
|
<CameraIcon className="size-4" /> {t("upload.camera")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -423,10 +417,10 @@ export function UploadScreen() {
|
||||||
<SnowflakeIcon className="size-12" />
|
<SnowflakeIcon className="size-12" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[14px] font-medium text-[var(--text)]">
|
<span className="text-[14px] font-medium text-[var(--text)]">
|
||||||
Drop your notes here
|
{t("upload.dropHere")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[12.5px] text-[var(--text-subtle)]">
|
<span className="text-[12.5px] text-[var(--text-subtle)]">
|
||||||
Photos, PDF, DOCX, PPTX, ODT/ODP, TXT, MD · images resized to 1600px before sending
|
{t("upload.formats")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -442,7 +436,7 @@ export function UploadScreen() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeImage(image.id)}
|
onClick={() => removeImage(image.id)}
|
||||||
aria-label={`Remove ${image.name}`}
|
aria-label={t("upload.remove", { name: image.name })}
|
||||||
className="absolute right-1 top-1 grid size-7 place-items-center rounded-full bg-frost-950/70 text-xs font-bold text-white transition hover:bg-rose-600"
|
className="absolute right-1 top-1 grid size-7 place-items-center rounded-full bg-frost-950/70 text-xs font-bold text-white transition hover:bg-rose-600"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|
@ -458,7 +452,7 @@ export function UploadScreen() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="grid aspect-square w-full place-items-center rounded-2xl border-2 border-dashed border-white/70 text-2xl text-accent-500 backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/60 dark:border-white/15 dark:text-accent-200"
|
className="grid aspect-square w-full place-items-center rounded-2xl border-2 border-dashed border-white/70 text-2xl text-accent-500 backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/60 dark:border-white/15 dark:text-accent-200"
|
||||||
aria-label="Add more photos"
|
aria-label={t("upload.addMorePhotos")}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -483,7 +477,7 @@ export function UploadScreen() {
|
||||||
</p>
|
</p>
|
||||||
{entry.status === "extracting" ? (
|
{entry.status === "extracting" ? (
|
||||||
<p className="text-xs font-medium text-frost-700 dark:text-frost-200">
|
<p className="text-xs font-medium text-frost-700 dark:text-frost-200">
|
||||||
{entry.progress ?? "Reading…"}
|
{entry.progress ?? t("upload.reading")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{entry.status === "error" ? (
|
{entry.status === "error" ? (
|
||||||
|
|
@ -492,11 +486,11 @@ export function UploadScreen() {
|
||||||
{entry.status === "ready" && entry.extracted ? (
|
{entry.status === "ready" && entry.extracted ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||||
{describeExtraction(entry.extracted)}
|
{describeExtraction(entry.extracted, t, plural)}
|
||||||
</p>
|
</p>
|
||||||
{entry.extracted.words === 0 && entry.extracted.images.length === 0 ? (
|
{entry.extracted.words === 0 && entry.extracted.images.length === 0 ? (
|
||||||
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||||
No readable text — is this a scan without a text layer?
|
{t("upload.noReadable")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{entry.extracted.warnings.map((warning) => (
|
{entry.extracted.warnings.map((warning) => (
|
||||||
|
|
@ -510,7 +504,7 @@ export function UploadScreen() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeDocument(entry.id)}
|
onClick={() => removeDocument(entry.id)}
|
||||||
aria-label={`Remove ${entry.name}`}
|
aria-label={t("upload.remove", { name: entry.name })}
|
||||||
className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-950/10 text-xs font-bold text-frost-900 transition hover:bg-rose-500 hover:text-white dark:bg-white/10 dark:text-frost-50"
|
className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-950/10 text-xs font-bold text-frost-900 transition hover:bg-rose-500 hover:text-white dark:bg-white/10 dark:text-frost-50"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|
@ -523,22 +517,24 @@ export function UploadScreen() {
|
||||||
{images.length > 0 || documents.length > 0 ? (
|
{images.length > 0 || documents.length > 0 ? (
|
||||||
<p className="mt-2 text-[12.5px] text-[var(--text-subtle)]">
|
<p className="mt-2 text-[12.5px] text-[var(--text-subtle)]">
|
||||||
{images.length > 0
|
{images.length > 0
|
||||||
? `${images.length} photo${images.length === 1 ? "" : "s"} · ${formatBytes(totalBytes)} after compression`
|
? plural(images.length, "upload.photoSummary", { size: formatBytes(totalBytes) })
|
||||||
: ""}
|
: ""}
|
||||||
{images.length > 0 && documents.length > 0 ? " · " : ""}
|
{images.length > 0 && documents.length > 0 ? " · " : ""}
|
||||||
{documents.length > 0
|
{documents.length > 0
|
||||||
? `${documents.length} document${documents.length === 1 ? "" : "s"}${
|
? `${plural(documents.length, "upload.docSummary")}${
|
||||||
documentImages.length ? ` (${documentImages.length} page image${documentImages.length === 1 ? "" : "s"})` : ""
|
documentImages.length
|
||||||
|
? ` (${plural(documentImages.length, "upload.docImages")})`
|
||||||
|
: ""
|
||||||
}`
|
}`
|
||||||
: ""}
|
: ""}
|
||||||
{imagesSkipped > 0
|
{imagesSkipped > 0
|
||||||
? ` · ${imagesSkipped} image${imagesSkipped === 1 ? "" : "s"} will be skipped (max ${MAX_IMAGES_TO_SEND} per deck)`
|
? ` · ${plural(imagesSkipped, "upload.skipImages", { max: MAX_IMAGES_TO_SEND })}`
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{phase === "preparing" ? (
|
{phase === "preparing" ? (
|
||||||
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">Compressing photos…</p>
|
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{fileError ? (
|
{fileError ? (
|
||||||
<p role="alert" className="mt-2 text-xs font-semibold text-rose-600 dark:text-rose-300">
|
<p role="alert" className="mt-2 text-xs font-semibold text-rose-600 dark:text-rose-300">
|
||||||
|
|
@ -550,35 +546,35 @@ export function UploadScreen() {
|
||||||
{/* ---------------------------------------------------------- */}
|
{/* ---------------------------------------------------------- */}
|
||||||
<section className="card space-y-4 p-4 sm:p-5">
|
<section className="card space-y-4 p-4 sm:p-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">2. Optional extras</h2>
|
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("upload.step2")}</h2>
|
||||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||||
Paste extra notes or give the AI a hint about what to focus on.
|
{t("upload.step2Help")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="notes-text" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
<label htmlFor="notes-text" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||||
Pasted notes or text
|
{t("upload.pastedNotes")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="notes-text"
|
id="notes-text"
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(event) => setText(event.target.value)}
|
onChange={(event) => setText(event.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="Paste lecture text, a summary, or any extra facts here…"
|
placeholder={t("upload.pastedPlaceholder")}
|
||||||
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="focus" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
<label htmlFor="focus" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||||
Topic hint (optional)
|
{t("upload.topicHint")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="focus"
|
id="focus"
|
||||||
value={focus}
|
value={focus}
|
||||||
onChange={(event) => setFocus(event.target.value)}
|
onChange={(event) => setFocus(event.target.value)}
|
||||||
placeholder="e.g. focus on the formulas, or on dates"
|
placeholder={t("upload.topicPlaceholder")}
|
||||||
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -586,14 +582,14 @@ export function UploadScreen() {
|
||||||
|
|
||||||
{/* ---------------------------------------------------------- */}
|
{/* ---------------------------------------------------------- */}
|
||||||
<section className="card space-y-4 p-4 sm:p-5">
|
<section className="card space-y-4 p-4 sm:p-5">
|
||||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">3. Difficulty & size</h2>
|
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("upload.step3")}</h2>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||||
Freeze into folder
|
{t("upload.freezeInto")}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...sortedFolders].map((folder) => {
|
{[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...sortedFolders].map((folder) => {
|
||||||
const active = folderId === folder.id;
|
const active = folderId === folder.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -636,8 +632,8 @@ export function UploadScreen() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="New folder name"
|
aria-label={t("common.newFolderName")}
|
||||||
placeholder="e.g. History"
|
placeholder={t("upload.folderPlaceholder")}
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none"
|
className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none"
|
||||||
/>
|
/>
|
||||||
|
|
@ -654,7 +650,7 @@ export function UploadScreen() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add
|
{t("common.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -663,13 +659,13 @@ export function UploadScreen() {
|
||||||
onClick={() => setCreatingFolder(true)}
|
onClick={() => setCreatingFolder(true)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl border border-dashed border-[var(--hairline)] px-3 py-1.5 text-sm font-semibold text-frost-500 transition-colors hover:text-accent-700 dark:text-frost-400 dark:hover:text-accent-200"
|
className="inline-flex items-center gap-1.5 rounded-xl border border-dashed border-[var(--hairline)] px-3 py-1.5 text-sm font-semibold text-frost-500 transition-colors hover:text-accent-700 dark:text-frost-400 dark:hover:text-accent-200"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" /> New folder
|
<PlusIcon className="size-4" /> {t("common.newFolder")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div role="radiogroup" aria-label="Difficulty" className="grid grid-cols-3 gap-2">
|
<div role="radiogroup" aria-label={t("upload.difficultyAria")} className="grid grid-cols-3 gap-2">
|
||||||
{DIFFICULTIES.map((option) => {
|
{DIFFICULTIES.map((option) => {
|
||||||
const selected = difficulty === option.value;
|
const selected = difficulty === option.value;
|
||||||
return (
|
return (
|
||||||
|
|
@ -689,8 +685,8 @@ export function UploadScreen() {
|
||||||
<span aria-hidden className="text-frost-600 dark:text-frost-200">
|
<span aria-hidden className="text-frost-600 dark:text-frost-200">
|
||||||
{option.icon}
|
{option.icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-bold text-[var(--text)]">{option.label}</span>
|
<span className="text-sm font-bold text-[var(--text)]">{t(option.labelKey)}</span>
|
||||||
<span className="hidden text-[11px] text-frost-500 sm:block dark:text-frost-400">{option.blurb}</span>
|
<span className="hidden text-[11px] text-frost-500 sm:block dark:text-frost-400">{t(option.blurbKey)}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -698,7 +694,7 @@ export function UploadScreen() {
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||||
Activities
|
{t("upload.activities")}
|
||||||
</span>
|
</span>
|
||||||
{COUNT_OPTIONS.map((option) => (
|
{COUNT_OPTIONS.map((option) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -723,14 +719,14 @@ export function UploadScreen() {
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||||
{documentsBusy
|
{documentsBusy
|
||||||
? "Reading your documents…"
|
? t("upload.readingDocs")
|
||||||
: hasContent
|
: hasContent
|
||||||
? "Ready when you are — generation can take a minute."
|
? t("upload.ready")
|
||||||
: "Add a photo or a document (or some text) to start."}
|
: t("upload.addToStart")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="lg" onClick={() => void generate()} disabled={!canGenerate}>
|
<Button size="lg" onClick={() => void generate()} disabled={!canGenerate}>
|
||||||
<SnowflakeIcon className="size-5" /> Freeze my deck
|
<SnowflakeIcon className="size-5" /> {t("upload.freezeDeck")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -744,12 +740,13 @@ export function UploadScreen() {
|
||||||
/* Loading state */
|
/* Loading state */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; messages: string[] }> = [
|
const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; keys: string[] }> = [
|
||||||
{ stage: "vision", messages: ["Reading your notes…", "Transcribing handwriting…", "Spotting definitions…"] },
|
{ stage: "vision", keys: ["upload.vision1", "upload.vision2", "upload.vision3"] },
|
||||||
{ stage: "generation", messages: ["Finding the key ideas…", "Writing good questions…", "Building your activities…"] },
|
{ stage: "generation", keys: ["upload.gen1", "upload.gen2", "upload.gen3"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) {
|
function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [tick, setTick] = useState(0);
|
const [tick, setTick] = useState(0);
|
||||||
const [tip, setTip] = useState(0);
|
const [tip, setTip] = useState(0);
|
||||||
|
|
||||||
|
|
@ -763,8 +760,8 @@ function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCa
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const activeIndex = progress.stage === "vision" ? 0 : 1;
|
const activeIndex = progress.stage === "vision" ? 0 : 1;
|
||||||
const messages = STAGE_STEPS[activeIndex].messages;
|
const keys = STAGE_STEPS[activeIndex].keys;
|
||||||
const message = progress.message && tick === 0 ? progress.message : messages[tick % messages.length];
|
const message = progress.message && tick === 0 ? progress.message : t(keys[tick % keys.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-[70dvh] flex-col items-center justify-center gap-6 text-center">
|
<div className="relative flex min-h-[70dvh] flex-col items-center justify-center gap-6 text-center">
|
||||||
|
|
@ -781,20 +778,20 @@ function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCa
|
||||||
<p className="text-xl font-extrabold text-[var(--text)]" aria-live="polite">
|
<p className="text-xl font-extrabold text-[var(--text)]" aria-live="polite">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
<ProgressBar value={activeIndex + 1} max={2} label="Generation progress" />
|
<ProgressBar value={activeIndex + 1} max={2} label={t("upload.generationProgress")} />
|
||||||
<ol className="space-y-1 text-left text-[14.5px] leading-relaxed text-[var(--text-muted)]">
|
<ol className="space-y-1 text-left text-[14.5px] leading-relaxed text-[var(--text-muted)]">
|
||||||
{STAGE_STEPS.map((step, index) => (
|
{STAGE_STEPS.map((step, index) => (
|
||||||
<li key={step.stage} className="flex items-center gap-2">
|
<li key={step.stage} className="flex items-center gap-2">
|
||||||
<span aria-hidden>
|
<span aria-hidden>
|
||||||
{index < activeIndex ? "✅" : index === activeIndex ? "🌀" : "⬜"}
|
{index < activeIndex ? "✅" : index === activeIndex ? "🌀" : "⬜"}
|
||||||
</span>
|
</span>
|
||||||
{step.messages[0]}
|
{t(step.keys[0])}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
<p className="pt-2 text-[12.5px] text-[var(--text-subtle)]">{GENERATION_TIPS[tip]}</p>
|
<p className="pt-2 text-[12.5px] text-[var(--text-subtle)]">{t(GENERATION_TIPS[tip])}</p>
|
||||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||||
Cancel
|
{t("upload.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity } from "@/lib/schemas";
|
import type { Activity } from "@/lib/schemas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
|
@ -67,6 +68,7 @@ export function isFillBlankCorrect(userValue: string, answer: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FillBlank({ activity, active, onAnswered, onNext, isLast }: ActivityComponentProps<FillBlankActivity>) {
|
export function FillBlank({ activity, active, onAnswered, onNext, isLast }: ActivityComponentProps<FillBlankActivity>) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [chosen, setChosen] = useState<string | null>(null);
|
const [chosen, setChosen] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<boolean | null>(null);
|
const [result, setResult] = useState<boolean | null>(null);
|
||||||
|
|
@ -121,8 +123,8 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}}
|
}}
|
||||||
disabled={result !== null}
|
disabled={result !== null}
|
||||||
aria-label="Type the missing word"
|
aria-label={t("fill.typeMissing")}
|
||||||
placeholder="type it"
|
placeholder={t("fill.placeholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
|
@ -142,7 +144,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
|
||||||
</PromptCard>
|
</PromptCard>
|
||||||
|
|
||||||
{activity.options ? (
|
{activity.options ? (
|
||||||
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label="Word bank">
|
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label={t("activity.wordBank")}>
|
||||||
{activity.options.map((option) => {
|
{activity.options.map((option) => {
|
||||||
const state =
|
const state =
|
||||||
result === null
|
result === null
|
||||||
|
|
@ -168,13 +170,13 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
|
||||||
) : result === null ? (
|
) : result === null ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="lg" onClick={() => submit(value)} disabled={!value.trim()} className="flex-1">
|
<Button size="lg" onClick={() => submit(value)} disabled={!value.trim()} className="flex-1">
|
||||||
Check answer
|
{t("fill.check")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{result === null && !activity.options ? (
|
{result === null && !activity.options ? (
|
||||||
<Hint>Press Enter to check. Small typos are forgiven.</Hint>
|
<Hint>{t("fill.hint")}</Hint>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{result !== null ? (
|
{result !== null ? (
|
||||||
|
|
@ -186,11 +188,11 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
detail={
|
detail={
|
||||||
<span>
|
<span>
|
||||||
Expected: <span className="font-bold">{activity.answer}</span>
|
{t("fill.expected")} <span className="font-bold">{activity.answer}</span>
|
||||||
{!result && chosen ? (
|
{!result && chosen ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
— you wrote <span className="italic">{chosen}</span>
|
{t("fill.youWrote")} <span className="italic">{chosen}</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity } from "@/lib/schemas";
|
import type { Activity } from "@/lib/schemas";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ import {
|
||||||
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
|
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
|
||||||
|
|
||||||
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<FlashcardActivity>) {
|
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<FlashcardActivity>) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [flipped, setFlipped] = useState(false);
|
const [flipped, setFlipped] = useState(false);
|
||||||
const [result, setResult] = useState<boolean | null>(null);
|
const [result, setResult] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
|
@ -30,13 +32,13 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ActivityHeader type="flashcard" topic={activity.topic} />
|
<ActivityHeader type="flashcard" topic={activity.topic} />
|
||||||
<Hint>{flipped ? "Did you know it before flipping?" : "Tap the card to flip it."}</Hint>
|
<Hint>{flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}</Hint>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFlipped((value) => !value)}
|
onClick={() => setFlipped((value) => !value)}
|
||||||
className="group relative block w-full text-left [perspective:1400px]"
|
className="group relative block w-full text-left [perspective:1400px]"
|
||||||
aria-label={flipped ? "Flip back to the question" : "Reveal the answer"}
|
aria-label={flipped ? t("flashcard.flipBack") : t("flashcard.reveal")}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotateX: flipped ? 180 : 0 }}
|
animate={{ rotateX: flipped ? 180 : 0 }}
|
||||||
|
|
@ -45,18 +47,18 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
|
||||||
>
|
>
|
||||||
<PromptCard className="absolute inset-0 flex flex-col justify-center gap-3 group-hover:border-frost-300 [backface-visibility:hidden]">
|
<PromptCard className="absolute inset-0 flex flex-col justify-center gap-3 group-hover:border-frost-300 [backface-visibility:hidden]">
|
||||||
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
|
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
|
||||||
Question
|
{t("flashcard.question")}
|
||||||
</span>
|
</span>
|
||||||
<QuestionText>{activity.front}</QuestionText>
|
<QuestionText>{activity.front}</QuestionText>
|
||||||
<span className="mt-2 text-xs font-semibold text-frost-400 dark:text-frost-400">
|
<span className="mt-2 text-xs font-semibold text-frost-400 dark:text-frost-400">
|
||||||
tap to flip ↻
|
{t("flashcard.tapFlip")}
|
||||||
</span>
|
</span>
|
||||||
</PromptCard>
|
</PromptCard>
|
||||||
|
|
||||||
<div className="[transform:rotateX(180deg)] [backface-visibility:hidden]">
|
<div className="[transform:rotateX(180deg)] [backface-visibility:hidden]">
|
||||||
<div className="card flex min-h-56 flex-col justify-center gap-3 px-5 py-6 sm:px-6">
|
<div className="card flex min-h-56 flex-col justify-center gap-3 px-5 py-6 sm:px-6">
|
||||||
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
|
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
|
||||||
Answer
|
{t("flashcard.answer")}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-lg font-semibold leading-relaxed text-frost-950 dark:text-frost-50">
|
<p className="text-lg font-semibold leading-relaxed text-frost-950 dark:text-frost-50">
|
||||||
{activity.back}
|
{activity.back}
|
||||||
|
|
@ -69,10 +71,10 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
|
||||||
{flipped && result === null ? (
|
{flipped && result === null ? (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Button variant="secondary" size="lg" onClick={() => settle(false)}>
|
<Button variant="secondary" size="lg" onClick={() => settle(false)}>
|
||||||
😅 Not yet
|
{t("flashcard.notYet")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="success" size="lg" onClick={() => settle(true)}>
|
<Button variant="success" size="lg" onClick={() => settle(true)}>
|
||||||
🧊 I knew it
|
{t("flashcard.knewIt")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -83,7 +85,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
|
||||||
explanation={activity.explanation}
|
explanation={activity.explanation}
|
||||||
xp={activity.xp}
|
xp={activity.xp}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
headline={result ? "Frozen solid!" : "Keep it on the pile"}
|
headline={result ? t("flashcard.frozenSolid") : t("flashcard.keepPile")}
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity } from "@/lib/schemas";
|
import type { Activity } from "@/lib/schemas";
|
||||||
import { cn, shuffle } from "@/lib/utils";
|
import { cn, shuffle } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ import {
|
||||||
type MatchingActivity = Extract<Activity, { type: "matching" }>;
|
type MatchingActivity = Extract<Activity, { type: "matching" }>;
|
||||||
|
|
||||||
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<MatchingActivity>) {
|
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<MatchingActivity>) {
|
||||||
|
const { t, plural } = useI18n();
|
||||||
const terms = useMemo(
|
const terms = useMemo(
|
||||||
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
|
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
|
||||||
[activity],
|
[activity],
|
||||||
|
|
@ -60,7 +62,7 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
||||||
topic={activity.topic}
|
topic={activity.topic}
|
||||||
extra={
|
extra={
|
||||||
<span className="rounded-full bg-[var(--surface-strong)] px-3 py-1 text-xs font-bold text-frost-600 dark:text-frost-200">
|
<span className="rounded-full bg-[var(--surface-strong)] px-3 py-1 text-xs font-bold text-frost-600 dark:text-frost-200">
|
||||||
{matched.length}/{total} matched
|
{t("matching.matched", { n: matched.length, total })}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -68,10 +70,10 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
||||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||||
<Hint>
|
<Hint>
|
||||||
{done
|
{done
|
||||||
? "All matched!"
|
? t("matching.allMatched")
|
||||||
: selectedTerm === null
|
: selectedTerm === null
|
||||||
? "Tap a term on the left, then its match on the right."
|
? t("matching.tapTerm")
|
||||||
: "Now tap the matching definition →"}
|
: t("matching.tapDef")}
|
||||||
</Hint>
|
</Hint>
|
||||||
</PromptCard>
|
</PromptCard>
|
||||||
|
|
||||||
|
|
@ -136,12 +138,10 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
||||||
xp={activity.xp}
|
xp={activity.xp}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
headline={misses === 0 ? "Perfect match!" : "Matched — with a few slips"}
|
headline={misses === 0 ? t("matching.perfect") : t("matching.slips")}
|
||||||
detail={
|
detail={
|
||||||
<span>
|
<span>
|
||||||
{misses === 0
|
{misses === 0 ? t("matching.firstTry") : plural(misses, "matching.wrongAttempts")}
|
||||||
? "You matched everything first try."
|
|
||||||
: `You made ${misses} wrong ${misses === 1 ? "attempt" : "attempts"}.`}
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity } from "@/lib/schemas";
|
import type { Activity } from "@/lib/schemas";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,6 +24,7 @@ export function MultipleChoice({
|
||||||
onNext,
|
onNext,
|
||||||
isLast,
|
isLast,
|
||||||
}: ActivityComponentProps<MultipleChoiceActivity>) {
|
}: ActivityComponentProps<MultipleChoiceActivity>) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [selected, setSelected] = useState<number | null>(null);
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
const answered = selected !== null;
|
const answered = selected !== null;
|
||||||
|
|
||||||
|
|
@ -62,7 +64,7 @@ export function MultipleChoice({
|
||||||
<QuestionText>{activity.question}</QuestionText>
|
<QuestionText>{activity.question}</QuestionText>
|
||||||
</PromptCard>
|
</PromptCard>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2.5" role="group" aria-label="Answer options">
|
<div className="flex flex-col gap-2.5" role="group" aria-label={t("activity.answerOptions")}>
|
||||||
{activity.options.map((option, index) => (
|
{activity.options.map((option, index) => (
|
||||||
<OptionTile
|
<OptionTile
|
||||||
key={option + index}
|
key={option + index}
|
||||||
|
|
@ -76,7 +78,7 @@ export function MultipleChoice({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!answered ? <Hint>Tip: press 1–4 on a keyboard.</Hint> : null}
|
{!answered ? <Hint>{t("mc.tipKeys")}</Hint> : null}
|
||||||
|
|
||||||
{answered ? (
|
{answered ? (
|
||||||
<FeedbackPanel
|
<FeedbackPanel
|
||||||
|
|
@ -88,7 +90,7 @@ export function MultipleChoice({
|
||||||
detail={
|
detail={
|
||||||
selected === activity.correctIndex ? null : (
|
selected === activity.correctIndex ? null : (
|
||||||
<>
|
<>
|
||||||
Correct answer:{" "}
|
{t("activity.correctAnswer")}{" "}
|
||||||
<span className="font-bold">{activity.options[activity.correctIndex]}</span>
|
<span className="font-bold">{activity.options[activity.correctIndex]}</span>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { Reorder } from "framer-motion";
|
import { Reorder } from "framer-motion";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity } from "@/lib/schemas";
|
import type { Activity } from "@/lib/schemas";
|
||||||
import { cn, shuffle } from "@/lib/utils";
|
import { cn, shuffle } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
|
@ -18,6 +19,7 @@ import {
|
||||||
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
|
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
|
||||||
|
|
||||||
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<OrderingActivity>) {
|
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<OrderingActivity>) {
|
||||||
|
const { t } = useI18n();
|
||||||
const initial = useMemo(() => shuffle(activity.items), [activity]);
|
const initial = useMemo(() => shuffle(activity.items), [activity]);
|
||||||
const [items, setItems] = useState<string[]>(initial);
|
const [items, setItems] = useState<string[]>(initial);
|
||||||
const [checked, setChecked] = useState(false);
|
const [checked, setChecked] = useState(false);
|
||||||
|
|
@ -48,7 +50,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
||||||
<PromptCard className="py-4">
|
<PromptCard className="py-4">
|
||||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||||
<Hint>
|
<Hint>
|
||||||
{checked ? "Here is the correct order:" : "Drag the cards, or use ▲▼, then check your order."}
|
{checked ? t("ordering.correctOrder") : t("ordering.hint")}
|
||||||
</Hint>
|
</Hint>
|
||||||
</PromptCard>
|
</PromptCard>
|
||||||
|
|
||||||
|
|
@ -89,7 +91,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Move "${item}" up`}
|
aria-label={t("ordering.moveUp", { item })}
|
||||||
onClick={() => move(index, -1)}
|
onClick={() => move(index, -1)}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
|
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
|
||||||
|
|
@ -98,7 +100,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Move "${item}" down`}
|
aria-label={t("ordering.moveDown", { item })}
|
||||||
onClick={() => move(index, 1)}
|
onClick={() => move(index, 1)}
|
||||||
disabled={index === items.length - 1}
|
disabled={index === items.length - 1}
|
||||||
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
|
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
|
||||||
|
|
@ -116,7 +118,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
||||||
|
|
||||||
{!checked ? (
|
{!checked ? (
|
||||||
<Button size="lg" onClick={check} disabled={items.length < 2}>
|
<Button size="lg" onClick={check} disabled={items.length < 2}>
|
||||||
Check order
|
{t("ordering.check")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<FeedbackPanel
|
<FeedbackPanel
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { ApiError, gradeAnswer } from "@/lib/client-api";
|
import { ApiError, gradeAnswer } from "@/lib/client-api";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity } from "@/lib/schemas";
|
import type { Activity } from "@/lib/schemas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button, Spinner } from "@/components/ui";
|
import { Button, Spinner } from "@/components/ui";
|
||||||
|
|
@ -26,6 +27,7 @@ type GradeState =
|
||||||
| { status: "self-check" };
|
| { status: "self-check" };
|
||||||
|
|
||||||
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps<ShortAnswerActivity>) {
|
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps<ShortAnswerActivity>) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [state, setState] = useState<GradeState>({ status: "idle" });
|
const [state, setState] = useState<GradeState>({ status: "idle" });
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
@ -61,13 +63,13 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
||||||
message:
|
message:
|
||||||
error instanceof ApiError
|
error instanceof ApiError
|
||||||
? error.message
|
? error.message
|
||||||
: "Grading failed. Check that Ollama is reachable and try again.",
|
: t("short.gradeFailed"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selfCheck = (correct: boolean) => {
|
const selfCheck = (correct: boolean) => {
|
||||||
setState({ status: "graded", correct, feedback: "Self-checked — you judged this one yourself.", missing: [] });
|
setState({ status: "graded", correct, feedback: t("short.selfChecked"), missing: [] });
|
||||||
onAnswered(correct, { userAnswer: value.trim() });
|
onAnswered(correct, { userAnswer: value.trim() });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -79,7 +81,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
||||||
</PromptCard>
|
</PromptCard>
|
||||||
|
|
||||||
<label className="sr-only" htmlFor={`answer-${activity.id}`}>
|
<label className="sr-only" htmlFor={`answer-${activity.id}`}>
|
||||||
Your answer
|
{t("short.yourAnswer")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id={`answer-${activity.id}`}
|
id={`answer-${activity.id}`}
|
||||||
|
|
@ -95,15 +97,15 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
||||||
}}
|
}}
|
||||||
disabled={state.status === "grading" || state.status === "graded"}
|
disabled={state.status === "grading" || state.status === "graded"}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Type your answer in your own words…"
|
placeholder={t("short.placeholder")}
|
||||||
className="w-full resize-none rounded-2xl border-2 border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-3 text-[15px] font-medium text-frost-950 shadow-sm outline-none transition placeholder:text-frost-400 focus:border-[var(--focus)] disabled:opacity-70 dark:border-[var(--glass-border)] dark:bg-[var(--surface-strong)] dark:text-frost-50 dark:placeholder:text-frost-400"
|
className="w-full resize-none rounded-2xl border-2 border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-3 text-[15px] font-medium text-frost-950 shadow-sm outline-none transition placeholder:text-frost-400 focus:border-[var(--focus)] disabled:opacity-70 dark:border-[var(--glass-border)] dark:bg-[var(--surface-strong)] dark:text-frost-50 dark:placeholder:text-frost-400"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{state.status === "idle" ? (
|
{state.status === "idle" ? (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Hint>Ctrl/⌘ + Enter to submit.</Hint>
|
<Hint>{t("short.ctrlEnter")}</Hint>
|
||||||
<Button size="lg" onClick={() => void grade()} disabled={!value.trim()}>
|
<Button size="lg" onClick={() => void grade()} disabled={!value.trim()}>
|
||||||
Submit answer
|
{t("short.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -111,20 +113,20 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
||||||
{state.status === "grading" ? (
|
{state.status === "grading" ? (
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-white/60 bg-white/50 backdrop-blur-xl px-4 py-3 text-sm font-semibold text-frost-800 dark:border-white/15 dark:bg-white/10 dark:text-frost-100">
|
<div className="flex items-center gap-3 rounded-2xl border border-white/60 bg-white/50 backdrop-blur-xl px-4 py-3 text-sm font-semibold text-frost-800 dark:border-white/15 dark:bg-white/10 dark:text-frost-100">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
Grading your answer…
|
{t("short.grading")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.status === "error" ? (
|
{state.status === "error" ? (
|
||||||
<div className="rounded-2xl border-2 border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
|
<div className="rounded-2xl border-2 border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
|
||||||
<p className="font-bold">Couldn't grade that answer</p>
|
<p className="font-bold">{t("short.couldNotGrade")}</p>
|
||||||
<p className="mt-1">{state.message}</p>
|
<p className="mt-1">{state.message}</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<Button size="sm" onClick={() => void grade()}>
|
<Button size="sm" onClick={() => void grade()}>
|
||||||
Retry
|
{t("common.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
|
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
|
||||||
Show answer & self-check
|
{t("short.showAnswer")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -132,15 +134,15 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
||||||
|
|
||||||
{state.status === "self-check" ? (
|
{state.status === "self-check" ? (
|
||||||
<div className="card space-y-3 p-4">
|
<div className="card space-y-3 p-4">
|
||||||
<p className="text-sm font-bold text-frost-900 dark:text-frost-100">Model answer</p>
|
<p className="text-sm font-bold text-frost-900 dark:text-frost-100">{t("short.modelAnswer")}</p>
|
||||||
<p className="text-sm leading-relaxed text-frost-700 dark:text-frost-200">{activity.referenceAnswer}</p>
|
<p className="text-sm leading-relaxed text-frost-700 dark:text-frost-200">{activity.referenceAnswer}</p>
|
||||||
<p className="text-xs text-frost-500 dark:text-frost-400">Key points: {activity.keyPoints.join(" · ")}</p>
|
<p className="text-xs text-frost-500 dark:text-frost-400">{t("short.keyPoints")} {activity.keyPoints.join(" · ")}</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Button variant="secondary" onClick={() => selfCheck(false)}>
|
<Button variant="secondary" onClick={() => selfCheck(false)}>
|
||||||
😅 I missed it
|
{t("short.missedIt")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="success" onClick={() => selfCheck(true)}>
|
<Button variant="success" onClick={() => selfCheck(true)}>
|
||||||
🧊 I had it right
|
{t("short.hadRight")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -157,7 +159,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="font-extrabold uppercase tracking-wide">
|
<p className="font-extrabold uppercase tracking-wide">
|
||||||
{state.correct ? "Accepted" : "Needs work"}
|
{state.correct ? t("short.accepted") : t("short.needsWork")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-normal">{state.feedback}</p>
|
<p className="mt-1 font-normal">{state.feedback}</p>
|
||||||
{state.missing.length ? (
|
{state.missing.length ? (
|
||||||
|
|
@ -169,7 +171,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
||||||
) : null}
|
) : null}
|
||||||
{!state.correct ? (
|
{!state.correct ? (
|
||||||
<p className="mt-2 font-normal">
|
<p className="mt-2 font-normal">
|
||||||
<span className="font-bold">Model answer: </span>
|
<span className="font-bold">{t("short.modelAnswer")}: </span>
|
||||||
{activity.referenceAnswer}
|
{activity.referenceAnswer}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
import { motion, useMotionValue, useTransform } from "framer-motion";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity } from "@/lib/schemas";
|
import type { Activity } from "@/lib/schemas";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ import {
|
||||||
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
||||||
|
|
||||||
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
|
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [result, setResult] = useState<boolean | null>(null);
|
const [result, setResult] = useState<boolean | null>(null);
|
||||||
const x = useMotionValue(0);
|
const x = useMotionValue(0);
|
||||||
// The drag is elastic, so the card moves less than the pointer — these
|
// The drag is elastic, so the card moves less than the pointer — these
|
||||||
|
|
@ -37,7 +39,7 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
|
||||||
<>
|
<>
|
||||||
<ActivityHeader type="true_false" topic={activity.topic} />
|
<ActivityHeader type="true_false" topic={activity.topic} />
|
||||||
<Hint>
|
<Hint>
|
||||||
{result === null ? "Swipe the card right for true, left for false — or tap a button." : "Nice swipe!"}
|
{result === null ? t("tf.hint") : t("tf.niceSwipe")}
|
||||||
</Hint>
|
</Hint>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -55,10 +57,10 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
|
||||||
>
|
>
|
||||||
<PromptCard className="relative min-h-44 select-none">
|
<PromptCard className="relative min-h-44 select-none">
|
||||||
<motion.span style={{ opacity: trueOpacity }} className="absolute left-4 top-3 text-sm font-extrabold uppercase tracking-widest text-emerald-500">
|
<motion.span style={{ opacity: trueOpacity }} className="absolute left-4 top-3 text-sm font-extrabold uppercase tracking-widest text-emerald-500">
|
||||||
✓ true
|
{t("tf.trueLabel")}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
<motion.span style={{ opacity: falseOpacity }} className="absolute right-4 top-3 text-sm font-extrabold uppercase tracking-widest text-rose-500">
|
<motion.span style={{ opacity: falseOpacity }} className="absolute right-4 top-3 text-sm font-extrabold uppercase tracking-widest text-rose-500">
|
||||||
false ✕
|
{t("tf.falseLabel")}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
<p className="pt-6 text-center text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50">
|
<p className="pt-6 text-center text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50">
|
||||||
“{activity.statement}”
|
“{activity.statement}”
|
||||||
|
|
@ -70,10 +72,10 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
|
||||||
{result === null ? (
|
{result === null ? (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Button variant="secondary" size="lg" onClick={() => answer(false)} className="border-rose-200 text-rose-700 dark:text-rose-200">
|
<Button variant="secondary" size="lg" onClick={() => answer(false)} className="border-rose-200 text-rose-700 dark:text-rose-200">
|
||||||
✕ False
|
{t("tf.falseBtn")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="success" size="lg" onClick={() => answer(true)}>
|
<Button variant="success" size="lg" onClick={() => answer(true)}>
|
||||||
✓ True
|
{t("tf.trueBtn")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -85,7 +87,8 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
detail={
|
detail={
|
||||||
<span>
|
<span>
|
||||||
This statement is <span className="font-bold">{activity.answer ? "true" : "false"}</span>.
|
{t("tf.statementIs.before")}{" "}
|
||||||
|
<span className="font-bold">{activity.answer ? t("tf.true") : t("tf.false")}</span>.
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { MultipleChoice } from "./MultipleChoice";
|
||||||
import { Ordering } from "./Ordering";
|
import { Ordering } from "./Ordering";
|
||||||
import { ShortAnswer } from "./ShortAnswer";
|
import { ShortAnswer } from "./ShortAnswer";
|
||||||
import { TrueFalse } from "./TrueFalse";
|
import { TrueFalse } from "./TrueFalse";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
import { type ActivityComponentProps } from "./shared";
|
import { type ActivityComponentProps } from "./shared";
|
||||||
|
|
||||||
export type { ActivityComponentProps } from "./shared";
|
export type { ActivityComponentProps } from "./shared";
|
||||||
|
|
@ -38,7 +39,7 @@ export function ActivityRenderer(props: ActivityComponentProps) {
|
||||||
// Exhaustiveness guard: adding a new activity type without a component
|
// Exhaustiveness guard: adding a new activity type without a component
|
||||||
// becomes a TypeScript error.
|
// becomes a TypeScript error.
|
||||||
const never: never = activity;
|
const never: never = activity;
|
||||||
return <p className="text-sm">Unsupported activity: {JSON.stringify(never)}</p>;
|
return <p className="text-sm">{t("activity.unsupported", { json: JSON.stringify(never) })}</p>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity } from "@/lib/schemas";
|
import type { Activity } from "@/lib/schemas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
|
@ -19,14 +20,14 @@ export type ActivityComponentProps<A extends Activity = Activity> = {
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TYPE_META: Record<Activity["type"], { label: string; icon: string; tint: string }> = {
|
export const TYPE_META: Record<Activity["type"], { labelKey: string; icon: string; tint: string }> = {
|
||||||
flashcard: { label: "Flashcard", icon: "🃏", tint: "tint-sky" },
|
flashcard: { labelKey: "type.flashcard", icon: "🃏", tint: "tint-sky" },
|
||||||
multiple_choice: { label: "Multiple choice", icon: "🔘", tint: "tint-lilac" },
|
multiple_choice: { labelKey: "type.multipleChoice", icon: "🔘", tint: "tint-lilac" },
|
||||||
true_false: { label: "True or false", icon: "⚖️", tint: "tint-mint" },
|
true_false: { labelKey: "type.trueFalse", icon: "⚖️", tint: "tint-mint" },
|
||||||
fill_blank: { label: "Fill the blank", icon: "✏️", tint: "tint-peach" },
|
fill_blank: { labelKey: "type.fillBlank", icon: "✏️", tint: "tint-peach" },
|
||||||
matching: { label: "Match up", icon: "🧲", tint: "tint-sky" },
|
matching: { labelKey: "type.matching", icon: "🧲", tint: "tint-sky" },
|
||||||
ordering: { label: "Put in order", icon: "🔢", tint: "tint-lilac" },
|
ordering: { labelKey: "type.ordering", icon: "🔢", tint: "tint-lilac" },
|
||||||
short_answer: { label: "Short answer", icon: "💬", tint: "tint-rose" },
|
short_answer: { labelKey: "type.shortAnswer", icon: "💬", tint: "tint-rose" },
|
||||||
};
|
};
|
||||||
export function ActivityHeader({
|
export function ActivityHeader({
|
||||||
type,
|
type,
|
||||||
|
|
@ -37,6 +38,7 @@ export function ActivityHeader({
|
||||||
topic?: string;
|
topic?: string;
|
||||||
extra?: React.ReactNode;
|
extra?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const meta = TYPE_META[type];
|
const meta = TYPE_META[type];
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -47,7 +49,7 @@ export function ActivityHeader({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span aria-hidden>{meta.icon}</span>
|
<span aria-hidden>{meta.icon}</span>
|
||||||
{meta.label}
|
{t(meta.labelKey)}
|
||||||
</span>
|
</span>
|
||||||
{topic ? (
|
{topic ? (
|
||||||
<span className="glass-chip rounded-full px-3 py-1 text-xs font-medium text-frost-600 dark:text-frost-300">
|
<span className="glass-chip rounded-full px-3 py-1 text-xs font-medium text-frost-600 dark:text-frost-300">
|
||||||
|
|
@ -144,6 +146,7 @@ export function FeedbackPanel({
|
||||||
detail?: React.ReactNode;
|
detail?: React.ReactNode;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 18 }}
|
initial={{ opacity: 0, y: 18 }}
|
||||||
|
|
@ -166,7 +169,7 @@ export function FeedbackPanel({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span aria-hidden>{correct ? "✅" : "💧"}</span>
|
<span aria-hidden>{correct ? "✅" : "💧"}</span>
|
||||||
{headline ?? (correct ? "Nice one!" : "Not quite")}
|
{headline ?? (correct ? t("activity.niceOne") : t("activity.notQuite"))}
|
||||||
</p>
|
</p>
|
||||||
{correct && xp ? (
|
{correct && xp ? (
|
||||||
<motion.span
|
<motion.span
|
||||||
|
|
@ -182,7 +185,7 @@ export function FeedbackPanel({
|
||||||
{detail ? <div className="mt-2 text-sm text-frost-700 dark:text-frost-200">{detail}</div> : null}
|
{detail ? <div className="mt-2 text-sm text-frost-700 dark:text-frost-200">{detail}</div> : null}
|
||||||
|
|
||||||
<p className="mt-2 text-sm leading-relaxed text-frost-700 dark:text-frost-200">
|
<p className="mt-2 text-sm leading-relaxed text-frost-700 dark:text-frost-200">
|
||||||
<span className="font-bold">Why: </span>
|
<span className="font-bold">{t("activity.why")} </span>
|
||||||
{explanation}
|
{explanation}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -190,7 +193,7 @@ export function FeedbackPanel({
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end">
|
<div className="mt-4 flex items-center justify-end">
|
||||||
<Button onClick={onNext} size="md" className="min-w-28">
|
<Button onClick={onNext} size="md" className="min-w-28">
|
||||||
{isLast ? "See results" : "Next"}
|
{isLast ? t("activity.seeResults") : t("activity.next")}
|
||||||
<span aria-hidden>→</span>
|
<span aria-hidden>→</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { localeHeader, t } from "./i18n";
|
||||||
import type { Activity, Difficulty } from "./schemas";
|
import type { Activity, Difficulty } from "./schemas";
|
||||||
import { API_KEY_HEADER, authHeaders } from "./settings";
|
import { API_KEY_HEADER, authHeaders } from "./settings";
|
||||||
|
|
||||||
|
|
@ -55,23 +56,23 @@ export async function generateDeck(
|
||||||
try {
|
try {
|
||||||
response = await fetch("/api/generate", {
|
response = await fetch("/api/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
throw new ApiError("Generation was cancelled.", "aborted");
|
throw new ApiError(t("api.cancelled"), "aborted");
|
||||||
}
|
}
|
||||||
throw new ApiError("Could not reach the Freezmo server. Is it still running?", "network");
|
throw new ApiError(t("api.unreachable"), "network");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
|
const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
|
||||||
throw new ApiError(body?.error ?? `The server returned HTTP ${response.status}.`, body?.kind);
|
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
|
||||||
}
|
}
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
throw new ApiError("The server sent an empty response.", "bad_response");
|
throw new ApiError(t("api.empty"), "bad_response");
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
|
|
@ -96,7 +97,7 @@ export async function generateDeck(
|
||||||
deck = event.deck;
|
deck = event.deck;
|
||||||
meta = event.meta ?? {};
|
meta = event.meta ?? {};
|
||||||
sawActivity = true;
|
sawActivity = true;
|
||||||
options.onProgress?.({ stage: "done", message: "Almost there…" });
|
options.onProgress?.({ stage: "done", message: t("api.almost") });
|
||||||
} else if (event.type === "error") {
|
} else if (event.type === "error") {
|
||||||
streamError = new ApiError(event.error, event.kind);
|
streamError = new ApiError(event.error, event.kind);
|
||||||
}
|
}
|
||||||
|
|
@ -119,8 +120,8 @@ export async function generateDeck(
|
||||||
if (!deck) {
|
if (!deck) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
sawActivity
|
sawActivity
|
||||||
? "The server finished without sending a deck."
|
? t("api.incomplete")
|
||||||
: "No deck came back from the server. Check your Ollama settings and try again.",
|
: t("api.noDeck"),
|
||||||
"bad_response",
|
"bad_response",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +130,7 @@ export async function generateDeck(
|
||||||
(activity) => activity && typeof activity.type === "string" && typeof activity.id === "string",
|
(activity) => activity && typeof activity.type === "string" && typeof activity.id === "string",
|
||||||
);
|
);
|
||||||
if (!valid || valid.length === 0) {
|
if (!valid || valid.length === 0) {
|
||||||
throw new ApiError("The generated deck had no usable activities. Try again.", "bad_response");
|
throw new ApiError(t("api.noActivities"), "bad_response");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { deck: { ...(deck as StreamedDeck), activities: valid }, meta };
|
return { deck: { ...(deck as StreamedDeck), activities: valid }, meta };
|
||||||
|
|
@ -147,15 +148,15 @@ export async function gradeAnswer(input: {
|
||||||
try {
|
try {
|
||||||
response = await fetch("/api/grade", {
|
response = await fetch("/api/grade", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
throw new ApiError("Grading was cancelled.", "aborted");
|
throw new ApiError(t("api.gradingCancelled"), "aborted");
|
||||||
}
|
}
|
||||||
throw new ApiError("Could not reach the Freezmo server.", "network");
|
throw new ApiError(t("api.unreachableShort"), "network");
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await response.json().catch(() => null)) as
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
|
@ -163,7 +164,7 @@ export async function gradeAnswer(input: {
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
if (!response.ok || !body || body.error) {
|
if (!response.ok || !body || body.error) {
|
||||||
throw new ApiError(body?.error ?? `Grading failed with HTTP ${response.status}.`, body?.kind);
|
throw new ApiError(body?.error ?? t("api.gradingFailed", { status: response.status }), body?.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -202,21 +203,21 @@ export type Health = {
|
||||||
export async function fetchHealth(): Promise<Health> {
|
export async function fetchHealth(): Promise<Health> {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch("/api/health", { cache: "no-store", headers: authHeaders() });
|
response = await fetch("/api/health", { cache: "no-store", headers: { ...authHeaders(), ...localeHeader() } });
|
||||||
} catch {
|
} catch {
|
||||||
throw new ApiError("Could not reach the Freezmo server.", "network");
|
throw new ApiError(t("api.unreachableShort"), "network");
|
||||||
}
|
}
|
||||||
const body = (await response.json().catch(() => null)) as (Partial<Health> & { error?: string; kind?: string }) | null;
|
const body = (await response.json().catch(() => null)) as (Partial<Health> & { error?: string; kind?: string }) | null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
body?.error ?? `The health check failed with HTTP ${response.status}.`,
|
body?.error ?? t("api.healthFailed", { status: response.status }),
|
||||||
body?.kind ?? "http",
|
body?.kind ?? "http",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Guard against anything that isn't a health payload (e.g. a proxy error
|
// Guard against anything that isn't a health payload (e.g. a proxy error
|
||||||
// page) so consumers can rely on the shape.
|
// page) so consumers can rely on the shape.
|
||||||
if (!body || typeof body !== "object" || !body.vision || !body.text) {
|
if (!body || typeof body !== "object" || !body.vision || !body.text) {
|
||||||
throw new ApiError("The Freezmo server sent an unexpected health response.", "bad_response");
|
throw new ApiError(t("api.healthUnexpected"), "bad_response");
|
||||||
}
|
}
|
||||||
return body as Health;
|
return body as Health;
|
||||||
}
|
}
|
||||||
|
|
@ -237,12 +238,12 @@ export async function verifyOllamaKey(apiKey: string): Promise<KeyCheck> {
|
||||||
try {
|
try {
|
||||||
response = await fetch("/api/health", {
|
response = await fetch("/api/health", {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: { [API_KEY_HEADER]: apiKey },
|
headers: { [API_KEY_HEADER]: apiKey, ...localeHeader() },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
result: "offline",
|
result: "offline",
|
||||||
message: "Could not reach the Freezmo server. Is it still running?",
|
message: t("api.unreachable"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,7 +251,7 @@ export async function verifyOllamaKey(apiKey: string): Promise<KeyCheck> {
|
||||||
if (!response.ok || !body) {
|
if (!response.ok || !body) {
|
||||||
return {
|
return {
|
||||||
result: "offline",
|
result: "offline",
|
||||||
message: "The Freezmo server answered unexpectedly. Try again in a moment.",
|
message: t("api.serverUnexpected"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,8 +259,7 @@ export async function verifyOllamaKey(apiKey: string): Promise<KeyCheck> {
|
||||||
return {
|
return {
|
||||||
result: "invalid",
|
result: "invalid",
|
||||||
message:
|
message:
|
||||||
body.error ??
|
body.error ?? t("api.keyRejected"),
|
||||||
"Ollama rejected this key. Check for typos, or create a new one at ollama.com/settings/keys.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (body.keyAccepted === true) return { result: "valid" };
|
if (body.keyAccepted === true) return { result: "valid" };
|
||||||
|
|
@ -268,7 +268,7 @@ export async function verifyOllamaKey(apiKey: string): Promise<KeyCheck> {
|
||||||
return {
|
return {
|
||||||
result: "offline",
|
result: "offline",
|
||||||
message:
|
message:
|
||||||
body.error ?? "Couldn't reach Ollama just now. Check your connection and try again.",
|
body.error ?? t("api.ollamaUnreachable"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { sanitiseApiKey } from "./http-guard";
|
import { sanitiseApiKey } from "./http-guard";
|
||||||
|
import { translate, type Locale } from "./i18n-dict";
|
||||||
|
|
||||||
export type AuthMode = "user-key" | "server" | "local";
|
export type AuthMode = "user-key" | "server" | "local";
|
||||||
|
|
||||||
|
|
@ -103,15 +104,16 @@ export function getServerConfig(): ServerConfig {
|
||||||
* Human-readable explanation for every auth configuration problem, so the UI
|
* Human-readable explanation for every auth configuration problem, so the UI
|
||||||
* never has to show a bare "unauthorised".
|
* never has to show a bare "unauthorised".
|
||||||
*/
|
*/
|
||||||
export const AUTH_ERROR_MESSAGES: Record<"missing_key" | "server_key_missing" | "local_with_cloud_url", string> = {
|
const AUTH_ERROR_KEYS: Record<"missing_key" | "server_key_missing" | "local_with_cloud_url", string> = {
|
||||||
missing_key:
|
missing_key: "server.authMissingKey",
|
||||||
"Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.",
|
server_key_missing: "server.authServerKeyMissing",
|
||||||
server_key_missing:
|
local_with_cloud_url: "server.authLocalCloud",
|
||||||
"This server is configured to use its own Ollama API key (OLLAMA_AUTH=server), but OLLAMA_API_KEY is empty in .env.local.",
|
|
||||||
local_with_cloud_url:
|
|
||||||
"This server is set to OLLAMA_AUTH=local but points at https://ollama.com. A signed-in local daemon can't be used against the cloud URL — either set OLLAMA_AUTH=user-key, or point OLLAMA_BASE_URL at http://localhost:11434.",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function authErrorMessage(locale: Locale, error: keyof typeof AUTH_ERROR_KEYS): string {
|
||||||
|
return translate(locale, AUTH_ERROR_KEYS[error]);
|
||||||
|
}
|
||||||
|
|
||||||
export function authErrorKind(error: "missing_key" | "server_key_missing" | "local_with_cloud_url"): string {
|
export function authErrorKind(error: "missing_key" | "server_key_missing" | "local_with_cloud_url"): string {
|
||||||
if (error === "missing_key") return "needs_key";
|
if (error === "missing_key") return "needs_key";
|
||||||
return "config";
|
return "config";
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
* to the API routes. Photos uploaded as images keep working exactly as before.
|
* to the API routes. Photos uploaded as images keep working exactly as before.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { t } from "./i18n";
|
||||||
import { prepareImageFromBlob, type PreparedImage } from "./image";
|
import { prepareImageFromBlob, type PreparedImage } from "./image";
|
||||||
|
|
||||||
export const MAX_DOCUMENT_BYTES = 40 * 1024 * 1024; // 40 MB per file
|
export const MAX_DOCUMENT_BYTES = 40 * 1024 * 1024; // 40 MB per file
|
||||||
|
|
@ -114,10 +115,10 @@ export function isDocumentFile(file: File): boolean {
|
||||||
|
|
||||||
export function describeDocumentError(file: File): string | null {
|
export function describeDocumentError(file: File): string | null {
|
||||||
if (!isDocumentFile(file)) {
|
if (!isDocumentFile(file)) {
|
||||||
return `"${file.name}" is not a supported document (use PDF, DOCX, PPTX, ODT/ODP or text).`;
|
return t("file.notDocument", { name: file.name });
|
||||||
}
|
}
|
||||||
if (file.size > MAX_DOCUMENT_BYTES) {
|
if (file.size > MAX_DOCUMENT_BYTES) {
|
||||||
return `"${file.name}" is larger than 40 MB.`;
|
return t("file.documentTooLarge", { name: file.name });
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -224,7 +225,7 @@ async function extractPdf(
|
||||||
|
|
||||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
|
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
message: `Reading page ${pageNumber} of ${totalPages}…`,
|
message: t("doc.readingPage", { n: pageNumber, total: totalPages }),
|
||||||
progress: pageNumber / totalPages,
|
progress: pageNumber / totalPages,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -452,16 +453,16 @@ export async function extractDocument(
|
||||||
case "pdf":
|
case "pdf":
|
||||||
return extractPdf(file, onProgress);
|
return extractPdf(file, onProgress);
|
||||||
case "docx":
|
case "docx":
|
||||||
onProgress?.({ message: "Reading the Word document…" });
|
onProgress?.({ message: t("doc.readingWord") });
|
||||||
return extractDocx(file);
|
return extractDocx(file);
|
||||||
case "pptx":
|
case "pptx":
|
||||||
onProgress?.({ message: "Reading the slides…" });
|
onProgress?.({ message: t("doc.readingSlides") });
|
||||||
return extractOoxmlPackage(file, "pptx");
|
return extractOoxmlPackage(file, "pptx");
|
||||||
case "opendocument":
|
case "opendocument":
|
||||||
onProgress?.({ message: "Reading the OpenDocument file…" });
|
onProgress?.({ message: t("doc.readingOpen") });
|
||||||
return extractOoxmlPackage(file, "opendocument");
|
return extractOoxmlPackage(file, "opendocument");
|
||||||
case "text":
|
case "text":
|
||||||
onProgress?.({ message: "Reading the text file…" });
|
onProgress?.({ message: t("doc.readingText") });
|
||||||
return extractPlainText(file);
|
return extractPlainText(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
* never persisted — it is simply every deck with no `folderId`.
|
* never persisted — it is simply every deck with no `folderId`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { t } from "./i18n";
|
||||||
import { folderSchema, UNORGANIZED_ID, type Folder } from "./schemas";
|
import { folderSchema, UNORGANIZED_ID, type Folder } from "./schemas";
|
||||||
import { uid } from "./utils";
|
import { uid } from "./utils";
|
||||||
|
|
||||||
|
|
@ -99,8 +100,8 @@ export function getFolder(id: string | undefined): Folder | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderName(id: string | undefined): string {
|
export function folderName(id: string | undefined): string {
|
||||||
if (!id || id === UNORGANIZED_ID) return "Unorganized";
|
if (!id || id === UNORGANIZED_ID) return t("common.unorganized");
|
||||||
return getFolder(id)?.name ?? "Unorganized";
|
return getFolder(id)?.name ?? t("common.unorganized");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findFolderByName(name: string): Folder | undefined {
|
export function findFolderByName(name: string): Folder | undefined {
|
||||||
|
|
@ -111,8 +112,8 @@ export function findFolderByName(name: string): Folder | undefined {
|
||||||
/** Case-insensitive uniqueness; returns the existing folder when it matches. */
|
/** Case-insensitive uniqueness; returns the existing folder when it matches. */
|
||||||
export function createFolder(name: string): { folder: Folder } | { error: string } {
|
export function createFolder(name: string): { folder: Folder } | { error: string } {
|
||||||
const trimmed = name.trim().replace(/\s+/g, " ");
|
const trimmed = name.trim().replace(/\s+/g, " ");
|
||||||
if (!trimmed) return { error: "Give the folder a name." };
|
if (!trimmed) return { error: t("folder.nameRequired") };
|
||||||
if (trimmed.length > 60) return { error: "That name is a bit long (60 characters max)." };
|
if (trimmed.length > 60) return { error: t("folder.nameTooLong") };
|
||||||
|
|
||||||
const existing = findFolderByName(trimmed);
|
const existing = findFolderByName(trimmed);
|
||||||
if (existing) return { folder: existing };
|
if (existing) return { folder: existing };
|
||||||
|
|
@ -125,11 +126,11 @@ export function createFolder(name: string): { folder: Folder } | { error: string
|
||||||
|
|
||||||
export function renameFolder(id: string, name: string): { ok: true } | { error: string } {
|
export function renameFolder(id: string, name: string): { ok: true } | { error: string } {
|
||||||
const trimmed = name.trim().replace(/\s+/g, " ");
|
const trimmed = name.trim().replace(/\s+/g, " ");
|
||||||
if (!trimmed) return { error: "Give the folder a name." };
|
if (!trimmed) return { error: t("folder.nameRequired") };
|
||||||
const clash = folderStore
|
const clash = folderStore
|
||||||
.getSnapshot()
|
.getSnapshot()
|
||||||
.find((folder) => folder.id !== id && folder.name.trim().toLowerCase() === trimmed.toLowerCase());
|
.find((folder) => folder.id !== id && folder.name.trim().toLowerCase() === trimmed.toLowerCase());
|
||||||
if (clash) return { error: "Another folder already has that name." };
|
if (clash) return { error: t("folder.nameTaken") };
|
||||||
|
|
||||||
ensureLoaded();
|
ensureLoaded();
|
||||||
write(folderStore.getSnapshot().map((folder) => (folder.id === id ? { ...folder, name: trimmed } : folder)));
|
write(folderStore.getSnapshot().map((folder) => (folder.id === id ? { ...folder, name: trimmed } : folder)));
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { LOCALE_HEADER, normaliseLocale, translate } from "./i18n-dict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small server-side guards shared by the API routes.
|
* Small server-side guards shared by the API routes.
|
||||||
*
|
*
|
||||||
|
|
@ -6,6 +8,11 @@
|
||||||
* bodies exhausting memory, and (c) odd values ending up in outbound headers.
|
* bodies exhausting memory, and (c) odd values ending up in outbound headers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** The language this request asked for (browsers send x-freezmo-locale). */
|
||||||
|
export function requestLocale(request: Request) {
|
||||||
|
return normaliseLocale(request.headers.get(LOCALE_HEADER));
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||||
return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
||||||
|
|
@ -19,7 +26,10 @@ export function rejectOversizedBody(request: Request, maxBytes: number): Respons
|
||||||
if (Number.isFinite(length) && length > maxBytes) {
|
if (Number.isFinite(length) && length > maxBytes) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
error: `Request body is too large (${formatBytes(length)}). Limit is ${formatBytes(maxBytes)}.`,
|
error: translate(requestLocale(request), "server.bodyTooLarge", {
|
||||||
|
size: formatBytes(length),
|
||||||
|
limit: formatBytes(maxBytes),
|
||||||
|
}),
|
||||||
kind: "too_large",
|
kind: "too_large",
|
||||||
},
|
},
|
||||||
{ status: 413 },
|
{ status: 413 },
|
||||||
|
|
@ -46,7 +56,10 @@ export function rejectCrossOrigin(request: Request): Response | null {
|
||||||
try {
|
try {
|
||||||
originHost = new URL(origin).host;
|
originHost = new URL(origin).host;
|
||||||
} catch {
|
} catch {
|
||||||
return Response.json({ error: "Invalid Origin header.", kind: "forbidden" }, { status: 403 });
|
return Response.json(
|
||||||
|
{ error: translate(requestLocale(request), "server.invalidOrigin"), kind: "forbidden" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = request.headers.get("host");
|
const host = request.headers.get("host");
|
||||||
|
|
@ -62,7 +75,7 @@ export function rejectCrossOrigin(request: Request): Response | null {
|
||||||
if (loopback.has(bare(originHost)) && loopback.has(bare(host ?? ""))) return null;
|
if (loopback.has(bare(originHost)) && loopback.has(bare(host ?? ""))) return null;
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Cross-origin requests are not allowed.", kind: "forbidden" },
|
{ error: translate(requestLocale(request), "server.crossOrigin"), kind: "forbidden" },
|
||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1093
lib/i18n-dict.ts
Normal file
1093
lib/i18n-dict.ts
Normal file
File diff suppressed because it is too large
Load diff
127
lib/i18n.ts
Normal file
127
lib/i18n.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser-side locale store + React bindings.
|
||||||
|
*
|
||||||
|
* English is the default and the source of truth; Czech is a full translation.
|
||||||
|
* Only *interface* copy lives in the dictionaries — your notes and the generated
|
||||||
|
* decks always keep their own language (that is a core feature, not a setting).
|
||||||
|
*
|
||||||
|
* The dictionaries themselves live in ./i18n-dict so the server can translate
|
||||||
|
* its API messages into the same language.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
LOCALE_HEADER,
|
||||||
|
LOCALES,
|
||||||
|
normaliseLocale,
|
||||||
|
translate,
|
||||||
|
translatePlural,
|
||||||
|
type Locale,
|
||||||
|
type Vars,
|
||||||
|
} from "./i18n-dict";
|
||||||
|
|
||||||
|
export { LOCALES, DEFAULT_LOCALE, LOCALE_HEADER, translate, translatePlural };
|
||||||
|
export type { Locale };
|
||||||
|
|
||||||
|
const STORAGE_KEY = "freezmo.locale.v1";
|
||||||
|
|
||||||
|
let cached: Locale | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function read(): Locale {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_LOCALE;
|
||||||
|
try {
|
||||||
|
return normaliseLocale(window.localStorage.getItem(STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(): void {
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localeStore = {
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
const onStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key === STORAGE_KEY) {
|
||||||
|
cached = read();
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getSnapshot(): Locale {
|
||||||
|
if (cached === null) cached = read();
|
||||||
|
return cached;
|
||||||
|
},
|
||||||
|
getServerSnapshot(): Locale {
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLocale(): Locale {
|
||||||
|
return localeStore.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocale(locale: Locale): void {
|
||||||
|
cached = normaliseLocale(locale);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, cached);
|
||||||
|
} catch {
|
||||||
|
/* private mode */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
document.documentElement.lang = cached;
|
||||||
|
} catch {
|
||||||
|
/* SSR */
|
||||||
|
}
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React hook: re-renders when the locale changes. */
|
||||||
|
export function useLocale(): Locale {
|
||||||
|
return useSyncExternalStore(localeStore.subscribe, localeStore.getSnapshot, localeStore.getServerSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Translate a key for the current locale. */
|
||||||
|
export function t(key: string, vars?: Vars): string {
|
||||||
|
return translate(localeStore.getSnapshot(), key, vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plural-aware translate for the current locale. */
|
||||||
|
export function plural(n: number, base: string, vars?: Vars): string {
|
||||||
|
return translatePlural(localeStore.getSnapshot(), n, base, vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header handed to our own API routes so they answer in the chosen language. */
|
||||||
|
export function localeHeader(): Record<string, string> {
|
||||||
|
return { [LOCALE_HEADER]: localeStore.getSnapshot() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience hook for components.
|
||||||
|
*
|
||||||
|
* `t` / `plural` are bound to the locale React is currently rendering with
|
||||||
|
* (the server snapshot during hydration, then the stored choice), so the first
|
||||||
|
* client render always matches the server HTML. Use the module-level `t` only
|
||||||
|
* in event handlers and non-React helpers.
|
||||||
|
*/
|
||||||
|
export function useI18n() {
|
||||||
|
const locale = useLocale();
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
t: (key: string, vars?: Vars) => translate(locale, key, vars),
|
||||||
|
plural: (n: number, base: string, vars?: Vars) => translatePlural(locale, n, base, vars),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { t } from "./i18n";
|
||||||
|
|
||||||
/** Client-side image helpers: validation, resizing and compression. */
|
/** Client-side image helpers: validation, resizing and compression. */
|
||||||
|
|
||||||
export const MAX_IMAGE_DIMENSION = 1600;
|
export const MAX_IMAGE_DIMENSION = 1600;
|
||||||
|
|
@ -23,8 +25,8 @@ export function isImageFile(file: File): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeFileError(file: File): string | null {
|
export function describeFileError(file: File): string | null {
|
||||||
if (!isImageFile(file)) return `"${file.name}" is not an image.`;
|
if (!isImageFile(file)) return t("file.notImage", { name: file.name });
|
||||||
if (file.size > MAX_FILE_BYTES) return `"${file.name}" is larger than 20 MB.`;
|
if (file.size > MAX_FILE_BYTES) return t("file.imageTooLarge", { name: file.name });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
* This module must only ever be imported from server code (route handlers).
|
* This module must only ever be imported from server code (route handlers).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_LOCALE, translate, type Locale } from "./i18n-dict";
|
||||||
import { getServerConfig, resolveModelName, type OllamaAuth } from "./config";
|
import { getServerConfig, resolveModelName, type OllamaAuth } from "./config";
|
||||||
|
|
||||||
export type OllamaMessage = {
|
export type OllamaMessage = {
|
||||||
|
|
@ -41,25 +42,31 @@ export class OllamaError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function friendlyErrorMessage(error: unknown, baseUrl?: string): string {
|
export function friendlyErrorMessage(
|
||||||
|
error: unknown,
|
||||||
|
baseUrl?: string,
|
||||||
|
locale: Locale = DEFAULT_LOCALE,
|
||||||
|
): string {
|
||||||
if (error instanceof OllamaError) {
|
if (error instanceof OllamaError) {
|
||||||
switch (error.kind) {
|
switch (error.kind) {
|
||||||
case "unreachable":
|
case "unreachable":
|
||||||
return `Ollama isn't reachable at ${baseUrl ?? "the configured URL"}. Check your settings and make sure the daemon is running.`;
|
return translate(locale, "server.ollamaUnreachable", {
|
||||||
|
url: baseUrl ?? "the configured URL",
|
||||||
|
});
|
||||||
case "timeout":
|
case "timeout":
|
||||||
return "Ollama took too long to answer. Try fewer/smaller images, a faster model, or retry.";
|
return translate(locale, "server.ollamaTimeout");
|
||||||
case "auth":
|
case "auth":
|
||||||
return "Ollama rejected the request. If you are using https://ollama.com, check OLLAMA_API_KEY (create one at ollama.com/settings/keys).";
|
return translate(locale, "server.ollamaAuth");
|
||||||
case "no_credits":
|
case "no_credits":
|
||||||
return error.message;
|
return error.message;
|
||||||
case "model_missing":
|
case "model_missing":
|
||||||
return error.message;
|
return error.message;
|
||||||
default:
|
default:
|
||||||
return error.message || "Ollama returned an unexpected response.";
|
return error.message || translate(locale, "server.ollamaUnexpected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (error instanceof Error) return error.message;
|
if (error instanceof Error) return error.message;
|
||||||
return "Something went wrong while talking to Ollama.";
|
return translate(locale, "server.ollamaGeneric");
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatOptions = {
|
type ChatOptions = {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
* share state with others — fine for Freezmo, documented in the README.
|
* share state with others — fine for Freezmo, documented in the README.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_LOCALE, translate, type Locale } from "./i18n-dict";
|
||||||
type Hits = { timestamps: number[] };
|
type Hits = { timestamps: number[] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -86,12 +87,10 @@ export function clientKey(request: Request): string {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rateLimitResponse(result: RateLimitResult, message?: string): Response {
|
export function rateLimitResponse(result: RateLimitResult, locale: Locale = DEFAULT_LOCALE): Response {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
error:
|
error: translate(locale, "server.rateLimited", { limit: result.limit, retry: result.retryAfter }),
|
||||||
message ??
|
|
||||||
`Too many requests — this app allows ${result.limit} per window. Try again in ${result.retryAfter}s.`,
|
|
||||||
kind: "rate_limited",
|
kind: "rate_limited",
|
||||||
retryAfter: result.retryAfter,
|
retryAfter: result.retryAfter,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
* Exposes a tiny observable store so every screen stays in sync.
|
* Exposes a tiny observable store so every screen stays in sync.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { t } from "./i18n";
|
||||||
import { deckSchema, UNORGANIZED_ID, type Activity, type Deck } from "./schemas";
|
import { deckSchema, UNORGANIZED_ID, type Activity, type Deck } from "./schemas";
|
||||||
import { createFolder, findFolderByName, getFolder, removeFolder } from "./folders";
|
import { createFolder, findFolderByName, getFolder, removeFolder } from "./folders";
|
||||||
import { uid } from "./utils";
|
import { uid } from "./utils";
|
||||||
|
|
@ -224,7 +225,7 @@ export function importDeck(raw: string): { deck: Deck } | { error: string } {
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw);
|
parsed = JSON.parse(raw);
|
||||||
} catch {
|
} catch {
|
||||||
return { error: "That file is not valid JSON." };
|
return { error: t("import.notJson") };
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundle = parsed && typeof parsed === "object" && "deck" in parsed ? (parsed as { folder?: unknown }) : null;
|
const bundle = parsed && typeof parsed === "object" && "deck" in parsed ? (parsed as { folder?: unknown }) : null;
|
||||||
|
|
@ -232,7 +233,7 @@ export function importDeck(raw: string): { deck: Deck } | { error: string } {
|
||||||
|
|
||||||
const result = deckSchema.safeParse(candidate);
|
const result = deckSchema.safeParse(candidate);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { error: "That file doesn't look like a Freezmo deck." };
|
return { error: t("import.notDeck") };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate the exported folder (or reuse an existing one with the same name).
|
// Recreate the exported folder (or reuse an existing one with the same name).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { t } from "./i18n";
|
||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
import { fetchHealth, type Health } from "./client-api";
|
import { fetchHealth, type Health } from "./client-api";
|
||||||
|
|
@ -25,7 +26,7 @@ export function useHealth() {
|
||||||
try {
|
try {
|
||||||
setHealth(await fetchHealth());
|
setHealth(await fetchHealth());
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError instanceof Error ? loadError.message : "Could not reach the Freezmo server.");
|
setError(loadError instanceof Error ? loadError.message : t("api.unreachableShort"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||||
*/
|
*/
|
||||||
const CACHE_VERSION = "freezmo-v3";
|
const CACHE_VERSION = "freezmo-v4";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue