diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000000000000000000000000000000000000..4ade604372b5fa115a9b9e7aa18fc970333203eb --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"6e5f9b33-6766-4a70-8673-bd559c8270cb","pid":16372,"acquiredAt":1778656145073} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000000000000000000000000000000000..ab66da45e61b0a9638f987e1f355f5b546a30ae5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,56 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:api.anthropic.com)", + "Bash(tar -xf bundle)", + "Bash(npx tsc *)", + "Bash(echo \"EXIT=$?\")", + "Bash(npx next *)", + "Bash(npx eslint *)", + "Bash(git restore *)", + "Bash(git reset *)", + "Bash(Get-ChildItem -Path \"E:\\\\language-learner\\\\mobile\" -Recurse -Include \"pubspec.yaml\", \"*.yaml\", \"*.yml\")", + "Bash(Select-Object FullName)", + "Bash(Get-ChildItem -Path \"E:\\\\language-learner\\\\node_modules\\\\next\\\\dist\\\\docs\" -ErrorAction SilentlyContinue)", + "Bash(Select-Object -First 20)", + "Bash(npm install *)", + "Bash(Get-Content \"E:\\\\language-learner\\\\mobile\\\\lib\\\\features\\\\home\\\\home_shell.dart\")", + "Bash(Select-Object -First 3)", + "Bash(npm run *)", + "mcp__puppeteer__puppeteer_navigate", + "mcp__puppeteer__puppeteer_screenshot", + "Bash(taskkill /PID 24984 /F)", + "PowerShell(Stop-Process *)", + "PowerShell(Get-Process *)", + "Read(//tmp/**)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/onboarding)", + "Bash(curl -s -o /dev/null -w \" | /camera: %{http_code}\" http://localhost:3000/camera)", + "Bash(git add *)", + "Bash(git commit -m ' *)", + "Bash(git push *)", + "PowerShell(git push *)", + "Bash(taskkill *)", + "Bash(git *)", + "Bash(npm ls *)", + "Bash(findstr /i \"huggingface inference\")", + "mcp__claude_ai_Hugging_Face__hub_repo_details", + "mcp__claude_ai_Hugging_Face__hub_repo_search", + "Bash(curl -s -o NUL -w \"practice %{http_code}\\\\n\" http://localhost:3000/practice)", + "Bash(curl -s -o NUL -w \"phrases %{http_code}\\\\n\" http://localhost:3000/phrases)", + "Bash(curl -s -o NUL -w \"camera %{http_code}\\\\n\" http://localhost:3000/camera)", + "Bash(npx --no -- tsc --noEmit --jsx preserve --esModuleInterop --skipLibCheck app/onboarding/OnboardingForm.tsx)", + "Bash(Get-ChildItem -Path \"E:\\\\language-learner\\\\mobile\\\\lib\" -Recurse -File)", + "Bash(Sort-Object FullName)", + "Bash(Get-ChildItem -Path \"E:\\\\language-learner\\\\app\" -Recurse -Directory)", + "Bash(Select-Object -First 30 FullName)", + "Bash(grep -E \"\\\\.\\(tsx?|json|mjs\\)$\")", + "Bash(xargs wc -l)", + "Bash(flutter analyze *)", + "Bash(ls *)", + "Bash(xargs grep -l \"novita\")", + "Bash(echo \"EXIT_CODE=$?\")" + ] + }, + "outputStyle": "default" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..1966a4c013a300b938b4bbece2e31e5a61869577 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.git +.gitignore +.github +.next +node_modules +npm-debug.log +.env +.env.local +.env.*.local +.vscode +.idea +.DS_Store + +# Mobile app — not part of the web build +mobile + +# Claude / local working dirs +.claude +docs + +# Build artifacts you don't need in the container +tsconfig.tsbuildinfo +response.html +*.log diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..f7a65c27822c90785e4c93b376298ecb8b140374 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +logo.png filter=lfs diff=lfs merge=lfs -text +mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..7b8da95f5e59cc72a63ed135ddaf5952d440535e --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..eaf91e2ac647df635a09f01b8a2a254252aae8d7 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000000000000000000000000000000000000..12809fcd11adb9aebcab53e4aa3f8c3898322733 --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1857 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000000000000000000000000000000000000..81c3e5698381c662938cc590b3f29f2f49807b12 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/language-learner.iml b/.idea/language-learner.iml new file mode 100644 index 0000000000000000000000000000000000000000..2dab39c0f666a5b88edd6697f9ec565d78a4ce20 --- /dev/null +++ b/.idea/language-learner.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml new file mode 100644 index 0000000000000000000000000000000000000000..ef2788df58feb598a5186b129568ca433a00abd2 --- /dev/null +++ b/.idea/libraries/Dart_Packages.xml @@ -0,0 +1,972 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/libraries/Dart_SDK.xml b/.idea/libraries/Dart_SDK.xml new file mode 100644 index 0000000000000000000000000000000000000000..6b5df16842c0068cc40a81da19e62ae4d93fd7eb --- /dev/null +++ b/.idea/libraries/Dart_SDK.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000000000000000000000000000000000000..b1f3d014d1aa1dea7877df4baf7c5282960657a2 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000000000000000000000000000000000000..c08a2df09cc4875630bdee8deb7354ca55936d71 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000000000000000000000000000000000..b0e0a6c8b5fcb3f65075a90f573d5eddb71031c7 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000000000000000000000000000000000..c8397c94c0e3faf3de616326ba8901e97fca11ca --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000000000000000000000000000000000..bd98b4ff8381117998bbf72bcac6eaa6f14eb27e --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..8bd0e39085d5260e7f8faffcad2fdc45e10aef33 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..43c994c2d3617f947bcb5adf1933e21dabe46bb5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4d4a6ede7e2b2f0a853fad0f0c2747a4b8f4e144 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.7 + +# ---- deps ---- +FROM node:20-alpine AS deps +WORKDIR /app +RUN apk add --no-cache libc6-compat +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +# ---- builder ---- +FROM node:20-alpine AS builder +WORKDIR /app +RUN apk add --no-cache libc6-compat +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# lib/db/client.ts throws at import time if DATABASE_URL is unset, and +# `next build` evaluates server modules. Provide a placeholder so the build +# succeeds. Real value (if any) is injected at runtime via HF Space Secrets. +ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +RUN npm run build + +# ---- runner ---- +FROM node:20-alpine AS runner +WORKDIR /app +RUN apk add --no-cache libc6-compat \ + && addgroup -g 1001 -S nodejs \ + && adduser -u 1001 -S nextjs -G nodejs + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +# HF Spaces routes traffic to $PORT (defaults to 7860). Bind to 0.0.0.0 +# so the container is reachable from outside. +ENV PORT=7860 +ENV HOSTNAME=0.0.0.0 + +COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json + +USER nextjs +EXPOSE 7860 + +CMD ["npx", "next", "start", "-H", "0.0.0.0", "-p", "7860"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..8f2f0bf6698e188f683a372e65d914d5cdb6c856 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +--- +title: PraxaLing +emoji: 🦜 +colorFrom: yellow +colorTo: gray +sdk: docker +app_port: 7860 +pinned: false +hf_oauth: true +hf_oauth_scopes: + - email + - inference-api +--- + +# PraxaLing + +Learn languages through short stories and the world around you, powered by HuggingFace for identity and inference. + +Two clients, one backend: +- **Web** — Next.js 16 App Router (also acts as the shared API). +- **Mobile** — Flutter (latest stable), calls the same HTTP endpoints. + +Three flows: +1. **Sign in with HuggingFace** (OAuth). +2. **Practice** — read short stories matched to your CEFR level and target language; tap any word for a translation. Generate new stories on demand. +3. **Camera** — take or upload a photo; the backend runs BLIP captioning, DETR detection, NLLB translation, and Mistral-generated example sentences. + +## Stack + +| Concern | Tech | +|---|---| +| Web | Next.js 16.2 · React 19 · Tailwind v4 · Turbopack | +| Mobile | Flutter 3.41 · Riverpod · go_router · dio | +| Auth | HuggingFace OAuth (`arctic`) · JWT (`jose`) | +| DB | Postgres · Drizzle ORM | +| Storage | Vercel Blob (optional) | +| Inference | `@huggingface/inference` · Mistral-7B-Instruct · BLIP · DETR · NLLB-200 | + +## Getting started (web) + +```bash +# 1. Install deps +npm install + +# 2. Bring up Postgres +docker compose up -d + +# 3. Configure env — copy and fill in +cp .env.example .env.local + +# 4. Apply schema +npm run db:push + +# 5. (Optional) seed stories +npm run db:seed + +# 6. Run dev server +npm run dev +``` + +Required env: +- `AUTH_SECRET` — any 32+ char random string (`openssl rand -base64 32`) +- `HF_OAUTH_CLIENT_ID` / `HF_OAUTH_CLIENT_SECRET` — from https://huggingface.co/settings/applications/new, or use the Space metadata `hf_oauth: true` and the app will read Hugging Face's injected `OAUTH_CLIENT_ID` / `OAUTH_CLIENT_SECRET` +- `HF_TOKEN` — read token from https://huggingface.co/settings/tokens +- Optional: `BLOB_READ_WRITE_TOKEN` for Vercel Blob + +Redirect URIs to register on the HF OAuth app: +- `http://localhost:3000/api/auth/callback/huggingface` +- `http://localhost:3000/api/auth/callback/huggingface?client=mobile` +- `https:///api/auth/callback/huggingface` +- `https:///api/auth/callback/huggingface?client=mobile` + +For the hosted Space, use the direct `*.hf.space` host, not the `huggingface.co/spaces/...` page, for redirect URIs and mobile `API_BASE_URL`. + +## Getting started (mobile) + +```bash +cd mobile +flutter pub get + +# iOS simulator — host loopback is 127.0.0.1 by default +flutter run --dart-define=API_BASE_URL=http://127.0.0.1:3000 + +# Android emulator — host loopback is 10.0.2.2 (default) +flutter run + +# Hosted backend / physical device +flutter run --dart-define=API_BASE_URL=https:// +``` + +See `docs/API.md` for the endpoints the app consumes. + +## Architecture notes + +- **Auth**: both clients exchange an HF OAuth code for our own short-lived JWT. Web stores it in an httpOnly cookie; mobile in `flutter_secure_storage`. `lib/auth/session.ts#getSession()` handles both. +- **Next.js 16 specifics**: `middleware.ts` is now `proxy.ts`; dynamic APIs (`cookies`, `headers`, `params`) are async; Turbopack is default. See `node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md`. +- **Data flow for camera**: multipart → BLIP + DETR in parallel → dedupe labels → translate each to target lang → Mistral generates 3 sentences keyed on caption + objects → persisted in `vision_jobs`. + +## Scripts + +| Command | Purpose | +|---|---| +| `npm run dev` | Next.js dev server | +| `npm run build` | Production build | +| `npm run db:push` | Apply schema to Postgres | +| `npm run db:generate` | Generate migration from schema | +| `npm run db:migrate` | Apply pending migrations | +| `npm run db:seed` | Insert hand-curated stories | +| `npm run db:studio` | Drizzle Studio UI | diff --git a/app/(app)/BottomNavLink.tsx b/app/(app)/BottomNavLink.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b1fc870fde988095b2a7f528c41f055135d1ca2 --- /dev/null +++ b/app/(app)/BottomNavLink.tsx @@ -0,0 +1,24 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export function BottomNavLink({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) { + const pathname = usePathname(); + const isActive = pathname === href || pathname.startsWith(`${href}/`); + + return ( + + {icon} + {label} + + ); +} diff --git a/app/(app)/camera/AnalysisResults.tsx b/app/(app)/camera/AnalysisResults.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a1710e6e766e5449e414919b37c0441d66f3fa0c --- /dev/null +++ b/app/(app)/camera/AnalysisResults.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Furi } from "@/components/Furi"; +import { TTSButton } from "@/components/TTSButton"; +import type { Analysis } from "./types"; + +const EASE = [0.16, 1, 0.3, 1] as const; + +export function AnalysisResults({ analysis, lang }: { analysis: Analysis; lang: string }) { + return ( + + + {analysis.objects.length > 0 ? ( +
    + {analysis.objects.map((o, i) => ( +
  • +
    + +
    +
    + +
    + {o.romanized ?
    {o.romanized}
    : null} +
    {o.label}
    +
    +
    +
    + {Math.round(o.score * 100)}% +
    +
  • + ))} +
