From 193994cadce2e6ba8476a00fa3762435beab8ee0 Mon Sep 17 00:00:00 2001 From: Muhammad Aiman <84276911+aimanmalib@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:46:31 +0000 Subject: [PATCH 1/4] feat(i18n): Bahasa Melayu + English language layer All UI text now lives in src/i18n/messages/{en,ms}.ts. The `Messages` type and a test both fail when a key is missing in either language, when placeholders differ, or when the BM file uses common Indonesian words. The first visit follows the browser language (ms -> BM). The choice is stored in localStorage (wrapped in try/catch, in-memory fallback) and mirrored to a cookie so the server renders the first paint in the same language. Co-Authored-By: Claude Opus 5.5 --- src/i18n/I18nProvider.tsx | 53 +++++++ src/i18n/config.ts | 51 +++++++ src/i18n/errors.ts | 12 ++ src/i18n/locale-store.ts | 96 +++++++++++++ src/i18n/messages/en.ts | 269 ++++++++++++++++++++++++++++++++++++ src/i18n/messages/index.ts | 46 ++++++ src/i18n/messages/ms.ts | 269 ++++++++++++++++++++++++++++++++++++ src/i18n/messages/types.ts | 13 ++ src/i18n/server.ts | 9 ++ tests/i18n/locale.test.ts | 69 +++++++++ tests/i18n/messages.test.ts | 84 +++++++++++ 11 files changed, 971 insertions(+) create mode 100644 src/i18n/I18nProvider.tsx create mode 100644 src/i18n/config.ts create mode 100644 src/i18n/errors.ts create mode 100644 src/i18n/locale-store.ts create mode 100644 src/i18n/messages/en.ts create mode 100644 src/i18n/messages/index.ts create mode 100644 src/i18n/messages/ms.ts create mode 100644 src/i18n/messages/types.ts create mode 100644 src/i18n/server.ts create mode 100644 tests/i18n/locale.test.ts create mode 100644 tests/i18n/messages.test.ts diff --git a/src/i18n/I18nProvider.tsx b/src/i18n/I18nProvider.tsx new file mode 100644 index 0000000..02ac27f --- /dev/null +++ b/src/i18n/I18nProvider.tsx @@ -0,0 +1,53 @@ +"use client"; +import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from "react"; +import { useRouter } from "next/navigation"; +import { intlLocale, type Locale } from "./config"; +import { translate, type MessageKey, type TranslateParams } from "./messages"; +import { getClientLocale, setClientLocale, subscribeLocale, writeLocaleCookie } from "./locale-store"; + +export interface I18nValue { + locale: Locale; + /** BCP 47 tag for Intl formatting. */ + intl: string; + setLocale: (locale: Locale) => void; + t: (key: MessageKey, params?: TranslateParams) => string; +} + +function makeValue(locale: Locale, setLocale: (l: Locale) => void): I18nValue { + return { + locale, + intl: intlLocale(locale), + setLocale, + t: (key, params) => translate(locale, key, params), + }; +} + +// Without a provider (unit tests, isolated components) everything renders in English. +const I18nContext = createContext(makeValue("en", () => undefined)); + +export function I18nProvider({ initialLocale, children }: { initialLocale: Locale; children: React.ReactNode }) { + const router = useRouter(); + // Server render and hydration use the locale the server picked; afterwards the browser's choice wins. + const locale = useSyncExternalStore(subscribeLocale, getClientLocale, () => initialLocale); + + useEffect(() => { + document.documentElement.lang = locale; + writeLocaleCookie(locale); + }, [locale]); + + const setLocale = useCallback( + (next: Locale) => { + setClientLocale(next); + // Re-render server parts (page title, ) with the new cookie. + router.refresh(); + }, + [router], + ); + + const value = useMemo(() => makeValue(locale, setLocale), [locale, setLocale]); + return {children}; +} + +export function useI18n(): I18nValue { + return useContext(I18nContext); +} diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 0000000..aeac0c3 --- /dev/null +++ b/src/i18n/config.ts @@ -0,0 +1,51 @@ +/** + * Locale settings shared by the server, the browser and the API routes. + * Pure data and pure functions: safe to import anywhere. + */ +export const LOCALES = ["en", "ms"] as const; +export type Locale = (typeof LOCALES)[number]; + +export const DEFAULT_LOCALE: Locale = "en"; + +/** localStorage key that holds the visitor's chosen UI language. */ +export const LOCALE_STORAGE_KEY = "learnpath.locale.v1"; +/** Mirror of the choice, read by the server so the first paint is already in the right language. */ +export const LOCALE_COOKIE = "learnpath-locale"; + +export function isLocale(value: unknown): value is Locale { + return typeof value === "string" && (LOCALES as readonly string[]).includes(value); +} + +/** + * Pick a locale from a list of browser language tags (navigator.languages or + * Accept-Language). Malay (`ms`, `ms-MY`, `zsm`) maps to BM; anything else is English. + */ +export function detectLocale(languages: readonly string[] | null | undefined): Locale { + for (const tag of languages ?? []) { + const base = tag.trim().toLowerCase().split(/[-_;]/)[0]; + if (base === "ms" || base === "zsm") return "ms"; + if (base === "en") return "en"; + } + return DEFAULT_LOCALE; +} + +/** Parse an Accept-Language header into tags ordered by q-value. */ +export function parseAcceptLanguage(header: string | null | undefined): string[] { + if (!header) return []; + return header + .split(",") + .map((part, index) => { + const [tag, ...params] = part.trim().split(";"); + const q = params.map((p) => p.trim()).find((p) => p.startsWith("q=")); + const weight = q ? Number.parseFloat(q.slice(2)) : 1; + return { tag: tag.trim(), weight: Number.isFinite(weight) ? weight : 0, index }; + }) + .filter((x) => x.tag && x.weight > 0) + .sort((a, b) => b.weight - a.weight || a.index - b.index) + .map((x) => x.tag); +} + +/** BCP 47 tag for Intl number/date formatting. */ +export function intlLocale(locale: Locale): string { + return locale === "ms" ? "ms-MY" : "en-MY"; +} diff --git a/src/i18n/errors.ts b/src/i18n/errors.ts new file mode 100644 index 0000000..b72974d --- /dev/null +++ b/src/i18n/errors.ts @@ -0,0 +1,12 @@ +import type { MessageKey, TranslateParams } from "./messages"; +import { en } from "./messages/en"; + +type T = (key: MessageKey, params?: TranslateParams) => string; + +/** Localised message for an API error code; unknown codes get the generic message. */ +export function errorMessage(t: T, code: string | undefined): string { + if (code && code !== "title" && code !== "openSettings" && Object.prototype.hasOwnProperty.call(en.errors, code)) { + return t(`errors.${code}` as MessageKey); + } + return t("errors.unknown"); +} diff --git a/src/i18n/locale-store.ts b/src/i18n/locale-store.ts new file mode 100644 index 0000000..dfadba9 --- /dev/null +++ b/src/i18n/locale-store.ts @@ -0,0 +1,96 @@ +/** + * Browser-side UI language. + * + * Source of truth: localStorage (`learnpath.locale.v1`). A cookie mirrors the + * choice so the server can render the first paint in the same language. + * Every storage access is wrapped in try/catch: storage can be blocked + * (private mode, site data disabled), and then the choice lives in memory for + * this tab only. + */ +import { LOCALE_COOKIE, LOCALE_STORAGE_KEY, detectLocale, isLocale, type Locale } from "./config"; + +const CHANGED_EVENT = "learnpath:locale-changed"; +const ONE_YEAR = 60 * 60 * 24 * 365; + +let memoryLocale: Locale | null = null; + +export function readStoredLocale(): Locale | null { + try { + const value = typeof window === "undefined" ? null : window.localStorage.getItem(LOCALE_STORAGE_KEY); + return isLocale(value) ? value : null; + } catch { + return null; + } +} + +export function readCookieLocale(): Locale | null { + try { + if (typeof document === "undefined") return null; + const match = document.cookie.split("; ").find((c) => c.startsWith(`${LOCALE_COOKIE}=`)); + const value = match?.slice(LOCALE_COOKIE.length + 1); + return isLocale(value) ? value : null; + } catch { + return null; + } +} + +/** Write the cookie the server reads. Harmless when cookies are blocked. */ +export function writeLocaleCookie(locale: Locale): void { + try { + if (typeof document === "undefined") return; + document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=${ONE_YEAR}; samesite=lax`; + } catch { + // cookies blocked: the server falls back to Accept-Language + } +} + +/** The language the UI should show right now in this browser. */ +export function getClientLocale(): Locale { + if (memoryLocale) return memoryLocale; + const stored = readStoredLocale(); + if (stored) return stored; + const cookie = readCookieLocale(); + if (cookie) return cookie; + if (typeof navigator === "undefined") return detectLocale([]); + return detectLocale(navigator.languages?.length ? navigator.languages : [navigator.language]); +} + +/** Persist a choice. Returns false when localStorage refused it (the choice still applies to this tab). */ +export function setClientLocale(locale: Locale): boolean { + memoryLocale = locale; + let saved = false; + try { + window.localStorage.setItem(LOCALE_STORAGE_KEY, locale); + saved = true; + } catch { + saved = false; + } + writeLocaleCookie(locale); + try { + window.dispatchEvent(new Event(CHANGED_EVENT)); + } catch { + // not in a browser + } + return saved; +} + +export function subscribeLocale(onChange: () => void): () => void { + if (typeof window === "undefined") return () => undefined; + const onStorage = (e: StorageEvent) => { + if (e.key === LOCALE_STORAGE_KEY) { + memoryLocale = null; + onChange(); + } + }; + window.addEventListener(CHANGED_EVENT, onChange); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(CHANGED_EVENT, onChange); + window.removeEventListener("storage", onStorage); + }; +} + +/** Test helper: forget the in-memory choice. */ +export function resetLocaleMemory(): void { + memoryLocale = null; +} diff --git a/src/i18n/messages/en.ts b/src/i18n/messages/en.ts new file mode 100644 index 0000000..ed19635 --- /dev/null +++ b/src/i18n/messages/en.ts @@ -0,0 +1,269 @@ +/** + * English UI text. `ms.ts` must have exactly the same keys (enforced by the + * `Messages` type and by tests/i18n/messages.test.ts). + * + * Placeholders use {name}. Keep both languages' placeholders identical. + */ +export const en = { + meta: { + title: "LearnPath: quizzes, flashcards and learning roadmaps with AI", + description: + "Generate quizzes, flashcards and learning roadmaps for any topic with an OpenAI-compatible LLM. Bring your own API key. In English and Bahasa Melayu.", + }, + common: { + appName: "LearnPath", + skipToContent: "Skip to content", + tryAgain: "Try again", + newTopic: "New topic", + close: "Close", + cancel: "Cancel", + save: "Save", + done: "Done", + next: "Next", + tokens: "{count} tokens", + sampleData: "Sample data", + }, + nav: { + label: "Main navigation", + home: "Home", + quiz: "Quiz", + flashcards: "Flashcards", + roadmap: "Roadmap", + progress: "Progress", + concepts: "Concepts", + }, + header: { + homeLink: "LearnPath home", + settings: "Settings", + statusServer: "Server key", + statusReady: "Ready", + statusNeedsKey: "Add API key", + language: "Language", + themeToDark: "Switch to dark theme", + themeToLight: "Switch to light theme", + }, + language: { + en: "English", + ms: "Bahasa Melayu", + enShort: "EN", + msShort: "BM", + }, + footer: { + source: "Source code on GitHub", + builtBy: "Built by {name}", + tagline: "Made in Malaysia", + }, + home: { + eyebrow: "AI study companion", + title: "Turn any topic into a study plan you can practise.", + lead: "Type a topic. LearnPath asks the AI model you choose for a quiz, a flashcard deck or a step-by-step roadmap, in English or Bahasa Melayu.", + ctaQuiz: "Start a quiz", + ctaRoadmap: "Plan a roadmap", + featuresTitle: "What you can do", + quizTitle: "Quiz", + quizDesc: "Multiple-choice questions at the difficulty you pick, each with an explanation.", + flashcardsTitle: "Flashcards", + flashcardsDesc: "A deck to review, rated with the SM-2 spaced-repetition method.", + roadmapTitle: "Learning roadmap", + roadmapDesc: "A step-by-step plan from beginner to advanced, with time estimates.", + progressTitle: "Progress", + progressDesc: "Sessions, scores and token usage from this visit.", + conceptsTitle: "Concept map", + conceptsDesc: "A sample map of related concepts and mastery levels.", + howTitle: "How it works", + how1Title: "Pick a topic", + how1Desc: "Anything from Photosynthesis to SQL joins.", + how2Title: "The AI drafts it", + how2Desc: "Your chosen model writes questions, cards or steps in your language.", + how3Title: "Practise and review", + how3Desc: "Answer, flip and rate. Your progress for this visit appears on the Progress page.", + byokTitle: "Bring your own key", + byokDesc: "Use a key from OpenAI, OpenRouter, Groq, DeepSeek, Together AI, Mistral or Xiaomi MiMo. It stays in your browser. Self-hosted copies can also use Ollama, llama.cpp or any OpenAI-compatible endpoint.", + byokCta: "Open settings", + honestNote: "AI output can contain mistakes. Check important facts with your textbook or teacher.", + }, + topicForm: { + topicLabel: "Topic", + examplesLabel: "Try", + example1: "Photosynthesis", + example2: "Fractions", + example3: "World War II", + example4: "SQL joins", + emptyTopic: "Enter a topic first.", + }, + quiz: { + title: "Quiz", + description: "Multiple-choice questions on any topic, with an explanation for every answer.", + formTitle: "Create a quiz", + placeholder: "e.g. Photosynthesis, Fractions, React hooks", + difficulty: "Difficulty", + questionCount: "Questions", + generate: "Generate quiz", + loading: "Writing your quiz…", + loadingHint: "This can take up to a minute, depending on the model.", + questionOf: "Question {current} of {total}", + easy: "Easy", + medium: "Medium", + hard: "Hard", + explanation: "Explanation", + correct: "Correct", + incorrect: "Not quite", + correctAnswerWas: "The answer is {answer}.", + seeResults: "See results", + completeTitle: "Quiz complete", + scoreLine: "{correct} of {total} correct", + scoreGreat: "Excellent work. You know this topic well.", + scoreGood: "Good progress. Review the questions you missed.", + scoreLow: "A good start. Try the flashcards for this topic, then take the quiz again.", + reviewTitle: "Review", + yourAnswer: "Your answer: {answer}", + tryAnother: "Try another topic", + retake: "Retake this quiz", + }, + flashcards: { + title: "Flashcards", + description: "Review a generated deck and rate each card. Ratings feed the SM-2 spaced-repetition schedule.", + formTitle: "Create a deck", + placeholder: "e.g. Photosynthesis, Cell biology, Data structures", + generate: "Generate flashcards", + loading: "Writing your flashcards…", + loadingHint: "This can take up to a minute, depending on the model.", + cardOf: "Card {current} of {total}", + tapToReveal: "Tap to show the answer", + answer: "Answer", + rateQuestion: "How well did you remember it?", + rateHard: "Hard", + rateGood: "Good", + rateEasy: "Easy", + completeTitle: "Deck reviewed", + completeDesc: "You reviewed {total} cards on {topic}.", + reviewAgain: "Review again", + }, + roadmap: { + title: "Learning roadmap", + description: "A step-by-step plan for a topic at your level, with time estimates and key ideas for each step.", + formTitle: "Plan a roadmap", + placeholder: "e.g. Photosynthesis, Web development, Statistics", + level: "Level", + beginner: "Beginner", + intermediate: "Intermediate", + advanced: "Advanced", + generate: "Generate roadmap", + loading: "Planning your roadmap…", + loadingHint: "This can take up to a minute, depending on the model.", + stepsCount: "{count} steps", + statusCompleted: "Completed", + statusCurrent: "Current step", + statusUpcoming: "Upcoming", + stepLabel: "Step {number}", + markDone: "Mark as done", + allDone: "Roadmap complete. Well done.", + newRoadmap: "New roadmap", + }, + progress: { + title: "Progress", + description: "Quizzes, flashcard decks and roadmaps you create during this visit. Nothing is saved after you close the tab.", + sessions: "Sessions", + studyTime: "Study time", + studyTimeValue: "{minutes} min", + tokens: "Tokens used", + weekly: "Activity in the last 7 days", + weeklyCaption: "Sessions per day", + byFeature: "Tokens by feature", + topics: "Topics", + topicLine: "{sessions} sessions · average score {score}%", + topicLineNoScore: "{sessions} sessions", + emptyTitle: "No sessions yet", + emptyDesc: "Generate a quiz, a flashcard deck or a roadmap and it will show up here.", + emptyCta: "Start a quiz", + featureQuiz: "Quiz", + featureFlashcards: "Flashcards", + featureRoadmap: "Roadmap", + featureConcepts: "Concept map", + featureChat: "Chat", + }, + days: { + sun: "Sun", + mon: "Mon", + tue: "Tue", + wed: "Wed", + thu: "Thu", + fri: "Fri", + sat: "Sat", + }, + concepts: { + title: "Concept map", + description: "How the main ideas of a subject connect, coloured by how well you know each one.", + mapTitle: "Programming fundamentals", + sampleNote: "This page shows fixed sample data. It is not generated from your sessions yet.", + legend: "Mastery", + strong: "Strong (80% and above)", + building: "Building (50–79%)", + weak: "Needs work (below 50%)", + linksTo: "Links to {list}", + variables: "Variables", + functions: "Functions", + dataTypes: "Data types", + controlFlow: "Control flow", + collections: "Collections", + oop: "Object-oriented programming", + }, + chat: { + reasoning: "Reasoning", + you: "You", + model: "AI", + }, + settings: { + title: "AI provider settings", + subtitle: "Bring your own API key to use the AI features.", + closeLabel: "Close settings", + serverMode: "This server has its own API key configured{detail}. There is nothing to set up here.", + provider: "Provider", + selfHostNote: "Self-hosted options need your own copy of LearnPath.", + baseUrl: "Base URL", + baseUrlDefault: "Leave empty to use {url}.", + baseUrlAny: "Any OpenAI-compatible endpoint, ending in /v1.", + model: "Model", + modelPlaceholder: "model name", + modelDefault: "Default for {provider}:", + useDefault: "use default", + apiKey: "API key", + optional: "(optional)", + keyPlaceholder: "Paste your key", + show: "Show", + hide: "Hide", + getKey: "Get an API key from {provider}", + privacyTitle: "Where your key goes", + privacyBody: "Your key is saved only in this browser. It is sent with each request through this app's API route to the provider you chose, and is never stored or logged on the server. Use a key with a spending limit, and press “Forget key” on a shared device.", + storageBlocked: "This browser blocked local storage, so the settings could not be saved (private mode?).", + keyForgotten: "Your key has been removed from this browser.", + forget: "Forget key", + }, + errors: { + title: "That did not work", + openSettings: "Open settings", + unknown: "Something went wrong. Please try again.", + network: "Could not reach LearnPath's server. Check your connection.", + http_error: "The request failed. Please try again.", + empty_stream: "The server returned an empty stream.", + missing_key: "Add your API key in Settings to use this feature.", + invalid_provider: "That provider is not supported. Choose another one in Settings.", + provider_not_allowed: "This provider is only available when you run LearnPath yourself.", + custom_base_url_not_allowed: "This server only talks to the built-in providers. Custom base URLs are disabled.", + invalid_base_url: "The base URL is not valid. Check it in Settings.", + invalid_model: "The model name contains characters that are not allowed.", + invalid_key_format: "That API key does not look valid.", + invalid_request: "The request was not valid. Check your input and try again.", + server_misconfigured: "This server is not set up correctly. Contact its owner.", + provider_rejected_key: "The provider rejected your key. Check the API key and provider in Settings.", + provider_payment_required: "The provider says this account has no credit or billing set up.", + provider_rate_limited: "The provider is rate limiting this key or its quota is used up. Wait a moment and try again.", + provider_model_not_found: "The provider could not find that model. Check the model name in Settings.", + provider_bad_request: "The provider refused the request. The model name may be wrong for this provider.", + provider_unavailable: "The provider is having trouble right now. Try again later.", + provider_unreachable: "Could not reach the provider. Check the provider in Settings.", + provider_timeout: "The provider took too long to answer. Try again.", + unreadable_output: "The model replied, but not in a format LearnPath could read. Try again or pick a stronger model.", + server_error: "Something went wrong on the server. Please try again.", + }, +}; diff --git a/src/i18n/messages/index.ts b/src/i18n/messages/index.ts new file mode 100644 index 0000000..6deb075 --- /dev/null +++ b/src/i18n/messages/index.ts @@ -0,0 +1,46 @@ +import type { Locale } from "../config"; +import { en } from "./en"; +import { ms } from "./ms"; +import type { MessageKey, Messages } from "./types"; + +export type { MessageKey, Messages }; + +export const MESSAGES: Record = { en, ms }; + +export type TranslateParams = Record; + +/** Look up a dotted key. Returns the key itself when it does not exist (never throws). */ +export function lookup(messages: Messages, key: string): string | undefined { + let node: unknown = messages; + for (const part of key.split(".")) { + if (!node || typeof node !== "object") return undefined; + node = (node as Record)[part]; + } + return typeof node === "string" ? node : undefined; +} + +/** Replace {name} placeholders. Unknown placeholders are left as they are. */ +export function interpolate(template: string, params?: TranslateParams): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (match, name: string) => + Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match, + ); +} + +export function translate(locale: Locale, key: MessageKey, params?: TranslateParams): string { + const text = lookup(MESSAGES[locale], key) ?? lookup(MESSAGES.en, key) ?? key; + return interpolate(text, params); +} + +/** Flatten nested messages into dotted keys (used by tests and tooling). */ +export function flattenMessages(node: unknown, prefix = ""): Record { + const out: Record = {}; + if (node && typeof node === "object") { + for (const [k, v] of Object.entries(node as Record)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === "string") out[key] = v; + else Object.assign(out, flattenMessages(v, key)); + } + } + return out; +} diff --git a/src/i18n/messages/ms.ts b/src/i18n/messages/ms.ts new file mode 100644 index 0000000..58a5035 --- /dev/null +++ b/src/i18n/messages/ms.ts @@ -0,0 +1,269 @@ +/** + * Teks antara muka Bahasa Melayu (ejaan baku Malaysia, bukan Indonesia). + * Mesti mempunyai kunci yang sama dengan `en.ts`. + */ +import type { Messages } from "./types"; + +export const ms: Messages = { + meta: { + title: "LearnPath: kuiz, kad imbas dan peta laluan pembelajaran dengan AI", + description: + "Jana kuiz, kad imbas dan peta laluan pembelajaran untuk apa-apa topik dengan LLM yang serasi OpenAI. Guna kunci API anda sendiri. Dalam Bahasa Melayu dan bahasa Inggeris.", + }, + common: { + appName: "LearnPath", + skipToContent: "Langkau ke kandungan", + tryAgain: "Cuba lagi", + newTopic: "Topik baharu", + close: "Tutup", + cancel: "Batal", + save: "Simpan", + done: "Selesai", + next: "Seterusnya", + tokens: "{count} token", + sampleData: "Data contoh", + }, + nav: { + label: "Navigasi utama", + home: "Utama", + quiz: "Kuiz", + flashcards: "Kad imbas", + roadmap: "Peta laluan", + progress: "Kemajuan", + concepts: "Konsep", + }, + header: { + homeLink: "Laman utama LearnPath", + settings: "Tetapan", + statusServer: "Kunci pelayan", + statusReady: "Sedia", + statusNeedsKey: "Masukkan kunci API", + language: "Bahasa", + themeToDark: "Tukar ke tema gelap", + themeToLight: "Tukar ke tema cerah", + }, + language: { + en: "English", + ms: "Bahasa Melayu", + enShort: "EN", + msShort: "BM", + }, + footer: { + source: "Kod sumber di GitHub", + builtBy: "Dibina oleh {name}", + tagline: "Buatan Malaysia", + }, + home: { + eyebrow: "Teman belajar berkuasa AI", + title: "Jadikan apa-apa topik pelan belajar yang boleh anda latih.", + lead: "Taip satu topik. LearnPath meminta model AI pilihan anda menyediakan kuiz, set kad imbas atau peta laluan langkah demi langkah, dalam Bahasa Melayu atau bahasa Inggeris.", + ctaQuiz: "Mula kuiz", + ctaRoadmap: "Rancang peta laluan", + featuresTitle: "Apa yang boleh anda buat", + quizTitle: "Kuiz", + quizDesc: "Soalan aneka pilihan pada tahap kesukaran yang anda pilih, setiap satu dengan penerangan.", + flashcardsTitle: "Kad imbas", + flashcardsDesc: "Set kad untuk diulang kaji, dinilai dengan kaedah ulangan berjarak SM-2.", + roadmapTitle: "Peta laluan pembelajaran", + roadmapDesc: "Pelan langkah demi langkah dari peringkat permulaan hingga lanjutan, dengan anggaran masa.", + progressTitle: "Kemajuan", + progressDesc: "Sesi, markah dan penggunaan token sepanjang lawatan ini.", + conceptsTitle: "Peta konsep", + conceptsDesc: "Peta contoh konsep yang berkait dan tahap penguasaannya.", + howTitle: "Cara ia berfungsi", + how1Title: "Pilih topik", + how1Desc: "Apa sahaja, daripada Fotosintesis hingga gabungan SQL.", + how2Title: "AI menyediakan draf", + how2Desc: "Model pilihan anda menulis soalan, kad atau langkah dalam bahasa anda.", + how3Title: "Latih dan ulang kaji", + how3Desc: "Jawab, balikkan kad dan beri penilaian. Kemajuan lawatan ini dipaparkan di halaman Kemajuan.", + byokTitle: "Guna kunci anda sendiri", + byokDesc: "Guna kunci daripada OpenAI, OpenRouter, Groq, DeepSeek, Together AI, Mistral atau Xiaomi MiMo. Kunci itu kekal dalam pelayar anda. Salinan yang dihos sendiri juga boleh menggunakan Ollama, llama.cpp atau mana-mana titik akhir yang serasi OpenAI.", + byokCta: "Buka tetapan", + honestNote: "Hasil AI mungkin mengandungi kesilapan. Semak fakta penting dengan buku teks atau guru anda.", + }, + topicForm: { + topicLabel: "Topik", + examplesLabel: "Cuba", + example1: "Fotosintesis", + example2: "Pecahan", + example3: "Sejarah Kemerdekaan Malaysia", + example4: "Gabungan SQL", + emptyTopic: "Masukkan topik dahulu.", + }, + quiz: { + title: "Kuiz", + description: "Soalan aneka pilihan untuk apa-apa topik, dengan penerangan bagi setiap jawapan.", + formTitle: "Sediakan kuiz", + placeholder: "cth. Fotosintesis, Pecahan, Sistem suria", + difficulty: "Tahap kesukaran", + questionCount: "Bilangan soalan", + generate: "Jana kuiz", + loading: "Sedang menulis kuiz anda…", + loadingHint: "Ini boleh mengambil masa sehingga seminit, bergantung pada model.", + questionOf: "Soalan {current} daripada {total}", + easy: "Mudah", + medium: "Sederhana", + hard: "Sukar", + explanation: "Penerangan", + correct: "Betul", + incorrect: "Kurang tepat", + correctAnswerWas: "Jawapannya ialah {answer}.", + seeResults: "Lihat keputusan", + completeTitle: "Kuiz selesai", + scoreLine: "{correct} daripada {total} betul", + scoreGreat: "Syabas. Anda menguasai topik ini dengan baik.", + scoreGood: "Kemajuan yang baik. Ulang kaji soalan yang anda tersilap.", + scoreLow: "Permulaan yang baik. Cuba kad imbas untuk topik ini, kemudian jawab kuiz sekali lagi.", + reviewTitle: "Semakan", + yourAnswer: "Jawapan anda: {answer}", + tryAnother: "Cuba topik lain", + retake: "Ulang kuiz ini", + }, + flashcards: { + title: "Kad imbas", + description: "Ulang kaji set kad yang dijana dan nilai setiap kad. Penilaian anda digunakan oleh jadual ulangan berjarak SM-2.", + formTitle: "Sediakan set kad", + placeholder: "cth. Fotosintesis, Biologi sel, Struktur data", + generate: "Jana kad imbas", + loading: "Sedang menulis kad imbas anda…", + loadingHint: "Ini boleh mengambil masa sehingga seminit, bergantung pada model.", + cardOf: "Kad {current} daripada {total}", + tapToReveal: "Ketik untuk melihat jawapan", + answer: "Jawapan", + rateQuestion: "Sejauh mana anda ingat?", + rateHard: "Sukar", + rateGood: "Baik", + rateEasy: "Mudah", + completeTitle: "Set kad selesai diulang kaji", + completeDesc: "Anda telah mengulang kaji {total} kad tentang {topic}.", + reviewAgain: "Ulang kaji semula", + }, + roadmap: { + title: "Peta laluan pembelajaran", + description: "Pelan langkah demi langkah untuk satu topik mengikut tahap anda, dengan anggaran masa dan idea utama bagi setiap langkah.", + formTitle: "Rancang peta laluan", + placeholder: "cth. Fotosintesis, Pembangunan web, Statistik", + level: "Tahap", + beginner: "Permulaan", + intermediate: "Pertengahan", + advanced: "Lanjutan", + generate: "Jana peta laluan", + loading: "Sedang merancang peta laluan anda…", + loadingHint: "Ini boleh mengambil masa sehingga seminit, bergantung pada model.", + stepsCount: "{count} langkah", + statusCompleted: "Selesai", + statusCurrent: "Langkah semasa", + statusUpcoming: "Akan datang", + stepLabel: "Langkah {number}", + markDone: "Tandakan selesai", + allDone: "Peta laluan selesai. Syabas.", + newRoadmap: "Peta laluan baharu", + }, + progress: { + title: "Kemajuan", + description: "Kuiz, set kad imbas dan peta laluan yang anda hasilkan sepanjang lawatan ini. Tiada apa-apa disimpan selepas anda menutup tab.", + sessions: "Sesi", + studyTime: "Masa belajar", + studyTimeValue: "{minutes} min", + tokens: "Token digunakan", + weekly: "Aktiviti 7 hari lepas", + weeklyCaption: "Sesi sehari", + byFeature: "Token mengikut ciri", + topics: "Topik", + topicLine: "{sessions} sesi · purata markah {score}%", + topicLineNoScore: "{sessions} sesi", + emptyTitle: "Belum ada sesi", + emptyDesc: "Jana kuiz, set kad imbas atau peta laluan dan ia akan dipaparkan di sini.", + emptyCta: "Mula kuiz", + featureQuiz: "Kuiz", + featureFlashcards: "Kad imbas", + featureRoadmap: "Peta laluan", + featureConcepts: "Peta konsep", + featureChat: "Sembang", + }, + days: { + sun: "Ahd", + mon: "Isn", + tue: "Sel", + wed: "Rab", + thu: "Kha", + fri: "Jum", + sat: "Sab", + }, + concepts: { + title: "Peta konsep", + description: "Bagaimana idea utama sesuatu subjek saling berkait, diwarnakan mengikut tahap penguasaan anda.", + mapTitle: "Asas pengaturcaraan", + sampleNote: "Halaman ini memaparkan data contoh yang tetap. Ia belum dijana daripada sesi anda.", + legend: "Penguasaan", + strong: "Kukuh (80% ke atas)", + building: "Sedang dibina (50–79%)", + weak: "Perlu diperbaiki (bawah 50%)", + linksTo: "Berkait dengan {list}", + variables: "Pemboleh ubah", + functions: "Fungsi", + dataTypes: "Jenis data", + controlFlow: "Aliran kawalan", + collections: "Koleksi", + oop: "Pengaturcaraan berorientasikan objek", + }, + chat: { + reasoning: "Penaakulan", + you: "Anda", + model: "AI", + }, + settings: { + title: "Tetapan penyedia AI", + subtitle: "Guna kunci API anda sendiri untuk menggunakan ciri AI.", + closeLabel: "Tutup tetapan", + serverMode: "Pelayan ini sudah mempunyai kunci API sendiri{detail}. Tiada apa-apa yang perlu disediakan di sini.", + provider: "Penyedia", + selfHostNote: "Pilihan hos sendiri memerlukan salinan LearnPath anda sendiri.", + baseUrl: "URL asas", + baseUrlDefault: "Biarkan kosong untuk menggunakan {url}.", + baseUrlAny: "Mana-mana titik akhir yang serasi OpenAI, berakhir dengan /v1.", + model: "Model", + modelPlaceholder: "nama model", + modelDefault: "Lalai untuk {provider}:", + useDefault: "guna lalai", + apiKey: "Kunci API", + optional: "(pilihan)", + keyPlaceholder: "Tampal kunci anda", + show: "Tunjuk", + hide: "Sorok", + getKey: "Dapatkan kunci API daripada {provider}", + privacyTitle: "Ke mana kunci anda pergi", + privacyBody: "Kunci anda disimpan dalam pelayar ini sahaja. Ia dihantar bersama setiap permintaan melalui laluan API aplikasi ini kepada penyedia yang anda pilih, dan tidak pernah disimpan atau dilog pada pelayan. Guna kunci yang mempunyai had perbelanjaan, dan tekan “Lupakan kunci” pada peranti yang dikongsi.", + storageBlocked: "Pelayar ini menyekat storan setempat, jadi tetapan tidak dapat disimpan (mod peribadi?).", + keyForgotten: "Kunci anda telah dibuang daripada pelayar ini.", + forget: "Lupakan kunci", + }, + errors: { + title: "Tidak berjaya", + openSettings: "Buka tetapan", + unknown: "Sesuatu tidak kena. Sila cuba lagi.", + network: "Tidak dapat menghubungi pelayan LearnPath. Semak sambungan internet anda.", + http_error: "Permintaan gagal. Sila cuba lagi.", + empty_stream: "Pelayan memulangkan strim kosong.", + missing_key: "Masukkan kunci API anda dalam Tetapan untuk menggunakan ciri ini.", + invalid_provider: "Penyedia itu tidak disokong. Pilih penyedia lain dalam Tetapan.", + provider_not_allowed: "Penyedia ini hanya tersedia apabila anda menjalankan LearnPath sendiri.", + custom_base_url_not_allowed: "Pelayan ini hanya berhubung dengan penyedia terbina dalam. URL asas tersuai dimatikan.", + invalid_base_url: "URL asas tidak sah. Semak dalam Tetapan.", + invalid_model: "Nama model mengandungi aksara yang tidak dibenarkan.", + invalid_key_format: "Kunci API itu nampaknya tidak sah.", + invalid_request: "Permintaan tidak sah. Semak input anda dan cuba lagi.", + server_misconfigured: "Pelayan ini belum dikonfigurasi dengan betul. Hubungi pemiliknya.", + provider_rejected_key: "Penyedia menolak kunci anda. Semak kunci API dan penyedia dalam Tetapan.", + provider_payment_required: "Penyedia menyatakan akaun ini tiada kredit atau pengebilan belum disediakan.", + provider_rate_limited: "Penyedia mengehadkan kadar permintaan untuk kunci ini atau kuotanya sudah habis. Tunggu sebentar dan cuba lagi.", + provider_model_not_found: "Penyedia tidak menjumpai model itu. Semak nama model dalam Tetapan.", + provider_bad_request: "Penyedia menolak permintaan ini. Nama model mungkin salah untuk penyedia ini.", + provider_unavailable: "Penyedia sedang menghadapi masalah. Cuba lagi sebentar nanti.", + provider_unreachable: "Tidak dapat menghubungi penyedia. Semak penyedia dalam Tetapan.", + provider_timeout: "Penyedia mengambil masa terlalu lama untuk menjawab. Cuba lagi.", + unreadable_output: "Model telah menjawab, tetapi dalam format yang tidak dapat dibaca oleh LearnPath. Cuba lagi atau pilih model yang lebih berkebolehan.", + server_error: "Berlaku ralat pada pelayan. Sila cuba lagi.", + }, +}; diff --git a/src/i18n/messages/types.ts b/src/i18n/messages/types.ts new file mode 100644 index 0000000..773aa80 --- /dev/null +++ b/src/i18n/messages/types.ts @@ -0,0 +1,13 @@ +import type { en } from "./en"; + +/** Same shape as the English messages, with every leaf widened to `string`. */ +type Widen = { [K in keyof T]: T[K] extends string ? string : Widen }; + +export type Messages = Widen; + +/** Every dotted key path that ends at a string, e.g. "quiz.generate". */ +export type MessageKey = Paths; + +type Paths = { + [K in keyof T & string]: T[K] extends string ? `${P}${K}` : Paths; +}[keyof T & string]; diff --git a/src/i18n/server.ts b/src/i18n/server.ts new file mode 100644 index 0000000..93f0047 --- /dev/null +++ b/src/i18n/server.ts @@ -0,0 +1,9 @@ +import { cookies, headers } from "next/headers"; +import { LOCALE_COOKIE, detectLocale, isLocale, parseAcceptLanguage, type Locale } from "./config"; + +/** Locale for the first server render: the visitor's saved choice, else their browser language. */ +export async function getRequestLocale(): Promise { + const saved = (await cookies()).get(LOCALE_COOKIE)?.value; + if (isLocale(saved)) return saved; + return detectLocale(parseAcceptLanguage((await headers()).get("accept-language"))); +} diff --git a/tests/i18n/locale.test.ts b/tests/i18n/locale.test.ts new file mode 100644 index 0000000..d271ae3 --- /dev/null +++ b/tests/i18n/locale.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { LOCALE_STORAGE_KEY, detectLocale, parseAcceptLanguage } from "@/i18n/config"; +import { getClientLocale, resetLocaleMemory, setClientLocale } from "@/i18n/locale-store"; + +function setLanguages(langs: string[]) { + vi.spyOn(navigator, "languages", "get").mockReturnValue(langs); +} + +describe("detectLocale", () => { + it("maps Malay tags to BM and everything else to English", () => { + expect(detectLocale(["ms-MY", "en"])).toBe("ms"); + expect(detectLocale(["ms"])).toBe("ms"); + expect(detectLocale(["en-GB", "ms"])).toBe("en"); + expect(detectLocale(["zh-CN", "ms-MY"])).toBe("ms"); + expect(detectLocale(["id-ID"])).toBe("en"); + expect(detectLocale([])).toBe("en"); + }); + + it("reads Accept-Language by q-value", () => { + expect(parseAcceptLanguage("en;q=0.5, ms-MY;q=0.9, zh")).toEqual(["zh", "ms-MY", "en"]); + expect(detectLocale(parseAcceptLanguage("en-US;q=0.4,ms;q=0.8"))).toBe("ms"); + expect(parseAcceptLanguage(null)).toEqual([]); + }); +}); + +describe("client locale store", () => { + beforeEach(() => { + localStorage.clear(); + resetLocaleMemory(); + document.cookie = "learnpath-locale=; max-age=0; path=/"; + }); + afterEach(() => vi.restoreAllMocks()); + + it("defaults to the browser language (ms → BM)", () => { + setLanguages(["ms-MY", "en-US"]); + expect(getClientLocale()).toBe("ms"); + }); + + it("defaults to English for other browser languages", () => { + setLanguages(["fr-FR"]); + expect(getClientLocale()).toBe("en"); + }); + + it("persists the choice in localStorage and mirrors it to a cookie", () => { + setLanguages(["en-US"]); + expect(setClientLocale("ms")).toBe(true); + expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe("ms"); + expect(document.cookie).toContain("learnpath-locale=ms"); + resetLocaleMemory(); + expect(getClientLocale()).toBe("ms"); + }); + + it("still switches when localStorage throws (private mode)", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("SecurityError"); + }); + expect(setClientLocale("ms")).toBe(false); + expect(getClientLocale()).toBe("ms"); + }); + + it("ignores garbage in storage", () => { + setLanguages(["en"]); + localStorage.setItem(LOCALE_STORAGE_KEY, "klingon"); + expect(getClientLocale()).toBe("en"); + }); +}); diff --git a/tests/i18n/messages.test.ts b/tests/i18n/messages.test.ts new file mode 100644 index 0000000..b639ee9 --- /dev/null +++ b/tests/i18n/messages.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { MESSAGES, flattenMessages, interpolate, translate } from "@/i18n/messages"; +import { LOCALES } from "@/i18n/config"; + +const flat = Object.fromEntries(LOCALES.map((l) => [l, flattenMessages(MESSAGES[l])])) as Record< + (typeof LOCALES)[number], + Record +>; + +const placeholders = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort(); + +describe("language files", () => { + it("contain a meaningful number of keys", () => { + expect(Object.keys(flat.en).length).toBeGreaterThan(100); + }); + + for (const locale of LOCALES) { + for (const other of LOCALES) { + if (locale === other) continue; + it(`every ${locale} key exists in ${other}`, () => { + const missing = Object.keys(flat[locale]).filter((k) => !(k in flat[other])); + expect(missing).toEqual([]); + }); + } + + it(`${locale} has no empty strings`, () => { + const empty = Object.entries(flat[locale]).filter(([, v]) => !v.trim()); + expect(empty).toEqual([]); + }); + } + + it("uses the same {placeholders} in both languages", () => { + const mismatched = Object.keys(flat.en).filter( + (k) => JSON.stringify(placeholders(flat.en[k])) !== JSON.stringify(placeholders(flat.ms[k] ?? "")), + ); + expect(mismatched).toEqual([]); + }); + + it("BM text avoids common Indonesian words", () => { + // Malaysian standard alternatives: hantar, muat turun, ketika/semasa, kerana, mesti/perlu, + // wang, percuma, sila, berjaya, butang, tetapan, kata laluan, boleh, nombor, cuba. + const indonesian = [ + "kirim", "unduh", "saat", "karena", "harus", "uang", "gratis", "silakan", "berhasil", + "tombol", "pengaturan", "sandi", "bisa", "nomor", "coba", "mencoba", "kamu", "aplikasinya", + ]; + const hits: string[] = []; + for (const [key, text] of Object.entries(flat.ms)) { + for (const word of indonesian) { + if (new RegExp(`(^|[^\\p{L}])${word}([^\\p{L}]|$)`, "iu").test(text)) hits.push(`${key}: ${word}`); + } + } + expect(hits).toEqual([]); + }); + + it("BM strings are actually translated (not copied from English)", () => { + // Brand names, abbreviations and pure numbers may be identical. + const allowedSame = new Set(["common.appName", "language.en", "language.ms", "language.enShort", "language.msShort", "chat.model", "settings.model", "progress.studyTimeValue"]); + const same = Object.keys(flat.en).filter((k) => flat.en[k] === flat.ms[k] && !allowedSame.has(k)); + expect(same).toEqual([]); + }); +}); + +describe("translate()", () => { + it("returns the string for the locale and fills placeholders", () => { + expect(translate("en", "quiz.questionOf", { current: 2, total: 5 })).toBe("Question 2 of 5"); + expect(translate("ms", "quiz.questionOf", { current: 2, total: 5 })).toBe("Soalan 2 daripada 5"); + }); + + it("uses the agreed Malaysian terms", () => { + expect(translate("ms", "nav.flashcards")).toBe("Kad imbas"); + expect(translate("ms", "nav.quiz")).toBe("Kuiz"); + expect(translate("ms", "home.roadmapTitle")).toBe("Peta laluan pembelajaran"); + expect(translate("ms", "header.settings")).toBe("Tetapan"); + expect(translate("ms", "settings.apiKey")).toBe("Kunci API"); + }); + + it("falls back to the key for an unknown key instead of throwing", () => { + expect(translate("ms", "nope.missing" as never)).toBe("nope.missing"); + }); + + it("leaves unknown placeholders untouched", () => { + expect(interpolate("Hi {name} {other}", { name: "Aina" })).toBe("Hi Aina {other}"); + }); +}); From 777cf2100411d3252fb510ae471f5e8a547f0e26 Mon Sep 17 00:00:00 2001 From: Muhammad Aiman <84276911+aimanmalib@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:46:31 +0000 Subject: [PATCH 2/4] feat(api): the model answers in the selected language Every API call sends `language: "en" | "ms"`. Each route appends a language instruction to its system prompt and a short reminder to the user message. The BM instruction asks for standard Malaysian spelling and names Indonesian words to avoid. Unknown values fall back to English. Tests cover every route (quiz, flashcards, roadmap, chat, analyze) and the browser client. Co-Authored-By: Claude Opus 5.5 --- src/app/api/analyze/route.ts | 7 ++-- src/app/api/chat/route.ts | 11 ++++-- src/app/api/flashcards/route.ts | 6 ++-- src/app/api/quiz/route.ts | 6 ++-- src/app/api/roadmap/route.ts | 7 ++-- src/hooks/useChat.ts | 6 ++-- src/lib/api-client.ts | 20 +++++++++-- src/lib/llm/language.ts | 41 ++++++++++++++++++++++ tests/api/routes.test.ts | 62 +++++++++++++++++++++++++++++++++ tests/lib/api-client.test.ts | 30 ++++++++++++++++ 10 files changed, 178 insertions(+), 18 deletions(-) create mode 100644 src/lib/llm/language.ts create mode 100644 tests/lib/api-client.test.ts diff --git a/src/app/api/analyze/route.ts b/src/app/api/analyze/route.ts index 6dbd623..005119f 100644 --- a/src/app/api/analyze/route.ts +++ b/src/app/api/analyze/route.ts @@ -1,15 +1,16 @@ import { NextResponse } from "next/server"; import { chatCompletion } from "@/lib/llm/client"; import { handleLlmRoute, textField } from "@/lib/llm/server"; +import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language"; export async function POST(request: Request) { return handleLlmRoute(request, async ({ llm, body }) => { const topic = textField(body.topic, 200, "general"); const sessions = Array.isArray(body.sessions) ? body.sessions.slice(-20) : []; const sessionSummary = JSON.stringify(sessions).slice(0, 8000); + const language = outputLanguage(body.language); - const systemPrompt = - "You are a learning analytics expert. Analyze study session data and provide insights and recommendations for improvement. Be specific and actionable."; + const systemPrompt = `You are a learning analytics expert. Analyze study session data and provide insights and recommendations for improvement. Be specific and actionable. ${languageInstruction(language)}`; const result = await chatCompletion( llm, @@ -17,7 +18,7 @@ export async function POST(request: Request) { { role: "system", content: systemPrompt }, { role: "user", - content: `Analyze these study sessions for topic ${JSON.stringify(topic)}: ${sessionSummary}. Provide: 1) Performance summary 2) Weak areas 3) Recommended next steps`, + content: `Analyze these study sessions for topic ${JSON.stringify(topic)}: ${sessionSummary}. Provide: 1) Performance summary 2) Weak areas 3) Recommended next steps. ${languageReminder(language)}`, }, ], { temperature: 0.5, maxTokens: 2048 }, diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 946537b..5eb1f49 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -2,16 +2,21 @@ import { NextResponse } from "next/server"; import { chatCompletion, chatCompletionStream, type ChatMessage } from "@/lib/llm/client"; import { handleLlmRoute, textField } from "@/lib/llm/server"; import { LlmError } from "@/lib/llm/errors"; +import { languageInstruction, outputLanguage } from "@/lib/llm/language"; export async function POST(request: Request) { return handleLlmRoute(request, async ({ llm, body }) => { const message = textField(body.message, 8000); if (!message) throw new LlmError("invalid_request", "Message is empty.", 400); const systemPrompt = textField(body.systemPrompt, 4000); + const language = outputLanguage(body.language); - const messages: ChatMessage[] = []; - if (systemPrompt) messages.push({ role: "system", content: systemPrompt }); - messages.push({ role: "user", content: message }); + // The language instruction always goes first; a caller's system prompt follows it. + const system = [languageInstruction(language), systemPrompt].filter(Boolean).join("\n\n"); + const messages: ChatMessage[] = [ + { role: "system", content: system }, + { role: "user", content: message }, + ]; if (body.stream === true) { const stream = await chatCompletionStream(llm, messages, { temperature: 0.7, maxTokens: 2048, timeoutMs: 120_000 }); diff --git a/src/app/api/flashcards/route.ts b/src/app/api/flashcards/route.ts index 850361c..54e1518 100644 --- a/src/app/api/flashcards/route.ts +++ b/src/app/api/flashcards/route.ts @@ -2,15 +2,17 @@ import { NextResponse } from "next/server"; import { chatCompletion } from "@/lib/llm/client"; import { extractJson, handleLlmRoute, intField, textField, unreadable } from "@/lib/llm/server"; import { LlmError } from "@/lib/llm/errors"; +import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language"; export async function POST(request: Request) { return handleLlmRoute(request, async ({ llm, body }) => { const topic = textField(body.topic, 200); if (!topic) throw new LlmError("invalid_request", "Enter a topic first.", 400); const count = intField(body.count, 1, 20, 10); + const language = outputLanguage(body.language); - const systemPrompt = `Generate ${count} study flashcards about the user's topic. Return ONLY a valid JSON array, no prose.`; - const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"fc1","front":"question or concept","back":"answer or definition"}]`; + const systemPrompt = `Generate ${count} study flashcards about the user's topic. Return ONLY a valid JSON array, no prose. ${languageInstruction(language)}`; + const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"fc1","front":"question or concept","back":"answer or definition"}]. ${languageReminder(language)}`; const result = await chatCompletion( llm, diff --git a/src/app/api/quiz/route.ts b/src/app/api/quiz/route.ts index 996d6ce..5e1a103 100644 --- a/src/app/api/quiz/route.ts +++ b/src/app/api/quiz/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { chatCompletion } from "@/lib/llm/client"; import { extractJson, handleLlmRoute, intField, oneOf, textField, unreadable } from "@/lib/llm/server"; import { LlmError } from "@/lib/llm/errors"; +import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language"; const DIFFICULTIES = ["easy", "medium", "hard"] as const; @@ -21,9 +22,10 @@ export async function POST(request: Request) { if (!topic) throw new LlmError("invalid_request", "Enter a topic first.", 400); const count = intField(body.count, 1, 10, 5); const difficulty = oneOf(body.difficulty, DIFFICULTIES, "medium"); + const language = outputLanguage(body.language); - const systemPrompt = `You are LearnPath's quiz generator. Create ${count} multiple-choice questions about the user's topic at ${difficulty} difficulty. Each question must have exactly 4 options with one correct answer. Return ONLY a valid JSON array, no prose.`; - const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"q1","question":"...","options":["A","B","C","D"],"correctIndex":0,"explanation":"..."}]`; + const systemPrompt = `You are LearnPath's quiz generator. Create ${count} multiple-choice questions about the user's topic at ${difficulty} difficulty. Each question must have exactly 4 options with one correct answer. Return ONLY a valid JSON array, no prose. ${languageInstruction(language)}`; + const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"q1","question":"...","options":["A","B","C","D"],"correctIndex":0,"explanation":"..."}]. ${languageReminder(language)}`; const result = await chatCompletion( llm, diff --git a/src/app/api/roadmap/route.ts b/src/app/api/roadmap/route.ts index fe71ba9..30f1d36 100644 --- a/src/app/api/roadmap/route.ts +++ b/src/app/api/roadmap/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { chatCompletion } from "@/lib/llm/client"; import { extractJson, handleLlmRoute, oneOf, textField, unreadable } from "@/lib/llm/server"; import { LlmError } from "@/lib/llm/errors"; +import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language"; const LEVELS = ["beginner", "intermediate", "advanced"] as const; @@ -10,9 +11,11 @@ export async function POST(request: Request) { const topic = textField(body.topic, 200); if (!topic) throw new LlmError("invalid_request", "Enter a topic first.", 400); const level = oneOf(body.level, LEVELS, "beginner"); + const language = outputLanguage(body.language); + const exampleTime = language === "ms" ? "2 jam" : "2 hours"; - const systemPrompt = `You are a learning path designer. Create a structured ${level}-level roadmap for the user's topic. Break it into clear steps with estimated times. Return ONLY valid JSON, no prose.`; - const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: {"title":"...","steps":[{"id":"s1","title":"...","description":"...","estimatedTime":"2 hours","topics":["a","b"]}]}`; + const systemPrompt = `You are a learning path designer. Create a structured ${level}-level roadmap for the user's topic. Break it into clear steps with estimated times. Return ONLY valid JSON, no prose. ${languageInstruction(language)}`; + const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: {"title":"...","steps":[{"id":"s1","title":"...","description":"...","estimatedTime":"${exampleTime}","topics":["a","b"]}]}. ${languageReminder(language)}`; const result = await chatCompletion( llm, diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts index ce08cc2..94ab908 100644 --- a/src/hooks/useChat.ts +++ b/src/hooks/useChat.ts @@ -1,6 +1,6 @@ "use client"; import { useCallback, useState } from "react"; -import { ApiError, apiHeaders, openSettings, postJson, toApiError } from "@/lib/api-client"; +import { ApiError, apiHeaders, openSettings, postJson, toApiError, withLanguage } from "@/lib/api-client"; import { parseSSEStream } from "@/lib/sse-parser"; interface ChatState { @@ -52,11 +52,11 @@ export function useChat() { const res = await fetch("/api/chat", { method: "POST", headers: apiHeaders(), - body: JSON.stringify({ message, systemPrompt, stream: true }), + body: JSON.stringify(withLanguage({ message, systemPrompt, stream: true })), }); if (!res.ok) { const err = await toApiError(res); - if (err.code === "missing_key") openSettings(err.message); + if (err.code === "missing_key") openSettings(err.code); throw err; } if (!res.body) throw new ApiError("empty_stream", "The server returned an empty stream.", res.status); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 7a1583e..ef3973c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -3,6 +3,8 @@ * provider settings attached as headers. */ import { loadSettings, settingsHeaders } from "./llm/settings"; +import { getClientLocale } from "@/i18n/locale-store"; +import type { Locale } from "@/i18n/config"; export const OPEN_SETTINGS_EVENT = "learnpath:open-settings"; @@ -35,6 +37,10 @@ export class ApiError extends Error { } } +/** + * Open the settings panel. `reason` is an error code (e.g. "missing_key"); + * the panel shows the matching message in the visitor's language. + */ export function openSettings(reason?: string): void { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(OPEN_SETTINGS_EVENT, { detail: { reason } })); @@ -58,16 +64,24 @@ export function apiHeaders(): Record { return { "Content-Type": "application/json", ...settingsHeaders(loadSettings()) }; } -export async function postJson(path: string, body: unknown, fetchImpl: typeof fetch = fetch): Promise { +/** + * Request body with the UI language added, so the model answers in the + * language the visitor is reading. An explicit `language` in `body` wins. + */ +export function withLanguage(body: T, locale: Locale = getClientLocale()): T & { language: Locale } { + return { language: locale, ...body }; +} + +export async function postJson(path: string, body: object, fetchImpl: typeof fetch = fetch): Promise { let res: Response; try { - res = await fetchImpl(path, { method: "POST", headers: apiHeaders(), body: JSON.stringify(body) }); + res = await fetchImpl(path, { method: "POST", headers: apiHeaders(), body: JSON.stringify(withLanguage(body)) }); } catch { throw new ApiError("network", "Could not reach LearnPath's server. Check your connection.", 0); } if (!res.ok) { const err = await toApiError(res); - if (err.code === "missing_key") openSettings(err.message); + if (err.code === "missing_key") openSettings(err.code); throw err; } return (await res.json()) as T; diff --git a/src/lib/llm/language.ts b/src/lib/llm/language.ts new file mode 100644 index 0000000..d132a0b --- /dev/null +++ b/src/lib/llm/language.ts @@ -0,0 +1,41 @@ +/** + * Output language for the model. + * + * Every API route reads `language` from the request body ("en" or "ms") and + * adds the instruction below to its prompts, so quizzes, flashcards and + * roadmaps come back in the language the visitor is reading. + * + * The Malay instruction asks for standard Malaysian spelling and lists common + * Indonesian words to avoid: models trained mostly on Indonesian web text + * drift into it otherwise. + */ +import { DEFAULT_LOCALE, isLocale, type Locale } from "@/i18n/config"; + +export function outputLanguage(value: unknown): Locale { + return isLocale(value) ? value : DEFAULT_LOCALE; +} + +export const LANGUAGE_NAMES: Record = { + en: "English", + ms: "Bahasa Melayu (Malaysian Malay)", +}; + +const INSTRUCTIONS: Record = { + en: "Write every piece of text meant for the learner in English.", + ms: [ + "Write every piece of text meant for the learner in Bahasa Melayu as used in Malaysia, with standard Dewan Bahasa dan Pustaka spelling.", + "Do not use Indonesian words or spelling. For example write: kerana (not karena), hantar (not kirim), muat turun (not unduh), semasa or ketika (not saat), mesti or perlu (not harus), wang (not uang), percuma (not gratis), boleh (not bisa), berjaya (not berhasil), nombor (not nomor), cuba (not coba), sila (not silakan).", + "Keep standard scientific and technical terms that Malaysian textbooks use.", + ].join(" "), +}; + +/** Sentence appended to a system prompt. */ +export function languageInstruction(locale: Locale): string { + const keepJson = "If the answer is JSON, keep the JSON keys exactly as specified in English; only the values are written in the chosen language."; + return `${INSTRUCTIONS[locale]} ${keepJson}`; +} + +/** Short reminder placed in the user message as well, for models that weigh system prompts lightly. */ +export function languageReminder(locale: Locale): string { + return `Answer language: ${LANGUAGE_NAMES[locale]}.`; +} diff --git a/tests/api/routes.test.ts b/tests/api/routes.test.ts index debc56c..7a97bd6 100644 --- a/tests/api/routes.test.ts +++ b/tests/api/routes.test.ts @@ -186,3 +186,65 @@ describe("GET /api/llm-config", () => { expect(text).not.toContain(ENV_KEY); }); }); + +describe("output language reaches the model", () => { + const h = { "x-llm-key": HDR_KEY }; + + function sentMessages(): { role: string; content: string }[] { + return JSON.parse(fetchMock.mock.calls[0][1].body).messages; + } + + const cases: [string, (r: Request) => Promise, Record, string][] = [ + ["quiz", quiz, { topic: "Fotosintesis" }, quizJson], + ["flashcards", flashcards, { topic: "Fotosintesis" }, '[{"front":"Q","back":"A"}]'], + ["roadmap", roadmap, { topic: "Fotosintesis" }, '{"title":"T","steps":[{"title":"S"}]}'], + ["chat", chat, { message: "Apa itu klorofil?" }, "ok"], + ["analyze", analyze, { topic: "Fotosintesis", sessions: [] }, "ok"], + ]; + + for (const [name, route, body, reply] of cases) { + it(`${name}: language "ms" asks for Malaysian Malay and warns against Indonesian`, async () => { + fetchMock.mockResolvedValue(completion(reply)); + const res = await route(req({ ...body, language: "ms" }, h)); + expect(res.status).toBe(200); + const messages = sentMessages(); + const system = messages.find((m) => m.role === "system")?.content ?? ""; + expect(system).toContain("Bahasa Melayu as used in Malaysia"); + expect(system).toContain("Do not use Indonesian"); + expect(system).toContain("kerana (not karena)"); + expect(system).not.toContain("in English."); + }); + + it(`${name}: language "en" (and the default) asks for English`, async () => { + fetchMock.mockResolvedValue(completion(reply)); + await route(req({ ...body, language: "en" }, h)); + const system = sentMessages().find((m) => m.role === "system")?.content ?? ""; + expect(system).toContain("meant for the learner in English"); + expect(system).not.toContain("Bahasa Melayu"); + + fetchMock.mockClear(); + fetchMock.mockResolvedValue(completion(reply)); + await route(req({ ...body, language: "xx-unknown" }, h)); + expect(sentMessages().find((m) => m.role === "system")?.content).toContain("in English"); + }); + } + + it("repeats the language in the user message for generator routes", async () => { + await quiz(req({ topic: "Fotosintesis", language: "ms" }, h)); + expect(sentMessages().find((m) => m.role === "user")?.content).toContain("Answer language: Bahasa Melayu (Malaysian Malay)."); + }); + + it("chat keeps the caller's system prompt after the language instruction", async () => { + fetchMock.mockResolvedValue(completion("ok")); + await chat(req({ message: "hai", systemPrompt: "You are a tutor.", language: "ms" }, h)); + const system = sentMessages()[0]; + expect(system.role).toBe("system"); + expect(system.content.indexOf("Bahasa Melayu")).toBeLessThan(system.content.indexOf("You are a tutor.")); + }); + + it("roadmap's format example uses Malay time units in BM", async () => { + fetchMock.mockResolvedValue(completion('{"title":"T","steps":[{"title":"S"}]}')); + await roadmap(req({ topic: "Fotosintesis", language: "ms" }, h)); + expect(sentMessages().find((m) => m.role === "user")?.content).toContain('"estimatedTime":"2 jam"'); + }); +}); diff --git a/tests/lib/api-client.test.ts b/tests/lib/api-client.test.ts new file mode 100644 index 0000000..422b812 --- /dev/null +++ b/tests/lib/api-client.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { postJson, withLanguage } from "@/lib/api-client"; +import { resetLocaleMemory, setClientLocale } from "@/i18n/locale-store"; + +function okFetch() { + return vi.fn().mockImplementation(async () => new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } })); +} + +describe("API client language", () => { + beforeEach(() => { + localStorage.clear(); + resetLocaleMemory(); + }); + afterEach(() => vi.restoreAllMocks()); + + it("sends the selected UI language with every request", async () => { + setClientLocale("ms"); + const fetchImpl = okFetch(); + await postJson("/api/quiz", { topic: "Fotosintesis" }, fetchImpl); + expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({ topic: "Fotosintesis", language: "ms" }); + + setClientLocale("en"); + await postJson("/api/roadmap", { topic: "Photosynthesis" }, fetchImpl); + expect(JSON.parse(fetchImpl.mock.calls[1][1].body).language).toBe("en"); + }); + + it("lets an explicit language in the body win", () => { + expect(withLanguage({ language: "en" }, "ms")).toEqual({ language: "en" }); + }); +}); From 8f9a8e1d92c4223e77a0b8d242775b3a4a6405ab Mon Sep 17 00:00:00 2001 From: Muhammad Aiman <84276911+aimanmalib@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:46:51 +0000 Subject: [PATCH 3/4] feat(ui): product-grade design system, light/dark themes, BM/EN screens - Colour tokens in globals.css for light and dark (prefers-color-scheme plus a toggle that applies before first paint); Tailwind's default palette is switched off so every colour goes through a token. A test computes WCAG AA contrast for every text/background pair in both themes. - Fixes the roadmap contrast bug: upcoming step numbers were white on light grey (1.2:1, Tailwind gray-200); they are now muted ink on the surface (7.7:1 light, 8.2:1 dark). - lucide-react icons instead of emoji, Inter + Fraunces via next/font, desktop nav plus a mobile bottom tab bar, BM | EN switch. - Shared topic form with example topics, skeleton loading states, localised error notices with retry, empty states, focus styles, skip link, focus trap in the settings dialog (now portalled to body). - Quiz: difficulty and length options, per-question feedback with the explanation, results with a review list. Flashcards: card grows with its text, end-of-deck summary. Roadmap: "Mark as done" moves the current step forward. - A test fails on hard-coded words in JSX text. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 16 + package.json | 1 + src/app/concepts/page.tsx | 34 ++- src/app/flashcards/page.tsx | 174 +++++++---- src/app/globals.css | 379 +++++++++++++++++++++++- src/app/layout.tsx | 65 ++-- src/app/page.tsx | 143 +++++++-- src/app/progress/page.tsx | 165 +++++++---- src/app/quiz/page.tsx | 268 +++++++++++++---- src/app/roadmap/page.tsx | 126 +++++--- src/components/AppHeader.tsx | 93 ++++-- src/components/ChatBubble.tsx | 23 +- src/components/ConceptMap.tsx | 67 +++-- src/components/ErrorNotice.tsx | 38 ++- src/components/FlashCard.tsx | 45 +-- src/components/GeneratingState.tsx | 47 +++ src/components/LanguageSwitch.tsx | 32 ++ src/components/LoadingSpinner.tsx | 10 +- src/components/Logo.tsx | 19 ++ src/components/MobileNav.tsx | 47 +++ src/components/ProgressChart.tsx | 60 +++- src/components/QuizCard.tsx | 124 +++++--- src/components/RoadmapNode.tsx | 106 ++++--- src/components/SettingsDialog.tsx | 189 +++++++----- src/components/SiteFooter.tsx | 28 ++ src/components/ThemeToggle.tsx | 26 ++ src/components/TokenUsageBar.tsx | 11 +- src/components/TopicForm.tsx | 88 ++++++ src/components/ui/EmptyState.tsx | 24 ++ src/components/ui/PageHeader.tsx | 25 ++ src/components/ui/PageShell.tsx | 19 ++ src/components/ui/ProgressBar.tsx | 15 + src/components/ui/SegmentedControl.tsx | 45 +++ src/lib/nav.ts | 25 ++ src/lib/theme.ts | 78 +++++ tests/components/ProgressChart.test.tsx | 11 +- tests/components/QuizCard.test.tsx | 8 +- tests/components/RoadmapNode.test.tsx | 25 +- tests/design/contrast.test.ts | 65 ++++ tests/i18n/no-hardcoded-text.test.ts | 37 +++ 40 files changed, 2251 insertions(+), 550 deletions(-) create mode 100644 src/components/GeneratingState.tsx create mode 100644 src/components/LanguageSwitch.tsx create mode 100644 src/components/Logo.tsx create mode 100644 src/components/MobileNav.tsx create mode 100644 src/components/SiteFooter.tsx create mode 100644 src/components/ThemeToggle.tsx create mode 100644 src/components/TopicForm.tsx create mode 100644 src/components/ui/EmptyState.tsx create mode 100644 src/components/ui/PageHeader.tsx create mode 100644 src/components/ui/PageShell.tsx create mode 100644 src/components/ui/ProgressBar.tsx create mode 100644 src/components/ui/SegmentedControl.tsx create mode 100644 src/lib/nav.ts create mode 100644 src/lib/theme.ts create mode 100644 tests/design/contrast.test.ts create mode 100644 tests/i18n/no-hardcoded-text.test.ts diff --git a/package-lock.json b/package-lock.json index 2892026..e847d49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.0", "dependencies": { "clsx": "^2.1.1", + "lucide-react": "1.52.0", "next": "^16.4.2", "react": "^19.3.1", "react-dom": "^19.3.1", @@ -6269,6 +6270,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz", + "integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/package.json b/package.json index 88a1b65..fd84816 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "clsx": "^2.1.1", + "lucide-react": "1.52.0", "next": "^16.4.2", "react": "^19.3.1", "react-dom": "^19.3.1", diff --git a/src/app/concepts/page.tsx b/src/app/concepts/page.tsx index 9cc81e8..4af8d55 100644 --- a/src/app/concepts/page.tsx +++ b/src/app/concepts/page.tsx @@ -1,23 +1,31 @@ "use client"; +import { Info, Network } from "lucide-react"; import { ConceptMap } from "@/components/ConceptMap"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { PageShell } from "@/components/ui/PageShell"; +import { useI18n } from "@/i18n/I18nProvider"; +import type { MessageKey } from "@/i18n/messages"; -const sampleConcepts = [ - { id: "1", label: "Variables", connections: ["2", "3"], mastery: 90 }, - { id: "2", label: "Functions", connections: ["1", "4"], mastery: 75 }, - { id: "3", label: "Data Types", connections: ["1", "5"], mastery: 85 }, - { id: "4", label: "Control Flow", connections: ["2", "6"], mastery: 60 }, - { id: "5", label: "Collections", connections: ["3", "6"], mastery: 45 }, - { id: "6", label: "OOP", connections: ["4", "5"], mastery: 30 }, +/** Fixed sample data (labels come from the language files). */ +const SAMPLE: { id: string; label: MessageKey; connections: string[]; mastery: number }[] = [ + { id: "1", label: "concepts.variables", connections: ["2", "3"], mastery: 90 }, + { id: "2", label: "concepts.functions", connections: ["1", "4"], mastery: 75 }, + { id: "3", label: "concepts.dataTypes", connections: ["1", "5"], mastery: 85 }, + { id: "4", label: "concepts.controlFlow", connections: ["2", "6"], mastery: 60 }, + { id: "5", label: "concepts.collections", connections: ["3", "6"], mastery: 45 }, + { id: "6", label: "concepts.oop", connections: ["4", "5"], mastery: 30 }, ]; export default function ConceptsPage() { + const { t } = useI18n(); return ( -
-

