diff --git a/.env.example b/.env.example index 5466821..6170428 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ OPENAI_API_KEY= OPENROUTER_API_KEY= ANTHROPIC_API_KEY= +# Google Cloud Translation Basic (v2, server-side only) +GOOGLE_CLOUD_TRANSLATION_API_KEY= + # Stripe Billing STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= diff --git a/README.md b/README.md index 90e8bbc..e5e1947 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,20 @@
+Lingora + # Lingora AI -### Privacy-first, multi-model English learning for global learners +### Privacy-first AI language learning with strict progression, real user data and multi-model support [![CI](https://github.com/Meranh05/LingoraAI/actions/workflows/ci.yml/badge.svg)](https://github.com/Meranh05/LingoraAI/actions/workflows/ci.yml) [![Next.js](https://img.shields.io/badge/Next.js-16-black?logo=next.js)](https://nextjs.org/) -[![Supabase](https://img.shields.io/badge/Supabase-Auth%20%2B%20RLS-3FCF8E?logo=supabase&logoColor=white)](https://supabase.com/) +[![React](https://img.shields.io/badge/React-19-149ECA?logo=react&logoColor=white)](https://react.dev/) +[![Supabase](https://img.shields.io/badge/Supabase-Auth%20%7C%20Postgres%20%7C%20Storage-3FCF8E?logo=supabase&logoColor=white)](https://supabase.com/) [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![License](https://img.shields.io/github/license/Meranh05/LingoraAI)](LICENSE) [![GitHub stars](https://img.shields.io/github/stars/Meranh05/LingoraAI?style=social)](https://github.com/Meranh05/LingoraAI/stargazers) -**[English](#english) · [Tiếng Việt](#tiếng-việt) · [Setup](#quick-start) · [Architecture](#architecture)** +[English](#english) · [Tiếng Việt](#tiếng-việt) · [Quick Start](#quick-start) · [Configuration](#configuration) · [Architecture](#architecture) ![Lingora dashboard](docs/screenshots/dashboard.png) @@ -19,120 +22,200 @@ ## English -Lingora is an open-source AI English-learning platform built for document-based -learning, adaptive practice and privacy-conscious personalization. - -It combines: - -- Google OAuth, magic-link and email/password authentication. -- User/admin role-based access and server-side authorization. -- Row Level Security so every learner only accesses their own private data. -- Reading, writing, listening and speaking practice. -- Document extraction, bilingual summaries, questions and vocabulary. -- Learning roadmaps, skill mastery and progress analytics. -- OpenAI, Gemini, Groq, OpenRouter, Anthropic and custom compatible APIs. -- Stripe subscriptions with Basic, Plus and Pro plans in VND or USD. -- A consent-based AI feedback pipeline for building anonymized training data. -- Vietnamese, English, Japanese and Thai navigation. - -> Lingora does **not** silently train on private conversations. Training -> candidates are created only from explicit feedback when consent is enabled, -> anonymized, and reviewed by an administrator before export. +Lingora is an open-source AI-assisted English learning platform built with +Next.js, Supabase and Stripe. It combines structured learning stages, +skill-based practice, document learning, gamification and configurable AI +providers in one account-isolated application. + +### What is implemented + +- Google OAuth, magic link and email/password authentication. +- Server-side `user` / `admin` authorization and Supabase Row Level Security. +- Strict stage progression: completing stage A is required before stage B. +- Stage sessions that only accept questions assigned to the active unit. +- Server-calculated completion scores, one-time rewards and anti-spam controls. +- Completion dialog with answers, correct answers, score and next-stage action. +- Difficult-question review queue persisted per learner. +- AI Tutor with saved conversations, document context and answer feedback. +- Reading, listening, speaking, writing, translation, quiz and vocabulary tools. +- Google Cloud Translation with automatic language detection and server-side keys. +- Flashcards with spaced review and learner-owned vocabulary. +- PDF, DOCX and TXT extraction with AI learning tools. +- XP, tokens, daily/weekly challenges, levels and competition leaderboards. +- Stripe plans, customer portal, subscription webhooks and token purchases. +- Avatar uploads through Supabase Storage with ownership policies. +- User preferences for sound, motion, mascot, compact mode and reminders. +- Vietnamese, English, Japanese and Thai interface support. +- Modern admin console for users, content, billing, AI and system diagnostics. + +> Lingora never silently trains on private conversations. Training candidates +> require explicit feedback, learner consent, PII filtering and administrator +> review before export. ## Tiếng Việt -Lingora là nền tảng học tiếng Anh mã nguồn mở có AI, tập trung vào học theo tài -liệu, luyện kỹ năng thích ứng và bảo vệ dữ liệu người dùng. - -### Điểm nổi bật - -- Đăng nhập Google, magic link, email và mật khẩu. -- Phân quyền `user` / `admin`, khóa hoặc mở tài khoản. -- RLS bảo đảm user chỉ đọc và sửa dữ liệu thuộc tài khoản của chính mình. -- Lộ trình A1–B1, mục tiêu ngày, tiến độ từng kỹ năng. -- Câu hỏi đọc, viết, nghe, nói; phản hồi và chấm điểm trực tiếp. -- Upload PDF, DOCX, TXT; tóm tắt song ngữ, tạo quiz và bộ từ vựng. -- Gia sư AI có memory cá nhân, feedback tốt/xấu và model gateway đa provider. -- AI Lab xuất JSONL từ dữ liệu đã consent, khử định danh và được admin duyệt. -- UI responsive cho desktop/mobile và điều hướng đa ngôn ngữ. +Lingora là nền tảng học tiếng Anh mã nguồn mở có AI, sử dụng dữ liệu thật theo +từng tài khoản và bảo vệ dữ liệu bằng Supabase RLS. + +### Tính năng chính + +- Đăng ký/đăng nhập bằng Google, magic link, email và mật khẩu. +- Phân quyền `user` / `admin`; user chỉ truy cập dữ liệu của chính mình. +- Lộ trình khóa tuần tự: hoàn thành chặng trước mới mở chặng tiếp theo. +- Mỗi phiên chặng chỉ ghi nhận câu hỏi thuộc đúng chặng đang học. +- Popup tổng kết điểm, câu trả lời, đáp án đúng, XP/token và nút qua chặng mới. +- Thưởng hoàn thành chỉ nhận một lần; có idempotency và giới hạn chống spam. +- Câu sai hoặc được đánh dấu khó được lưu vào danh sách ôn lại. +- Gia sư AI đa provider, hội thoại lưu theo tài khoản và hỏi đáp theo tài liệu. +- Luyện đọc, nghe, nói, viết, dịch thuật, quiz, từ vựng và flashcards. +- Hệ thống level, nhiệm vụ, thi đua, bảng xếp hạng, XP và Lingora Token. +- Upload ảnh đại diện thật bằng Supabase Storage. +- Cài đặt âm thanh, hoạt ảnh, linh vật, giao diện thu gọn và nhắc học. +- Admin Console quản lý người dùng, nội dung, AI, gói dịch vụ và hệ thống. ## Product Tour | Authentication | Learning roadmap | | --- | --- | -| ![Authentication](docs/screenshots/login.png) | ![Roadmap](docs/screenshots/roadmap.png) | +| ![Lingora authentication](docs/screenshots/login.png) | ![Lingora roadmap](docs/screenshots/roadmap.png) | | Adaptive practice | Administration | | --- | --- | -| ![Practice](docs/screenshots/practice.png) | ![Admin](docs/screenshots/admin.png) | +| ![Lingora practice](docs/screenshots/practice.png) | ![Lingora admin](docs/screenshots/admin.png) | + +### Live administration analytics + +The administration dashboard reads directly from Supabase and visualizes +14-day learning activity, plan distribution, skill engagement and the current +content inventory. + +![Lingora live administration analytics](docs/screenshots/admin-analytics.png)
-Mobile roadmap +Mobile roadmap preview -![Mobile roadmap](docs/screenshots/mobile-roadmap.png) +![Lingora mobile roadmap](docs/screenshots/mobile-roadmap.png)
-## Feature Matrix +## Modules -| Area | Included | +| Module | Capabilities | | --- | --- | -| Authentication | Google OAuth, magic link, password, PKCE callback, SSR cookies | -| Authorization | Proxy session refresh, server DAL, admin API checks, RLS | -| Learning | Roadmap, reading, writing, listening, speaking, vocabulary, quiz | -| Documents | PDF/DOCX/TXT extraction, summary, questions, vocabulary | -| AI | Multi-provider gateway, auto-detect, user memory, explicit feedback | -| Admin | User role/status management, metrics, AI Lab, JSONL export | -| Billing | Stripe Checkout, Customer Portal, webhook sync, quotas, revenue dashboard | -| Privacy | Per-user rows, private storage, consent snapshot, PII filtering | -| Internationalization | Vietnamese, English, Japanese and Thai navigation | +| AI Tutor | Multi-model chat, document context, persisted sessions, feedback | +| Roadmap | Sequential units, mastery gates, completion summaries, boss stages | +| Skill Practice | Reading, listening, speaking and writing progress | +| Documents | PDF/DOCX/TXT extraction, summaries, questions and vocabulary | +| Vocabulary | Personal word library and review scheduling | +| Flashcards | Learner-owned cards and spaced repetition | +| Speaking | Microphone permission diagnostics and Web Speech transcription | +| Quiz | Multiple choice, fill blank, dictation, matching and sentence order | +| Progress | Skill mastery, attempts, study time, streaks and levels | +| Competition | Opt-in leaderboard, weekly challenges and anti-spam scoring | +| Economy | XP, Lingora Tokens, temporary unlocks and Stripe purchases | +| Settings | Profile, avatar, locale, preferences, AI keys and diagnostics | +| Admin | Users, content studio, billing metrics, AI Lab and system status | + +## Tech Stack + +- Next.js 16 App Router and React 19 +- TypeScript in strict mode +- Tailwind CSS 4 and shadcn/Base UI components +- Supabase Auth, PostgreSQL, RLS and Storage +- Stripe Checkout, Billing Portal and webhooks +- Gemini, Groq, OpenAI, OpenRouter, Anthropic and custom compatible endpoints +- Vitest and GitHub Actions ## Quick Start Requirements: -- Node.js 20.16+ or 22+ +- Node.js 22 recommended - pnpm 10+ -- A Supabase project for production authentication/data +- A Supabase project ```bash git clone https://github.com/Meranh05/LingoraAI.git cd LingoraAI pnpm install +``` + +Windows PowerShell: + +```powershell Copy-Item .env.example .env.local pnpm dev ``` +macOS/Linux: + +```bash +cp .env.example .env.local +pnpm dev +``` + Open [http://localhost:3000](http://localhost:3000). -Lingora does not fabricate demo user data. Without Supabase variables, protected -routes redirect to `/setup` and remain unavailable until the database is -configured. +Protected routes redirect to `/setup` until Supabase is configured. Lingora +does not generate fake learner records when the backend is missing. + +## Configuration + +```dotenv +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +SUPABASE_SECRET_KEY= + +GEMINI_API_KEY= +GROQ_API_KEY= +OPENAI_API_KEY= +OPENROUTER_API_KEY= +ANTHROPIC_API_KEY= +GOOGLE_CLOUD_TRANSLATION_API_KEY= + +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +``` + +`NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` is intended for browser use with RLS. +`SUPABASE_SECRET_KEY`, AI provider keys and Stripe secrets must remain +server-only. + +Users can alternatively enter an AI API key in Settings. Browser-entered keys +are kept in `sessionStorage`, sent to a server Route Handler only when making a +request, and are not persisted to Supabase. ## Supabase Setup 1. Create a Supabase project. -2. Copy the project URL, publishable key and secret key to `.env.local`. -3. Run migrations in order: +2. Add its URL, publishable key and secret key to `.env.local`. +3. Apply every SQL file in `supabase/migrations` in filename order. +4. Enable the required providers in **Authentication → Sign In / Providers**. +5. Add the following redirect URLs: ```text -supabase/migrations/202606090001_initial_lingora.sql -supabase/migrations/202606090002_auth_rbac_learning_ai.sql -supabase/migrations/202606090003_real_learning_workflows.sql -supabase/migrations/202606090004_security_hardening.sql -supabase/migrations/202606100001_competition_leaderboard.sql -supabase/migrations/202606100002_billing_subscriptions.sql +http://localhost:3000/auth/callback +https://your-domain.com/auth/callback ``` -4. Enable Email and Google in **Authentication → Providers**. -5. Add callback URLs: +For Google OAuth, register this callback URL in Google Cloud: ```text -http://localhost:3000/auth/callback -https://your-domain.com/auth/callback +https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback ``` -6. Register the first user, then promote that user once in SQL Editor: +The migrations create: + +- User-owned learning tables and RLS policies. +- Roadmaps, units, questions, sessions and strict completion rewards. +- Competition, challenges, wallets, token unlocks and anti-spam functions. +- Billing plans, subscriptions, transactions and webhook records. +- AI feedback/training review tables. +- Public avatar bucket with user-scoped upload/update/delete policies. + +Promote the first administrator once in Supabase SQL Editor: ```sql update public.profiles @@ -142,84 +225,119 @@ where id = ( ); ``` -Never expose `SUPABASE_SECRET_KEY` to browser code. +## AI Providers + +Lingora can automatically detect a provider from the selected provider, API key +prefix, model name or custom Base URL. -## Environment +| Provider | Environment variable | +| --- | --- | +| Gemini | `GEMINI_API_KEY` | +| Groq | `GROQ_API_KEY` | +| OpenAI | `OPENAI_API_KEY` | +| OpenRouter | `OPENROUTER_API_KEY` | +| Anthropic | `ANTHROPIC_API_KEY` | +| Compatible API | Custom Base URL and model in Settings | + +Retryable provider failures such as HTTP `429`, `500`, `502`, `503` and `504` +use bounded retries and return actionable messages to the interface. + +### Google Cloud Translation + +The Translation workspace uses Google Cloud Translation Basic (v2) when +`GOOGLE_CLOUD_TRANSLATION_API_KEY` is configured. The key stays on the server; +the browser only calls Lingora's authenticated `/api/translation/google` +route. If the key is absent, Lingora falls back to the configured AI provider. + +1. Create or select a project in Google Cloud Console. +2. Enable **Cloud Translation API** and attach a billing account. +3. Create an API key under **APIs & Services → Credentials**. +4. Restrict the key to **Cloud Translation API**. For production, also apply + the network or application restrictions appropriate for the deployment. +5. Add the key to `.env.local`, then restart the Next.js server: ```dotenv -NEXT_PUBLIC_APP_URL=http://localhost:3000 +GOOGLE_CLOUD_TRANSLATION_API_KEY=your_server_side_key +``` -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= -SUPABASE_SECRET_KEY= +The integration supports automatic source-language detection, explicit +source/target selection, language swapping, copy-to-clipboard and private +usage events. Translation text itself is not stored in learning-event +metadata. -OPENAI_API_KEY= -GEMINI_API_KEY= -GROQ_API_KEY= -OPENROUTER_API_KEY= -ANTHROPIC_API_KEY= +## Speech Recognition -STRIPE_SECRET_KEY= -STRIPE_WEBHOOK_SECRET= -``` +Speaking practice uses the browser Web Speech API. + +- Use a current Chrome or Edge browser. +- The page must run on HTTPS or `localhost`. +- Microphone permission must be allowed for the site. +- Browser speech transcription may require Internet even after microphone + permission succeeds. +- VPNs, firewalls or restricted networks can block the browser speech service. + +Settings includes diagnostics for network state, secure context, microphone, +speech support, cookies and server AI providers. ## Stripe Billing -Lingora uses Stripe Checkout for recurring subscriptions and Stripe Customer -Portal for payment-method updates, invoices and cancellation. +Lingora uses Stripe Checkout for subscriptions and token purchases, Stripe +Customer Portal for account management, and signed webhooks for synchronization. -1. Add the Stripe test secret key to `STRIPE_SECRET_KEY`. -2. Create a webhook endpoint pointing to - `https://your-domain.com/api/billing/webhook`. -3. Subscribe it to `checkout.session.completed`, - `customer.subscription.created`, `customer.subscription.updated`, - `customer.subscription.deleted`, `invoice.paid` and - `invoice.payment_failed`. -4. Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET`. -5. Configure Stripe Customer Portal in the Stripe Dashboard. +Create a webhook endpoint: -For local webhook testing: +```text +https://your-domain.com/api/billing/webhook +``` -```bash -stripe listen --forward-to localhost:3000/api/billing/webhook +Subscribe it to: + +```text +checkout.session.completed +customer.subscription.created +customer.subscription.updated +customer.subscription.deleted +invoice.paid +invoice.payment_failed ``` -The default monthly catalog is Basic (`99,000 VND` / `$4.99`), Plus -(`199,000 VND` / `$8.99`) and Pro (`399,000 VND` / `$16.99`). Edit -`public.billing_plans` to change prices or limits. +Local webhook forwarding: -Every plan supports card payment and a one-time three-day trial without a card. -Admin accounts also see a developer-only no-card Checkout button while the -server uses an `sk_test_` Stripe Sandbox key. +```bash +stripe listen --forward-to localhost:3000/api/billing/webhook +``` -Users may alternatively enter an AI key for the current browser tab. It is -stored in `sessionStorage`, sent only to a server Route Handler, and never saved -to Supabase. +The seeded catalog includes Basic, Plus and Pro plans. Prices and limits are +stored in `public.billing_plans` and can be managed from the database/admin +workflow. Test-mode admin accounts can use the developer checkout flow; normal +users can use card checkout or the one-time three-day trial flow. ## Architecture ```mermaid flowchart LR - Browser["Next.js UI"] --> Proxy["Supabase SSR Proxy"] - Proxy --> Auth["Supabase Auth"] - Browser --> Routes["Server Actions / Route Handlers"] - Routes --> DAL["Authorization DAL"] - DAL --> RLS["Postgres + RLS"] - Routes --> Gateway["AI Provider Gateway"] - Gateway --> Models["Gemini / Groq / OpenAI / OpenRouter / Anthropic"] - RLS --> Feedback["Consented AI Feedback"] - Feedback --> Anon["PII filtering + review queue"] - Anon --> Export["Admin-approved JSONL export"] + UI["Next.js UI"] --> SSR["Supabase SSR auth"] + SSR --> Auth["Supabase Auth"] + UI --> API["Route Handlers / Server Actions"] + API --> Access["Server authorization"] + Access --> DB["PostgreSQL + RLS"] + API --> AI["AI provider gateway"] + AI --> Models["Gemini / Groq / OpenAI / OpenRouter / Anthropic"] + API --> Stripe["Stripe Checkout + Webhooks"] + DB --> Review["Consented feedback review"] + Review --> Export["Admin-approved anonymized export"] ``` -Security is enforced at multiple layers: +Security is enforced through: -1. Supabase validates SSR tokens with `getClaims()`. -2. Pages and APIs verify viewer/admin permissions server-side. -3. Postgres RLS enforces row ownership. -4. Secret-key admin operations only run on the server. +1. SSR token validation with Supabase `getClaims()`. +2. Server-side authorization for pages and mutations. +3. PostgreSQL RLS for user-owned rows. +4. User-scoped Storage policies for avatar mutations. +5. Server-only service, AI and Stripe secrets. +6. Idempotency and database claims for rewards and payments. -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for additional details. +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). ## Development @@ -229,33 +347,35 @@ pnpm typecheck pnpm test pnpm build -# Run everything +# Run the complete quality gate pnpm check ``` -## Roadmap +The GitHub Actions workflow runs the same quality gate for pull requests and +pushes to `main`. + +## Current Limitations -- Real streaming responses and resumable chat sessions. -- Speech-to-text provider adapters and pronunciation scoring. -- Background document chunking and embeddings jobs. -- Admin content authoring and training-candidate review workflow. -- More complete translated content and locale-aware course packs. -- Self-hosted model evaluation and fine-tuning recipes. +- Web Speech availability depends on browser and network services. +- AI responses are request/response based; token streaming is not implemented. +- Document extraction runs in the request lifecycle rather than a background + worker queue. +- Complete translated learning packs still depend on published content. ## Contributing -Issues, translations, course content and provider adapters are welcome. Read -[CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. +Bug fixes, translations, CEFR content, accessibility improvements and provider +adapters are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a +pull request. -If Lingora is useful, consider starring the repository, sharing a screenshot, -or opening a focused issue. Specific feedback is more useful than generic -promotion. +If Lingora is useful, star the repository, share a screenshot or open a focused +issue with reproducible details. ## Security -Please do not disclose vulnerabilities in public issues. Follow -[SECURITY.md](SECURITY.md). +Do not disclose authentication bypasses, RLS failures, secret exposure or +cross-user access in public issues. Follow [SECURITY.md](SECURITY.md). ## License -Distributed under the repository [LICENSE](LICENSE). +Lingora is distributed under the [GNU General Public License v3.0](LICENSE). diff --git a/docs/screenshots/admin-analytics.png b/docs/screenshots/admin-analytics.png new file mode 100644 index 0000000..c574148 Binary files /dev/null and b/docs/screenshots/admin-analytics.png differ diff --git a/src/app/ai-tutor/page.tsx b/src/app/ai-tutor/page.tsx index 5a26d2a..e98cd62 100644 --- a/src/app/ai-tutor/page.tsx +++ b/src/app/ai-tutor/page.tsx @@ -9,5 +9,5 @@ export const metadata: Metadata = { export default async function AiTutorPage() { const viewer = await requireViewer(); - return ; + return ; } diff --git a/src/app/api/admin/content/route.ts b/src/app/api/admin/content/route.ts index f89f392..d7fa1bb 100644 --- a/src/app/api/admin/content/route.ts +++ b/src/app/api/admin/content/route.ts @@ -22,7 +22,12 @@ const createSchema = z.discriminatedUnion("kind", [ slug: z.string().trim().min(2).max(80).regex(/^[a-z0-9-]+$/), titleVi: z.string().trim().min(2).max(160), titleEn: z.string().trim().max(160).optional(), + titleJa: z.string().trim().max(160).optional(), + titleTh: z.string().trim().max(160).optional(), descriptionVi: z.string().trim().max(1000).optional(), + descriptionEn: z.string().trim().max(1000).optional(), + descriptionJa: z.string().trim().max(1000).optional(), + descriptionTh: z.string().trim().max(1000).optional(), targetLevel: z.string().trim().min(1).max(20), estimatedHours: z.coerce.number().int().min(1).max(1000), published: z.boolean(), @@ -32,7 +37,13 @@ const createSchema = z.discriminatedUnion("kind", [ pathId: z.uuid(), position: z.coerce.number().int().min(1).max(1000), titleVi: z.string().trim().min(2).max(160), + titleEn: z.string().trim().max(160).optional(), + titleJa: z.string().trim().max(160).optional(), + titleTh: z.string().trim().max(160).optional(), descriptionVi: z.string().trim().max(1000).optional(), + descriptionEn: z.string().trim().max(1000).optional(), + descriptionJa: z.string().trim().max(1000).optional(), + descriptionTh: z.string().trim().max(1000).optional(), skill, level: z.string().trim().min(1).max(20), estimatedMinutes: z.coerce.number().int().min(1).max(300), @@ -41,7 +52,13 @@ const createSchema = z.discriminatedUnion("kind", [ kind: z.literal("question"), unitId: z.uuid(), promptVi: z.string().trim().min(2).max(3000), + promptEn: z.string().trim().max(3000).optional(), + promptJa: z.string().trim().max(3000).optional(), + promptTh: z.string().trim().max(3000).optional(), explanationVi: z.string().trim().max(3000).optional(), + explanationEn: z.string().trim().max(3000).optional(), + explanationJa: z.string().trim().max(3000).optional(), + explanationTh: z.string().trim().max(3000).optional(), passage: z.string().trim().max(10000).optional(), audioUrl: z.string().trim().url().optional().or(z.literal("")), skill, @@ -55,7 +72,13 @@ const createSchema = z.discriminatedUnion("kind", [ kind: z.literal("challenge"), slug: z.string().trim().min(2).max(80).regex(/^[a-z0-9-]+$/), titleVi: z.string().trim().min(2).max(160), + titleEn: z.string().trim().max(160).optional(), + titleJa: z.string().trim().max(160).optional(), + titleTh: z.string().trim().max(160).optional(), descriptionVi: z.string().trim().max(1000), + descriptionEn: z.string().trim().max(1000).optional(), + descriptionJa: z.string().trim().max(1000).optional(), + descriptionTh: z.string().trim().max(1000).optional(), challengeType: z.enum(["daily", "weekly", "boss", "community"]), difficulty: z.enum(["easy", "normal", "hard", "legendary"]), eventType: z.string().trim().max(80).optional(), @@ -89,8 +112,8 @@ export async function POST(request: Request) { if (input.kind === "path") { result = await admin.from("learning_paths").insert({ slug: input.slug, - title: { vi: input.titleVi, en: input.titleEn || input.titleVi }, - description: { vi: input.descriptionVi || "" }, + title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi }, + description: { vi: input.descriptionVi || "", en: input.descriptionEn || input.descriptionVi || "", ja: input.descriptionJa || input.descriptionEn || input.descriptionVi || "", th: input.descriptionTh || input.descriptionEn || input.descriptionVi || "" }, target_level: input.targetLevel, estimated_hours: input.estimatedHours, is_published: input.published, @@ -100,8 +123,8 @@ export async function POST(request: Request) { result = await admin.from("learning_units").insert({ path_id: input.pathId, position: input.position, - title: { vi: input.titleVi, en: input.titleVi }, - description: { vi: input.descriptionVi || "" }, + title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi }, + description: { vi: input.descriptionVi || "", en: input.descriptionEn || input.descriptionVi || "", ja: input.descriptionJa || input.descriptionEn || input.descriptionVi || "", th: input.descriptionTh || input.descriptionEn || input.descriptionVi || "" }, skill: input.skill, level: input.level, estimated_minutes: input.estimatedMinutes, @@ -133,8 +156,8 @@ export async function POST(request: Request) { owner_id: null, skill: input.skill, question_type: input.questionType, - prompt: { vi: input.promptVi, en: input.promptVi }, - explanation: { vi: input.explanationVi || "" }, + prompt: { vi: input.promptVi, en: input.promptEn || input.promptVi, ja: input.promptJa || input.promptEn || input.promptVi, th: input.promptTh || input.promptEn || input.promptVi }, + explanation: { vi: input.explanationVi || "", en: input.explanationEn || input.explanationVi || "", ja: input.explanationJa || input.explanationEn || input.explanationVi || "", th: input.explanationTh || input.explanationEn || input.explanationVi || "" }, passage: input.passage || null, audio_url: input.audioUrl || null, options: optionObjects.length ? optionObjects : null, @@ -148,8 +171,8 @@ export async function POST(request: Request) { ends.setDate(ends.getDate() + input.durationDays); result = await admin.from("learning_challenges").insert({ slug: input.slug, - title: { vi: input.titleVi, en: input.titleVi }, - description: { vi: input.descriptionVi, en: input.descriptionVi }, + title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi }, + description: { vi: input.descriptionVi, en: input.descriptionEn || input.descriptionVi, ja: input.descriptionJa || input.descriptionEn || input.descriptionVi, th: input.descriptionTh || input.descriptionEn || input.descriptionVi }, challenge_type: input.challengeType, difficulty: input.difficulty, event_type: input.eventType || null, diff --git a/src/app/api/practice/attempt/route.ts b/src/app/api/practice/attempt/route.ts index f704fe4..d100bca 100644 --- a/src/app/api/practice/attempt/route.ts +++ b/src/app/api/practice/attempt/route.ts @@ -1,6 +1,12 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getOptionalViewer } from "@/lib/auth"; +import { + answerWords, + normalizeAnswer, + orderedSentenceScore, + wordSimilarity, +} from "@/lib/practice-scoring"; import { createAdminClient } from "@/lib/supabase/admin"; const schema = z.object({ @@ -9,33 +15,15 @@ const schema = z.object({ durationSeconds: z.number().int().min(0).max(14_400).default(0), module: z.enum(["practice", "quiz", "competition"]).default("practice"), challengeId: z.uuid().optional(), + unitId: z.uuid().optional(), + unitSessionId: z.uuid().optional(), idempotencyKey: z.uuid(), -}); - -function normalize(value: unknown) { - return String(value ?? "").trim().toLocaleLowerCase("en"); -} - -function words(value: unknown) { - return normalize(value) - .replace(/[^\p{L}\p{N}\s]/gu, "") - .split(/\s+/) - .filter(Boolean); -} - -function similarity(expectedValue: unknown, actualValue: unknown) { - const expected = words(expectedValue); - const actual = words(actualValue); - if (!expected.length) return 0; - const remaining = [...actual]; - const matched = expected.filter((word) => { - const index = remaining.indexOf(word); - if (index < 0) return false; - remaining.splice(index, 1); - return true; - }).length; - return Math.round((matched / expected.length) * 100); -} +}).refine( + (value) => + (!value.unitId && !value.unitSessionId) || + Boolean(value.unitId && value.unitSessionId), + { message: "unitId và unitSessionId phải được gửi cùng nhau." }, +); export async function POST(request: Request) { const viewer = await getOptionalViewer(); @@ -44,12 +32,73 @@ export async function POST(request: Request) { const admin = createAdminClient(); const { data: question, error: questionError } = await admin .from("practice_questions") - .select("id,skill,question_type,answer_key,explanation,options") + .select("id,unit_id,skill,question_type,answer_key,explanation,options") .eq("id", input.questionId) .single(); if (questionError || !question) { return NextResponse.json({ error: "Không tìm thấy câu hỏi." }, { status: 404 }); } + if (input.unitId && question.unit_id !== input.unitId) { + return NextResponse.json( + { error: "Câu hỏi không thuộc chặng học hiện tại." }, + { status: 403 }, + ); + } + if (input.unitId && input.unitSessionId) { + const { data: activeSession } = await admin + .from("learning_unit_sessions") + .select("id") + .eq("id", input.unitSessionId) + .eq("user_id", viewer.id) + .eq("unit_id", input.unitId) + .eq("status", "active") + .maybeSingle(); + if (!activeSession) { + return NextResponse.json( + { error: "Phiên học không còn hoạt động. Hãy mở lại chặng." }, + { status: 403 }, + ); + } + } + if (question.unit_id && input.module !== "competition") { + const { data: unit } = await admin + .from("learning_units") + .select("path_id,position") + .eq("id", question.unit_id) + .maybeSingle(); + if (unit) { + const [{ data: enrollment }, { data: previous }] = await Promise.all([ + admin + .from("user_path_enrollments") + .select("id") + .eq("user_id", viewer.id) + .eq("path_id", unit.path_id) + .maybeSingle(), + admin + .from("learning_units") + .select("id") + .eq("path_id", unit.path_id) + .lt("position", unit.position) + .order("position", { ascending: false }) + .limit(1) + .maybeSingle(), + ]); + if (enrollment && previous) { + const { data: previousProgress } = await admin + .from("user_unit_progress") + .select("completed_at") + .eq("user_id", viewer.id) + .eq("unit_id", previous.id) + .maybeSingle(); + if (!previousProgress?.completed_at) { + return NextResponse.json( + { error: "Hãy hoàn thành checkpoint trước để mở bài học này." }, + { status: 403 }, + ); + } + } + } + } const key = (question.answer_key ?? {}) as { value?: unknown; @@ -61,37 +110,39 @@ export async function POST(request: Request) { const options = (question.options ?? []) as Array<{ id?: string; text?: string }>; let score: number | null = null; if (question.question_type === "multiple_choice" || question.question_type === "true_false") { - const selected = options.find((option) => normalize(option.id) === normalize(input.answer)); + const selected = options.find((option) => normalizeAnswer(option.id) === normalizeAnswer(input.answer)); score = - normalize(input.answer) === normalize(key.value) || - normalize(selected?.text) === normalize(key.value) + normalizeAnswer(input.answer) === normalizeAnswer(key.value) || + normalizeAnswer(selected?.text) === normalizeAnswer(key.value) ? 100 : 0; } else if (question.question_type === "dictation") { - score = similarity(key.text ?? key.value, input.answer); + score = wordSimilarity(key.text ?? key.value, input.answer); } else if (question.question_type === "short_answer") { - score = similarity(key.value ?? key.text, input.answer); + score = wordSimilarity(key.value ?? key.text, input.answer); } else if (question.question_type === "fill_blank") { - score = normalize(key.value ?? key.text) === normalize(input.answer) ? 100 : similarity(key.value ?? key.text, input.answer); + score = normalizeAnswer(key.value ?? key.text) === normalizeAnswer(input.answer) + ? 100 + : wordSimilarity(key.value ?? key.text, input.answer); } else if (question.question_type === "match_meaning") { - const selected = options.find((option) => normalize(option.id) === normalize(input.answer)); + const selected = options.find((option) => normalizeAnswer(option.id) === normalizeAnswer(input.answer)); score = - normalize(input.answer) === normalize(key.value) || - normalize(selected?.text) === normalize(key.value) + normalizeAnswer(input.answer) === normalizeAnswer(key.value) || + normalizeAnswer(selected?.text) === normalizeAnswer(key.value) ? 100 : 0; } else if (question.question_type === "sentence_order") { const ordered = Array.isArray(input.answer) ? input.answer.join(" ") : input.answer; - score = similarity(key.text ?? key.value, ordered); + score = orderedSentenceScore(key.text ?? key.value, ordered); } else if (question.question_type === "speaking") { const keywords = key.keywords ?? []; score = keywords.length - ? similarity(keywords.join(" "), input.answer) - : Math.min(100, words(input.answer).length * 10); + ? wordSimilarity(keywords.join(" "), input.answer) + : Math.min(100, answerWords(input.answer).length * 10); } else if (question.question_type === "essay") { - const count = words(input.answer).length; + const count = answerWords(input.answer).length; const minimum = key.min_words ?? 60; const maximum = key.max_words ?? 180; score = @@ -128,6 +179,21 @@ export async function POST(request: Request) { if (!secured) { return NextResponse.json({ error: "Không thể ghi nhận lượt học." }, { status: 500 }); } + if (input.unitId && input.unitSessionId) { + const { error: sessionError } = await admin.rpc( + "attach_attempt_to_unit_session", + { + target_user_id: viewer.id, + target_session_id: input.unitSessionId, + target_unit_id: input.unitId, + target_question_id: question.id, + target_attempt_id: secured.attempt_id, + }, + ); + if (sessionError) { + return NextResponse.json({ error: sessionError.message }, { status: 403 }); + } + } if (input.module === "quiz" && score !== null) { await admin.from("quiz_results").insert({ user_id: viewer.id, @@ -147,5 +213,15 @@ export async function POST(request: Request) { }, rewardEligible: secured.reward_eligible, cooldownSeconds: secured.cooldown_seconds, + correctAnswer: + question.question_type === "multiple_choice" || + question.question_type === "true_false" || + question.question_type === "match_meaning" + ? options.find( + (option) => + normalizeAnswer(option.id) === normalizeAnswer(key.value) || + normalizeAnswer(option.text) === normalizeAnswer(key.value), + )?.text ?? String(key.value ?? "") + : String(key.text ?? key.value ?? ""), }); } diff --git a/src/app/api/practice/review/route.ts b/src/app/api/practice/review/route.ts new file mode 100644 index 0000000..620b873 --- /dev/null +++ b/src/app/api/practice/review/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getOptionalViewer } from "@/lib/auth"; +import { createAdminClient } from "@/lib/supabase/admin"; + +const schema = z.object({ + questionId: z.uuid(), + difficult: z.boolean(), + score: z.number().min(0).max(100).default(0), +}); + +export async function POST(request: Request) { + const viewer = await getOptionalViewer(); + if (!viewer) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const input = schema.parse(await request.json()); + const admin = createAdminClient(); + const { error } = await admin.rpc("set_question_review_state", { + target_user_id: viewer.id, + target_question_id: input.questionId, + target_difficult: input.difficult, + target_score: input.score, + }); + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + return NextResponse.json({ saved: true }); +} diff --git a/src/app/api/profile/avatar/route.ts b/src/app/api/profile/avatar/route.ts new file mode 100644 index 0000000..a18c545 --- /dev/null +++ b/src/app/api/profile/avatar/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { getOptionalViewer } from "@/lib/auth"; +import { createAdminClient } from "@/lib/supabase/admin"; + +const allowedTypes = new Map([ + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/webp", "webp"], + ["image/gif", "gif"], +]); + +function hasValidImageSignature(type: string, bytes: Uint8Array) { + if (type === "image/jpeg") return bytes[0] === 0xff && bytes[1] === 0xd8; + if (type === "image/png") { + return ( + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 + ); + } + const header = new TextDecoder().decode(bytes.slice(0, 12)); + if (type === "image/gif") return header.startsWith("GIF8"); + if (type === "image/webp") { + return header.startsWith("RIFF") && header.slice(8, 12) === "WEBP"; + } + return false; +} + +export async function POST(request: Request) { + const viewer = await getOptionalViewer(); + if (!viewer) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("avatar"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "Vui lòng chọn một ảnh." }, { status: 400 }); + } + const extension = allowedTypes.get(file.type); + if (!extension) { + return NextResponse.json( + { error: "Chỉ hỗ trợ JPG, PNG, WebP hoặc GIF." }, + { status: 400 }, + ); + } + if (file.size > 5 * 1024 * 1024) { + return NextResponse.json( + { error: "Ảnh đại diện không được vượt quá 5 MB." }, + { status: 400 }, + ); + } + const fileBuffer = await file.arrayBuffer(); + if (!hasValidImageSignature(file.type, new Uint8Array(fileBuffer))) { + return NextResponse.json( + { error: "Nội dung file không phải ảnh hợp lệ." }, + { status: 400 }, + ); + } + + const admin = createAdminClient(); + const path = `${viewer.id}/avatar.${extension}`; + const { error: uploadError } = await admin.storage + .from("avatars") + .upload(path, fileBuffer, { + contentType: file.type, + cacheControl: "3600", + upsert: true, + }); + if (uploadError) { + return NextResponse.json({ error: uploadError.message }, { status: 400 }); + } + + const { data } = admin.storage.from("avatars").getPublicUrl(path); + const avatarUrl = `${data.publicUrl}?v=${Date.now()}`; + const { error: profileError } = await admin + .from("profiles") + .update({ avatar_url: avatarUrl, updated_at: new Date().toISOString() }) + .eq("id", viewer.id); + if (profileError) { + return NextResponse.json({ error: profileError.message }, { status: 400 }); + } + + return NextResponse.json({ avatarUrl }); +} diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index 3186b02..e5962f5 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -9,6 +9,16 @@ const schema = z.object({ learningGoal: z.string().max(500).optional(), dailyGoalMinutes: z.number().int().min(5).max(240).optional(), aiTrainingConsent: z.boolean().optional(), + preferences: z + .object({ + emailReminders: z.boolean(), + dailyReminder: z.boolean(), + weeklySummary: z.boolean(), + autoPlayAudio: z.boolean(), + showMascot: z.boolean(), + compactMode: z.boolean(), + }) + .optional(), }); export async function PATCH(request: Request) { @@ -34,6 +44,9 @@ export async function PATCH(request: Request) { consent_updated_at: new Date().toISOString(), } : {}), + ...(input.preferences !== undefined + ? { preferences: input.preferences } + : {}), updated_at: new Date().toISOString(), }) .eq("id", viewer.id); diff --git a/src/app/api/roadmap/session/route.ts b/src/app/api/roadmap/session/route.ts new file mode 100644 index 0000000..dbdb8a8 --- /dev/null +++ b/src/app/api/roadmap/session/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getOptionalViewer } from "@/lib/auth"; +import { createAdminClient } from "@/lib/supabase/admin"; + +const requestSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("start"), + unitId: z.uuid(), + }), + z.object({ + action: z.literal("finalize"), + sessionId: z.uuid(), + }), +]); + +export async function POST(request: Request) { + const viewer = await getOptionalViewer(); + if (!viewer) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const input = requestSchema.parse(await request.json()); + const admin = createAdminClient(); + + if (input.action === "start") { + const { data, error } = await admin.rpc("start_learning_unit_session", { + target_user_id: viewer.id, + target_unit_id: input.unitId, + }); + if (error) { + return NextResponse.json({ error: error.message }, { status: 403 }); + } + return NextResponse.json( + { sessionId: data }, + { headers: { "Cache-Control": "private, no-store" } }, + ); + } + + const { data: result, error } = await admin.rpc( + "finalize_learning_unit_session", + { + target_user_id: viewer.id, + target_session_id: input.sessionId, + }, + ); + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + let nextUnit: { id: string; title: Record } | null = null; + const nextUnitId = (result as { nextUnitId?: string | null } | null) + ?.nextUnitId; + if (nextUnitId) { + const { data } = await admin + .from("learning_units") + .select("id,title") + .eq("id", nextUnitId) + .maybeSingle(); + nextUnit = data as typeof nextUnit; + } + + return NextResponse.json( + { result, nextUnit }, + { headers: { "Cache-Control": "private, no-store" } }, + ); +} diff --git a/src/app/api/translation/google/route.ts b/src/app/api/translation/google/route.ts new file mode 100644 index 0000000..287f3c7 --- /dev/null +++ b/src/app/api/translation/google/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getOptionalViewer } from "@/lib/auth"; +import { consumeAiQuota } from "@/lib/billing"; +import { hasFeatureAccess } from "@/lib/economy"; +import { + GoogleTranslationError, + translateWithGoogle, +} from "@/lib/google-cloud-translate"; +import { + isTranslationLanguage, + translationLanguages, +} from "@/lib/translation-languages"; +import { createClient } from "@/lib/supabase/server"; + +const languageCodes = translationLanguages.map((language) => language.code) as [ + string, + ...string[], +]; + +const schema = z.object({ + text: z.string().trim().min(1).max(10_000), + source: z.enum(languageCodes).nullable().optional(), + target: z.enum(languageCodes), +}); + +export async function POST(request: Request) { + try { + const viewer = await getOptionalViewer(); + if (!viewer) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!(await hasFeatureAccess(viewer.id, "translation", "basic"))) { + return NextResponse.json( + { + error: "Dịch thuật yêu cầu gói Basic, Plus hoặc Pro.", + code: "PLAN_UPGRADE_REQUIRED", + requiredPlan: "basic", + }, + { status: 403 }, + ); + } + + const input = schema.parse(await request.json()); + if (input.source && input.source === input.target) { + return NextResponse.json( + { error: "Ngôn ngữ nguồn và đích phải khác nhau." }, + { status: 400 }, + ); + } + if ( + (input.source && !isTranslationLanguage(input.source)) || + !isTranslationLanguage(input.target) + ) { + return NextResponse.json( + { error: "Ngôn ngữ chưa được hỗ trợ." }, + { status: 400 }, + ); + } + if (!process.env.GOOGLE_CLOUD_TRANSLATION_API_KEY?.trim()) { + return NextResponse.json( + { + error: "Google Cloud Translation chưa được cấu hình.", + code: "GOOGLE_TRANSLATION_NOT_CONFIGURED", + }, + { status: 503 }, + ); + } + + const usage = await consumeAiQuota(viewer.id); + if (!usage.allowed) { + return NextResponse.json( + { + error: `Bạn đã dùng hết ${usage.quota} lượt AI hôm nay.`, + code: "AI_QUOTA_EXCEEDED", + }, + { status: 429 }, + ); + } + + const result = await translateWithGoogle({ + text: input.text, + source: input.source ?? undefined, + target: input.target, + }); + const supabase = await createClient(); + await supabase.from("learning_events").insert({ + user_id: viewer.id, + event_type: "translation", + skill: "translation", + duration_seconds: 0, + metadata: { + provider: "google-cloud-translation", + source: result.detectedSourceLanguage, + target: input.target, + characters: input.text.length, + }, + }); + + return NextResponse.json( + { + ...result, + provider: "google-cloud-translation", + usage: { + used: usage.used, + quota: usage.quota, + remaining: Math.max(0, usage.quota - usage.used), + }, + }, + { headers: { "Cache-Control": "private, no-store" } }, + ); + } catch (error) { + const message = + error instanceof z.ZodError + ? "Dữ liệu dịch không hợp lệ." + : error instanceof Error + ? error.message + : "Không thể dịch nội dung."; + const status = + error instanceof GoogleTranslationError + ? error.status + : error instanceof z.ZodError + ? 400 + : 500; + const code = + error instanceof GoogleTranslationError ? error.code : undefined; + return NextResponse.json( + { error: message, code }, + { status, headers: { "Cache-Control": "private, no-store" } }, + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 57064ff..7fae219 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -201,6 +201,18 @@ border-top: 4px dotted rgb(125 211 252 / 75%); } + .journey-route path:last-child { + animation: journey-dash 14s linear infinite; + } + + .journey-cloud { + animation: journey-cloud 5s ease-in-out infinite; + } + + .sticker-pop { + animation: sticker-pop 2.4s ease-in-out infinite; + } + html[data-motion="reduced"] *, html[data-motion="reduced"] *::before, html[data-motion="reduced"] *::after { @@ -209,6 +221,15 @@ animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; } + + html[data-density="compact"] main { + padding-top: 1rem; + padding-bottom: 1rem; + } + + html[data-density="compact"] main > div { + gap: 1rem; + } } @keyframes lumo-float { @@ -239,3 +260,27 @@ transform: translateY(0); } } + +@keyframes journey-dash { + to { + stroke-dashoffset: -120; + } +} + +@keyframes journey-cloud { + 0%, 100% { + transform: translateX(0) translateY(0); + } + 50% { + transform: translateX(8px) translateY(-3px); + } +} + +@keyframes sticker-pop { + 0%, 100% { + transform: translateY(0) rotate(-3deg); + } + 50% { + transform: translateY(-4px) rotate(4deg); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7174495..5ed0131 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -37,11 +37,12 @@ export default async function RootLayout({ - + - + {children} diff --git a/src/app/learn/[slug]/page.tsx b/src/app/learn/[slug]/page.tsx index 843a574..d9b8186 100644 --- a/src/app/learn/[slug]/page.tsx +++ b/src/app/learn/[slug]/page.tsx @@ -1,4 +1,11 @@ +import Link from "next/link"; +import { LockKeyhole } from "lucide-react"; import { notFound, redirect } from "next/navigation"; +import { LearningWorkspace } from "@/components/learning-workspace"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { requireViewer } from "@/lib/auth"; +import { getUnitLessonData } from "@/lib/learning-data"; type Props = { params: Promise<{ slug: string }>; @@ -10,6 +17,38 @@ export default async function LearnPage({ params }: Props) { "documents", "vocabulary", "flashcards", "reading", "listening", "speaking", "writing", "translation", "quiz", "progress", ]); - if (!supported.has(slug)) notFound(); - redirect(`/${slug}`); + if (supported.has(slug)) redirect(`/${slug}`); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(slug)) { + notFound(); + } + const viewer = await requireViewer(); + const data = await getUnitLessonData(viewer, slug); + if (!data) notFound(); + if (data.locked) { + return ( + + + + + +