+ ) : ( +

No objects detected. Try a clearer photo.

+ )} +
+ + + {analysis.sentences.length > 0 ? ( +
    + {analysis.sentences.map((s, i) => ( +
  • +
    +
    + +
    + +
    + {s.romanized ?
    {s.romanized}
    : null} +
    {s.gloss}
    +
  • + ))} +
+ ) : ( +

No sentences generated. Retry the photo.

+ )} +
+
+ ); +} + +function ResultCard({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ + {title} +

+ {children} +
+ ); +} diff --git a/app/(app)/camera/CameraClient.tsx b/app/(app)/camera/CameraClient.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d762a817b9369d25069f0200d5453580323e3c46 --- /dev/null +++ b/app/(app)/camera/CameraClient.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useRef, useState } from "react"; +import { motion } from "framer-motion"; +import type { Level } from "@/lib/languages"; +import { LevelPicker } from "@/components/LevelPicker"; +import { PhotoMarkers } from "./PhotoMarkers"; +import { UploadDropzone } from "./UploadDropzone"; +import { AnalysisResults } from "./AnalysisResults"; +import type { Analysis } from "./types"; +import { Camera, RotateCcw, Wand2, AlertCircle } from "lucide-react"; + +export function CameraClient({ defaultLevel, lang }: { defaultLevel: Level; lang: string }) { + const [previewUrl, setPreviewUrl] = useState(null); + const [imageSize, setImageSize] = useState<{ w: number; h: number } | null>(null); + const [analysis, setAnalysis] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [level, setLevel] = useState(defaultLevel); + const inputRef = useRef(null); + // Increments per pick so a slow analysis can't attach to a newer photo. + const uploadSeq = useRef(0); + + function pick(file: File) { + setError(null); + setAnalysis(null); + if (previewUrl) URL.revokeObjectURL(previewUrl); + + const url = URL.createObjectURL(file); + setPreviewUrl(url); + + const img = new Image(); + img.onload = () => setImageSize({ w: img.naturalWidth, h: img.naturalHeight }); + img.src = url; + + void upload(file, ++uploadSeq.current); + } + + async function upload(file: File, seq: number) { + setIsLoading(true); + try { + const form = new FormData(); + form.append("image", file); + form.append("level", level); + const res = await fetch("/api/vision/analyze", { method: "POST", body: form }); + if (seq !== uploadSeq.current) return; + if (!res.ok) { + const j = await res.json().catch(() => ({})); + setError(j.message || j.error || "Analysis failed."); + return; + } + setAnalysis((await res.json()) as Analysis); + } catch { + if (seq === uploadSeq.current) setError("Network error. Please try again."); + } finally { + if (seq === uploadSeq.current) setIsLoading(false); + } + } + + function onInputChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) pick(file); + } + + function clear() { + uploadSeq.current++; + if (previewUrl) URL.revokeObjectURL(previewUrl); + setPreviewUrl(null); + setAnalysis(null); + setImageSize(null); + setError(null); + setIsLoading(false); + if (inputRef.current) inputRef.current.value = ""; + } + + return ( +
+ + +
+ Sentences match level + +
+ + {!previewUrl && inputRef.current?.click()} />} + + {previewUrl && imageSize && ( +
+
+ + +
+ +
+
+ Selected practice + + {analysis ? : null} + + {isLoading && ( +
+ + Analyzing image... +
+ )} +
+
+ + {analysis?.caption ? ( +
+ “{analysis.caption}” +
+ ) : null} +
+ )} + + {error ? ( + + +
+

Analysis failed

+

{error}

+
+
+ ) : null} + + {analysis ? : null} +
+ ); +} diff --git a/app/(app)/camera/PhotoMarkers.tsx b/app/(app)/camera/PhotoMarkers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c0dfeeacbc3dec6ba23fafe0a97fc4906858903 --- /dev/null +++ b/app/(app)/camera/PhotoMarkers.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useState } from "react"; +import type { FuriSegment } from "@/lib/types/dialogue"; +import { Furi } from "@/components/Furi"; + +type MarkerObject = { + label: string; + translation: string; + translationSegments?: FuriSegment[]; + romanized?: string; + box: [number, number, number, number]; +}; + +export function PhotoMarkers({ + objects, + imageSize, +}: { + objects: MarkerObject[]; + imageSize: { w: number; h: number }; +}) { + const [selected, setSelected] = useState(null); + + return ( + <> + {objects.map((obj, i) => { + const [x1, y1, x2, y2] = obj.box; + const isNormalized = x1 <= 1 && y1 <= 1 && x2 <= 1 && y2 <= 1; + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const left = isNormalized ? `${cx * 100}%` : `${(cx / imageSize.w) * 100}%`; + const top = isNormalized ? `${cy * 100}%` : `${(cy / imageSize.h) * 100}%`; + + const isSelected = selected === i; + const isDimmed = selected !== null && !isSelected; + const wrapperOpacity = isDimmed ? "opacity-30" : "opacity-100"; + const wrapperZ = isSelected ? "z-20" : "z-10"; + + return ( + + ); + })} + + ); +} diff --git a/app/(app)/camera/UploadDropzone.tsx b/app/(app)/camera/UploadDropzone.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aaae87567395e5dc7173a4cf54eba2a04231e9cf --- /dev/null +++ b/app/(app)/camera/UploadDropzone.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ImagePlus } from "lucide-react"; + +const EASE = [0.16, 1, 0.3, 1] as const; + +export function UploadDropzone({ onPick }: { onPick: () => void }) { + return ( + +
+
+ +
+

Drop anything to upload

+

+ Camera or gallery images automatically become interactive vocabulary cards. +

+
+
+ ); +} diff --git a/app/(app)/camera/page.tsx b/app/(app)/camera/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d648975f52154bbbee49b6a32b4f18ed8ae5bf9 --- /dev/null +++ b/app/(app)/camera/page.tsx @@ -0,0 +1,36 @@ +import { getSession } from "@/lib/auth/session"; +import type { Level } from "@/lib/languages"; +import { CameraClient } from "./CameraClient"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; + +export default async function CameraPage() { + const session = (await getSession())!; + const targetLangs = session.targetLangs ?? (session.targetLang ? [session.targetLang] : []); + return ( +
+
+
+
+ + Image practice + +

+ Learn from a photo +

+

+ Capture objects around you and turn them into vocabulary. +

+
+ +
+
+ +
+ ); +} + diff --git a/app/(app)/camera/types.ts b/app/(app)/camera/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..535b6d1fa9033b1ae0197bfe48f1cd04c6aa0eae --- /dev/null +++ b/app/(app)/camera/types.ts @@ -0,0 +1,25 @@ +import type { FuriSegment } from "@/lib/types/dialogue"; + +export type AnalysisObject = { + label: string; + translation: string; + translationSegments?: FuriSegment[]; + romanized?: string; + box: [number, number, number, number]; + score: number; +}; + +export type AnalysisSentence = { + target: string; + targetSegments?: FuriSegment[]; + gloss: string; + romanized?: string; +}; + +export type Analysis = { + id: string; + caption: string; + objects: AnalysisObject[]; + sentences: AnalysisSentence[]; + imageUrl?: string; +}; diff --git a/app/(app)/error.tsx b/app/(app)/error.tsx new file mode 100644 index 0000000000000000000000000000000000000000..59b833b902915991166b9ac78c506312346e8660 --- /dev/null +++ b/app/(app)/error.tsx @@ -0,0 +1,17 @@ +"use client"; + +export default function AppError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message || "Please try again."}