🧠 Concept Map

- -

- Sample data. The concept map is a static demo and is not generated from your sessions yet. + + +

+

-
+ ({ ...c, label: t(c.label) }))} title={t("concepts.mapTitle")} /> + ); } diff --git a/src/app/flashcards/page.tsx b/src/app/flashcards/page.tsx index 39ad1df..97ca381 100644 --- a/src/app/flashcards/page.tsx +++ b/src/app/flashcards/page.tsx @@ -1,24 +1,45 @@ "use client"; -import { useCallback, useState } from "react"; +import { useState } from "react"; +import { GalleryVerticalEnd, RotateCcw } from "lucide-react"; import { FlashCard } from "@/components/FlashCard"; -import { LoadingSpinner } from "@/components/LoadingSpinner"; import { ErrorNotice } from "@/components/ErrorNotice"; +import { GeneratingState } from "@/components/GeneratingState"; +import { TopicForm } from "@/components/TopicForm"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { PageShell } from "@/components/ui/PageShell"; +import { ProgressBar } from "@/components/ui/ProgressBar"; import { useFlashcardStore, type Flashcard } from "@/store/flashcard-store"; import type { QualityRating } from "@/lib/spaced-repetition"; import { ApiError, postJson } from "@/lib/api-client"; import { recordSession } from "@/lib/record-session"; +import { useI18n } from "@/i18n/I18nProvider"; +import type { MessageKey } from "@/i18n/messages"; + +const RATINGS: { quality: QualityRating; label: MessageKey; className: string }[] = [ + { quality: 1, label: "flashcards.rateHard", className: "border-danger/40 bg-danger-soft text-danger-ink" }, + { quality: 3, label: "flashcards.rateGood", className: "border-accent/40 bg-warning-soft text-warning-ink" }, + { quality: 5, label: "flashcards.rateEasy", className: "border-success/40 bg-success-soft text-success-ink" }, +]; + +type Tally = Record<1 | 3 | 5, number>; +const EMPTY_TALLY: Tally = { 1: 0, 3: 0, 5: 0 }; export default function FlashcardsPage() { - const [topicInput, setTopicInput] = useState(""); + const { t } = useI18n(); + const store = useFlashcardStore(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const store = useFlashcardStore(); + const [lastTopic, setLastTopic] = useState(""); + const [finished, setFinished] = useState(false); + const [tally, setTally] = useState(EMPTY_TALLY); - const handleGenerate = useCallback(async () => { - const topic = topicInput.trim(); - if (!topic || isLoading) return; + const generate = async (topic: string) => { + if (isLoading) return; setIsLoading(true); setError(null); + setLastTopic(topic); + setFinished(false); + setTally(EMPTY_TALLY); const started = Date.now(); try { const data = await postJson<{ cards: Flashcard[]; tokens_used?: number }>("/api/flashcards", { topic, count: 10 }); @@ -29,82 +50,117 @@ export default function FlashcardsPage() { } finally { setIsLoading(false); } - }, [topicInput, isLoading, store]); + }; const currentCard = store.cards[store.currentIndex]; - const progress = store.getProgress(); + const total = store.cards.length; - const rateAndNext = (quality: QualityRating) => { + const rateAndNext = (quality: 1 | 3 | 5) => { store.rate(quality); - store.next(); + setTally((prev) => ({ ...prev, [quality]: prev[quality] + 1 })); + if (store.currentIndex >= total - 1) setFinished(true); + else store.next(); + }; + + const newTopic = () => { + store.setCards([], ""); + setFinished(false); + }; + + const reviewAgain = () => { + store.reset(); + setTally(EMPTY_TALLY); + setFinished(false); }; return ( -
-

🃏 Flashcards

+ + - {store.cards.length === 0 && !isLoading && ( + {total === 0 && !isLoading && ( <> -
-

Choose a topic

- setTopicInput(e.target.value)} - placeholder="e.g., Data Structures, Biology..." - className="w-full px-4 py-2 border rounded-lg mb-4 text-base" - onKeyDown={(e) => e.key === "Enter" && handleGenerate()} - /> - + +
+ generate(lastTopic) : undefined} />
- )} - {isLoading && } + {isLoading && } - {currentCard && ( -
-

- {Math.min(progress.reviewed + 1, progress.total)} / {progress.total} -

+ {currentCard && !finished && ( +
+
+

+ {t("flashcards.cardOf", { current: store.currentIndex + 1, total })} +

+ +
+
+ +
- {store.isFlipped && ( -
- {([1, 3, 5] as const).map((q) => ( - - ))} -
- )} -
- + ))} +
+
+ )} +
+
+ )} + + {finished && ( +
+

{t("flashcards.completeTitle")}

+

{t("flashcards.completeDesc", { total, topic: store.topic })}

+
+ {RATINGS.map((r) => ( +
+
{t(r.label)}
+
{tally[r.quality as 1 | 3 | 5]}
+
+ ))} +
+
+ +
)} -
+ ); } diff --git a/src/app/globals.css b/src/app/globals.css index ab3d907..fd7cb30 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,19 +1,384 @@ -@import 'tailwindcss'; +@import "tailwindcss"; /* - The default border color has changed to `currentcolor` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. + LearnPath design tokens. - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. + Every colour in the UI comes from the variables below; components use the + Tailwind utilities generated from them (bg-surface, text-ink-muted, ...). + Tailwind's default palette is switched off so a stray `text-gray-400` + cannot slip in and break dark mode or contrast. + + Text/background pairs are checked for WCAG AA (4.5:1) in both themes. + Light is the default; dark applies when the system prefers it (and the + visitor has not picked light), or when the visitor picks dark. */ + +:root { + color-scheme: light; + --canvas: #f6f6f2; + --surface: #ffffff; + --surface-2: #eeefe9; + --surface-raised: #ffffff; + --line: #e1e3dc; + --line-strong: #8f978f; + --ink: #16211e; + --ink-muted: #4a5651; + --ink-subtle: #5d6964; + --brand: #0c6b60; + --brand-hover: #0a564d; + --on-brand: #ffffff; + --brand-soft: #e1f0ec; + --brand-ink: #0a564d; + --accent: #b4540a; + --accent-soft: #fbeedb; + --accent-ink: #8a3f06; + --success: #1b7339; + --success-soft: #e3f2e8; + --success-ink: #165f2f; + --danger: #b42318; + --danger-soft: #fcebe9; + --danger-ink: #9a1d13; + --warning-soft: #fdf3dd; + --warning-ink: #7a4305; + --focus: #0c6b60; + --overlay: rgb(14 22 20 / 0.45); + --shadow-color: 150 10% 20%; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --canvas: #0d1312; + --surface: #141c1a; + --surface-2: #1b2522; + --surface-raised: #2b3834; + --line: #26322e; + --line-strong: #6b7a74; + --ink: #e7edea; + --ink-muted: #a8b5b0; + --ink-subtle: #93a29c; + --brand: #3cc2ad; + --brand-hover: #62d3c1; + --on-brand: #04201b; + --brand-soft: #11302a; + --brand-ink: #7fdccd; + --accent: #f0a640; + --accent-soft: #33240f; + --accent-ink: #f5c27a; + --success: #4cc27a; + --success-soft: #11291a; + --success-ink: #84d9a4; + --danger: #ef7466; + --danger-soft: #341614; + --danger-ink: #f6a097; + --warning-soft: #2f2410; + --warning-ink: #f1c46f; + --focus: #62d3c1; + --overlay: rgb(0 0 0 / 0.6); + --shadow-color: 0 0% 0%; + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --canvas: #0d1312; + --surface: #141c1a; + --surface-2: #1b2522; + --surface-raised: #2b3834; + --line: #26322e; + --line-strong: #6b7a74; + --ink: #e7edea; + --ink-muted: #a8b5b0; + --ink-subtle: #93a29c; + --brand: #3cc2ad; + --brand-hover: #62d3c1; + --on-brand: #04201b; + --brand-soft: #11302a; + --brand-ink: #7fdccd; + --accent: #f0a640; + --accent-soft: #33240f; + --accent-ink: #f5c27a; + --success: #4cc27a; + --success-soft: #11291a; + --success-ink: #84d9a4; + --danger: #ef7466; + --danger-soft: #341614; + --danger-ink: #f6a097; + --warning-soft: #2f2410; + --warning-ink: #f1c46f; + --focus: #62d3c1; + --overlay: rgb(0 0 0 / 0.6); + --shadow-color: 0 0% 0%; +} + +@theme { + --color-*: initial; +} + +@theme inline { + --color-white: #ffffff; + --color-canvas: var(--canvas); + --color-surface: var(--surface); + --color-surface-2: var(--surface-2); + --color-surface-raised: var(--surface-raised); + --color-line: var(--line); + --color-line-strong: var(--line-strong); + --color-ink: var(--ink); + --color-ink-muted: var(--ink-muted); + --color-ink-subtle: var(--ink-subtle); + --color-brand: var(--brand); + --color-brand-hover: var(--brand-hover); + --color-on-brand: var(--on-brand); + --color-brand-soft: var(--brand-soft); + --color-brand-ink: var(--brand-ink); + --color-accent: var(--accent); + --color-accent-soft: var(--accent-soft); + --color-accent-ink: var(--accent-ink); + --color-success: var(--success); + --color-success-soft: var(--success-soft); + --color-success-ink: var(--success-ink); + --color-danger: var(--danger); + --color-danger-soft: var(--danger-soft); + --color-danger-ink: var(--danger-ink); + --color-warning-soft: var(--warning-soft); + --color-warning-ink: var(--warning-ink); + --color-focus: var(--focus); + + --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-display: var(--font-fraunces), ui-serif, Georgia, "Times New Roman", serif; + --font-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + + --text-display: clamp(2.125rem, 1.55rem + 2.6vw, 3.5rem); + --text-display--line-height: 1.08; + --text-display--letter-spacing: -0.02em; + --text-title: clamp(1.625rem, 1.35rem + 1.2vw, 2.25rem); + --text-title--line-height: 1.15; + --text-title--letter-spacing: -0.015em; + + --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.06), 0 1px 1px hsl(var(--shadow-color) / 0.04); + --shadow-raised: 0 10px 30px -12px hsl(var(--shadow-color) / 0.25), 0 2px 6px -2px hsl(var(--shadow-color) / 0.08); +} + @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--line); + } + + html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + overflow-x: clip; + } + + body { + background: var(--canvas); + color: var(--ink); + font-family: var(--font-sans); + font-feature-settings: "cv11", "ss01"; + -webkit-font-smoothing: antialiased; + overflow-x: clip; + } + + :focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; + } + + ::selection { + background: var(--brand-soft); + color: var(--ink); + } + + input::placeholder { + color: var(--ink-subtle); + opacity: 1; + } +} + +@layer components { + .card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 1rem; + box-shadow: var(--shadow-card); + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 2.75rem; + padding: 0.625rem 1.125rem; + border-radius: 0.75rem; + font-weight: 600; + font-size: 0.9375rem; + line-height: 1.25rem; + border: 1px solid transparent; + transition: + background-color 150ms ease, + border-color 150ms ease, + color 150ms ease, + box-shadow 150ms ease; + -webkit-tap-highlight-color: transparent; + cursor: pointer; + } + .btn:disabled { + cursor: not-allowed; + opacity: 0.55; + } + .btn-primary { + background: var(--brand); + color: var(--on-brand); + box-shadow: var(--shadow-card); + } + @media (hover: hover) { + .btn-primary:not(:disabled):hover { + background: var(--brand-hover); + } + } + .btn-secondary { + background: var(--surface); + color: var(--ink); + border-color: var(--line-strong); } + @media (hover: hover) { + .btn-secondary:not(:disabled):hover { + background: var(--surface-2); + } + } + .btn-ghost { + background: transparent; + color: var(--ink-muted); + } + @media (hover: hover) { + .btn-ghost:not(:disabled):hover { + background: var(--surface-2); + color: var(--ink); + } + } + .btn-sm { + min-height: 2.25rem; + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + border-radius: 0.625rem; + } + + .field { + width: 100%; + min-height: 2.875rem; + padding: 0.625rem 0.875rem; + border-radius: 0.75rem; + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--ink); + font-size: 1rem; /* 16px keeps iOS Safari from zooming on focus */ + transition: + border-color 150ms ease, + box-shadow 150ms ease; + } + .field:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 25%, transparent); + } + + select.field { + -webkit-appearance: none; + appearance: none; + padding-right: 2.5rem; + background-image: linear-gradient(45deg, transparent 50%, var(--ink-muted) 50%), + linear-gradient(135deg, var(--ink-muted) 50%, transparent 50%); + background-position: + calc(100% - 1.15rem) 50%, + calc(100% - 0.8rem) 50%; + background-size: 0.35rem 0.35rem; + background-repeat: no-repeat; + } + + .label { + display: block; + margin-bottom: 0.375rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--ink); + } + + .eyebrow { + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--brand-ink); + } + + /* Shown only in the light or only in the dark theme (theme toggle icons). */ + .only-dark { + display: none; + } + :root[data-theme="dark"] .only-dark { + display: inline-block; + } + :root[data-theme="dark"] .only-light { + display: none; + } + @media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .only-dark { + display: inline-block; + } + :root:not([data-theme="light"]) .only-light { + display: none; + } + } + + .skeleton { + background: linear-gradient(90deg, var(--surface-2) 0%, var(--line) 50%, var(--surface-2) 100%); + background-size: 200% 100%; + animation: shimmer 1.4s ease-in-out infinite; + border-radius: 0.5rem; + } +} + +@keyframes shimmer { + from { + background-position: 100% 0; + } + to { + background-position: -100% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + ::before, + ::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* Flashcard flip. Prefixed backface-visibility for older WebKit. */ +.flip-scene { + perspective: 1200px; +} +.flip-card { + display: grid; + transform-style: preserve-3d; + transition: transform 500ms cubic-bezier(0.2, 0.7, 0.2, 1); +} +.flip-card[data-flipped="true"] { + transform: rotateY(180deg); +} +.flip-face { + grid-area: 1 / 1; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; +} +.flip-back { + transform: rotateY(180deg); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dc44e88..9f55ffa 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,27 +1,56 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; +import { Fraunces, Inter } from "next/font/google"; import { AppHeader } from "@/components/AppHeader"; +import { MobileNav } from "@/components/MobileNav"; +import { SiteFooter } from "@/components/SiteFooter"; +import { I18nProvider } from "@/i18n/I18nProvider"; +import { translate } from "@/i18n/messages"; +import { getRequestLocale } from "@/i18n/server"; +import { THEME_INIT_SCRIPT } from "@/lib/theme"; import "./globals.css"; -export const metadata: Metadata = { - title: "LearnPath — Adaptive Learning", - description: "AI-generated quizzes, flashcards and learning roadmaps with any OpenAI-compatible LLM. Bring your own API key.", +const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" }); +const fraunces = Fraunces({ + subsets: ["latin"], + variable: "--font-fraunces", + display: "swap", + axes: ["opsz", "SOFT"], +}); + +export async function generateMetadata(): Promise { + const locale = await getRequestLocale(); + return { + title: translate(locale, "meta.title"), + description: translate(locale, "meta.description"), + }; +} + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f6f6f2" }, + { media: "(prefers-color-scheme: dark)", color: "#0d1312" }, + ], }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const locale = await getRequestLocale(); return ( - - - - {children} - + + +