Checkpoint chưa được mở khóa

+

+ {data.reason === "not_enrolled" + ? "Đăng ký lộ trình trước khi bắt đầu bài học." + : "Đạt mastery của checkpoint trước để tiếp tục."} +

+ +
+
+ ); + } + return ( + + ); } diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..a749329 --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ +
+ {Array.from({ length: 4 }, (_, index) => ( + + ))} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/app/practice/page.tsx b/src/app/practice/page.tsx index cf4d8a5..2daf949 100644 --- a/src/app/practice/page.tsx +++ b/src/app/practice/page.tsx @@ -5,5 +5,5 @@ import { getLearningWorkspaceData } from "@/lib/learning-data"; export default async function PracticePage() { const viewer = await requireViewer(); const data = await getLearningWorkspaceData(viewer, { kind: "practice" }); - return ; + return ; } diff --git a/src/components/account-settings.tsx b/src/components/account-settings.tsx index 766f3f5..0158a22 100644 --- a/src/components/account-settings.tsx +++ b/src/components/account-settings.tsx @@ -1,7 +1,21 @@ "use client"; -import { useState } from "react"; -import { BrainCircuit, Globe2, Save, ShieldCheck, UserRound } from "lucide-react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { ChangeEvent, useRef, useState } from "react"; +import { + BellRing, + BrainCircuit, + Camera, + Clock3, + Globe2, + Headphones, + Loader2, + Mail, + Save, + ShieldCheck, + UserRound, +} from "lucide-react"; import { toast } from "sonner"; import type { Viewer } from "@/lib/auth"; import { localeNames, type Locale } from "@/lib/i18n"; @@ -27,6 +41,16 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { useLocale } from "@/components/locale-provider"; +import { Switch } from "@/components/ui/switch"; + +type Preferences = { + emailReminders: boolean; + dailyReminder: boolean; + weeklySummary: boolean; + autoPlayAudio: boolean; + showMascot: boolean; + compactMode: boolean; +}; export function AccountSettings({ viewer, @@ -37,14 +61,57 @@ export function AccountSettings({ learningGoal: string; dailyGoalMinutes: number; aiTrainingConsent: boolean; + preferences: Preferences; }; }) { + const router = useRouter(); const { setLocale: setAppLocale } = useLocale(); const [fullName, setFullName] = useState(viewer.fullName); const [locale, setLocale] = useState(viewer.locale as Locale); const [goal, setGoal] = useState(initial.learningGoal); const [minutes, setMinutes] = useState(initial.dailyGoalMinutes); const [consent, setConsent] = useState(initial.aiTrainingConsent); + const [preferences, setPreferences] = useState(initial.preferences); + const [avatarUrl, setAvatarUrl] = useState(viewer.avatarUrl); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const fileInputRef = useRef(null); + + function updatePreference(key: keyof Preferences, value: boolean) { + setPreferences((current) => ({ ...current, [key]: value })); + } + + async function uploadAvatar(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + if (!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(file.type)) { + toast.error("Chỉ hỗ trợ JPG, PNG, WebP hoặc GIF."); + return; + } + if (file.size > 5 * 1024 * 1024) { + toast.error("Ảnh đại diện không được vượt quá 5 MB."); + return; + } + const preview = URL.createObjectURL(file); + setAvatarUrl(preview); + setUploadingAvatar(true); + const formData = new FormData(); + formData.set("avatar", file); + const response = await fetch("/api/profile/avatar", { + method: "POST", + body: formData, + }); + const data = (await response.json()) as { avatarUrl?: string; error?: string }; + setUploadingAvatar(false); + URL.revokeObjectURL(preview); + event.target.value = ""; + if (!response.ok || !data.avatarUrl) { + setAvatarUrl(viewer.avatarUrl); + return toast.error(data.error ?? "Không thể tải ảnh đại diện."); + } + setAvatarUrl(data.avatarUrl); + toast.success("Đã cập nhật ảnh đại diện."); + router.refresh(); + } async function save() { const response = await fetch("/api/profile", { @@ -56,6 +123,7 @@ export function AccountSettings({ learningGoal: goal, dailyGoalMinutes: minutes, aiTrainingConsent: consent, + preferences, }), }); const data = (await response.json()) as { error?: string }; @@ -67,12 +135,13 @@ export function AccountSettings({ }); setAppLocale(locale); toast.success("Đã lưu hồ sơ."); + router.refresh(); } return ( -
- - +
+ + Hồ sơ học tập @@ -80,7 +149,54 @@ export function AccountSettings({ Lingora dùng mục tiêu và thời gian để điều chỉnh lộ trình. - + +
+
+ {avatarUrl ? ( + {fullName} + ) : ( + + {fullName.slice(0, 1).toUpperCase()} + + )} + {uploadingAvatar ? ( + + + + ) : null} +
+
+

Ảnh đại diện của bạn

+

+ JPG, PNG, WebP hoặc GIF. Kích thước tối đa 5 MB. +

+ + +
+
+ +
setFullName(event.target.value)} /> @@ -99,11 +215,12 @@ export function AccountSettings({
+