+ +
+ ); +} + diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be1769afdb0da3958fed72a1a128092bb16ae254 --- /dev/null +++ b/app/(app)/layout.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import Image from "next/image"; +import { redirect } from "next/navigation"; +import { Camera, Home, Languages, User } from "lucide-react"; +import { getSession } from "@/lib/auth/session"; +import { BottomNavLink } from "./BottomNavLink"; + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const session = await getSession(); + if (!session) redirect("/"); + if (!session.targetLang || !session.level) redirect("/onboarding"); + + return ( +
+
+ + PraxaLing logo + PraxaLing + +
+ +
+ {children} +
+ + +
+ ); +} diff --git a/app/(app)/phrases/PhraseResult/EmptyState.tsx b/app/(app)/phrases/PhraseResult/EmptyState.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0ce3a03ddd1ae378de36b9743eec3af11d4d960e --- /dev/null +++ b/app/(app)/phrases/PhraseResult/EmptyState.tsx @@ -0,0 +1,35 @@ +import { PenLine } from "lucide-react"; + +export function EmptyState({ isPending = false }: { isPending?: boolean }) { + return ( +
+
+ +
+ {isPending ? ( +
+
+ Building your mini lesson... +
+
+
+
+
+ ) : ( +

+ Enter a phrase to build a mini lesson +

+ )} + +
+ ); +} diff --git a/app/(app)/phrases/PhraseResult/HeaderCard.tsx b/app/(app)/phrases/PhraseResult/HeaderCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aa4d687f7d04d17f4ec79ac4f3cf4d70b1ce917c --- /dev/null +++ b/app/(app)/phrases/PhraseResult/HeaderCard.tsx @@ -0,0 +1,33 @@ +import type { FuriSegment } from "@/lib/types/dialogue"; +import { Furi } from "@/components/Furi"; +import { TTSButton } from "@/components/TTSButton"; + +export function HeaderCard({ + input, + inputSegments, + partOfSpeech, + translation, + lang, +}: { + input: string; + inputSegments?: FuriSegment[]; + partOfSpeech: string; + translation: string; + lang: string; +}) { + return ( +
+
{partOfSpeech}
+
+

+ +

+ +
+

{translation}

+
+ ); +} diff --git a/app/(app)/phrases/PhraseResult/KanjiCard.tsx b/app/(app)/phrases/PhraseResult/KanjiCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d87ce19f306c72b47e120ccbf6adb96e04f10dd4 --- /dev/null +++ b/app/(app)/phrases/PhraseResult/KanjiCard.tsx @@ -0,0 +1,39 @@ +import type { KanjiInfo } from "@/lib/hf/phrases"; +import { Furi } from "@/components/Furi"; + +export function KanjiCard({ kanji }: { kanji: KanjiInfo }) { + return ( +
+
+
{kanji.kanji}
+
{kanji.meaning}
+
+
+
+
On
+
+ {kanji.onyomi.length ? kanji.onyomi.join("、") : } +
+
+
+
Kun
+
+ {kanji.kunyomi.length ? kanji.kunyomi.join("、") : } +
+
+
+ {kanji.exampleWord ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/app/(app)/phrases/PhraseResult/RelatedWordCard.tsx b/app/(app)/phrases/PhraseResult/RelatedWordCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..03bea77f675156f3266bb0fdbd85239e41a5d69b --- /dev/null +++ b/app/(app)/phrases/PhraseResult/RelatedWordCard.tsx @@ -0,0 +1,25 @@ +import type { RelatedWord } from "@/lib/hf/phrases"; +import { Furi } from "@/components/Furi"; +import { TTSButton } from "@/components/TTSButton"; + +export function RelatedWordCard({ word, lang }: { word: RelatedWord; lang: string }) { + return ( +
+
+
+ +
+
+ {word.partOfSpeech ? ( + + {word.partOfSpeech} + + ) : null} + +
+
+
{word.translation}
+ {word.note ?

{word.note}

: null} +
+ ); +} diff --git a/app/(app)/phrases/PhraseResult/Section.tsx b/app/(app)/phrases/PhraseResult/Section.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0638a3b3d94b8bc2cfdc025363b5ed59a662de35 --- /dev/null +++ b/app/(app)/phrases/PhraseResult/Section.tsx @@ -0,0 +1,19 @@ +export function Section({ + title, + icon, + children, +}: { + title: string; + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+ {icon} +

{title}

+
+ {children} +
+ ); +} diff --git a/app/(app)/phrases/PhraseResult/SentenceCard.tsx b/app/(app)/phrases/PhraseResult/SentenceCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..491595f242cff11e7e44cf04beb5e05f9d955f5a --- /dev/null +++ b/app/(app)/phrases/PhraseResult/SentenceCard.tsx @@ -0,0 +1,20 @@ +import type { PhraseSentence } from "@/lib/hf/phrases"; +import { Furi } from "@/components/Furi"; +import { TTSButton } from "@/components/TTSButton"; + +export function SentenceCard({ sentence, lang }: { sentence: PhraseSentence; lang: string }) { + return ( +
+
+
+ +
+ +
+
{sentence.translation}
+ {sentence.note ? ( +
{sentence.note}
+ ) : null} +
+ ); +} diff --git a/app/(app)/phrases/PhraseResult/index.tsx b/app/(app)/phrases/PhraseResult/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9d80d08649b8c6667f851fca6b5db56b67b6359c --- /dev/null +++ b/app/(app)/phrases/PhraseResult/index.tsx @@ -0,0 +1,78 @@ +import { BookOpen, Languages, Lightbulb, ListChecks, MessageSquareText } from "lucide-react"; +import type { PhraseAnalysis } from "@/lib/hf/phrases"; +import { Section } from "./Section"; +import { HeaderCard } from "./HeaderCard"; +import { SentenceCard } from "./SentenceCard"; +import { KanjiCard } from "./KanjiCard"; +import { RelatedWordCard } from "./RelatedWordCard"; + +export function PhraseResult({ result, lang }: { result: PhraseAnalysis; lang: string }) { + return ( +
+ + +
}> +
+ {result.sentences.map((sentence, index) => ( + + ))} +
+
+ + {result.kanjiInfo.length > 0 ? ( +
}> +
+ {result.kanjiInfo.map((kanji, index) => ( + + ))} +
+
+ ) : null} + + {result.relatedWords.length > 0 ? ( +
}> +
+ {result.relatedWords.map((word, index) => ( + + ))} +
+
+ ) : null} + +
+
}> +
+ {result.breakdown.map((item, index) => ( +
+
{item.part}
+

{item.explanation}

+
+ ))} +
+
+ +
}> + {result.verbInfo ? ( +
+
Verb info
+

{result.verbInfo}

+
+ ) : null} +
    + {result.tips.map((tip, index) => ( +
  • + {tip} +
  • + ))} +
+
+
+
+ ); +} diff --git a/app/(app)/phrases/PhrasesClient.tsx b/app/(app)/phrases/PhrasesClient.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b99b0a767db1017bfee282afc45f9141dd14eea --- /dev/null +++ b/app/(app)/phrases/PhrasesClient.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; +import type { PhraseAnalysis } from "@/lib/hf/phrases"; +import type { Level } from "@/lib/languages"; +import { LevelPicker } from "@/components/LevelPicker"; +import { PhraseResult } from "./PhraseResult"; +import { EmptyState } from "./PhraseResult/EmptyState"; + +const examples = ["Car", "Dog", "Teacher", "finding lost documents"]; + +export function PhrasesClient({ defaultLevel, lang }: { defaultLevel: Level; lang: string }) { + const [text, setText] = useState(""); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(false); + const [level, setLevel] = useState(defaultLevel); + + async function analyze(value = text) { + if (isPending) return; + const phrase = value.trim(); + if (!phrase) { + setError("Enter a phrase first."); + return; + } + setError(null); + + setIsPending(true); + try { + const res = await fetch("/api/phrases", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: phrase, level }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.message || data.error || "Could not analyze this phrase."); + return; + } + setResult((await res.json()) as PhraseAnalysis); + } catch { + setError("Could not analyze this phrase."); + } finally { + setIsPending(false); + } + } + + return ( +
+
+
+ + +
+
+ setText(event.target.value)} + onKeyDown={(event) => event.key === "Enter" && analyze()} + placeholder="Type a phrase, word, or verb..." + className="h-20 w-full sm:flex-1 rounded-2xl border-4 border-black bg-white px-8 text-2xl font-black text-black outline-none transition-all focus:translate-x-[-2px] focus:translate-y-[-2px] focus:shadow-[6px_6px_0px_rgba(0,0,0,1)] placeholder:text-xl placeholder:font-bold placeholder:text-gray-400 shadow-[4px_4px_0px_rgba(0,0,0,1)]" + /> + +
+ {!text && ( +
+ {examples.map((example) => ( + + ))} +
+ )} + {error ? ( +
+ {error} +
+ ) : null} +
+ + {isPending ? : result ? : } +
+ ); +} diff --git a/app/(app)/phrases/page.tsx b/app/(app)/phrases/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6f12ae30cadb982b3d30c69e1af0e6d989928fa3 --- /dev/null +++ b/app/(app)/phrases/page.tsx @@ -0,0 +1,36 @@ +import { getSession } from "@/lib/auth/session"; +import { languageName, type Level } from "@/lib/languages"; +import { PhrasesClient } from "./PhrasesClient"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; + +export default async function PhrasesPage() { + const session = (await getSession())!; + const targetLangs = session.targetLangs ?? (session.targetLang ? [session.targetLang] : []); + + return ( +
+
+
+
+ + Phrase coach + +

+ Break down phrases +

+

+ Master {languageName(session.targetLang!)} with deep grammar insights, accurate translations, and contextual usage tips. +

+
+ +
+
+ +
+ ); +} diff --git a/app/(app)/practice/CustomTopicCard.tsx b/app/(app)/practice/CustomTopicCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..404c9bca03b0e2759811e7a827205788f03e573d --- /dev/null +++ b/app/(app)/practice/CustomTopicCard.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { LevelPicker } from "@/components/LevelPicker"; +import type { Level } from "@/lib/languages"; +import { Wand2, ArrowRight } from "lucide-react"; + +export function CustomTopicCard({ + level, + onLevelChange, + customTopic, + onCustomTopicChange, + onGenerate, + pending, + disabled, + error, +}: { + level: Level; + onLevelChange: (level: Level) => void; + customTopic: string; + onCustomTopicChange: (value: string) => void; + onGenerate: () => void; + pending: boolean; + disabled: boolean; + error: string | null; +}) { + return ( +
+
+
+ + Build your own +
+ +
+ +
+