Initialize repo

This commit is contained in:
Kazimierz Ciołek
2026-02-02 13:56:14 +01:00
commit 7ecefb5621
127 changed files with 219019 additions and 0 deletions

72
.gitignore vendored Normal file
View File

@@ -0,0 +1,72 @@
# --- System & IDE ---
.DS_Store
.Thumbs.db
.vs
.idea
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/launch.json
!.vscode/tasks.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sublime*
.aider*
# --- Node.js / Frontend ---
node_modules/
.npm/
.yarn/
dist/
dist-ssr/
*.tgz
package-lock.json
yarn.lock
bun.lockb
# --- Rust & Tauri ---
# Folder target jest kluczowy do ignorowania - zajmuje mnóstwo miejsca
target/
src-tauri/target/
src-tauri/dump.rdb
/examples/**/src-tauri/gen/
/bench/**/src-tauri/gen/
# Artefakty budowania i pliki binarne
src-tauri/*.app
src-tauri/*.deb
src-tauri/*.rpm
src-tauri/*.msi
src-tauri/*.exe
src-tauri/*.dll
src-tauri/*.lib
src-tauri/*.pdb
src-tauri/binaries/*
src-tauri/llama/*
# --- Logs & Runtime ---
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
debug.log
pids
*.pid
*.seed
*.pid.lock
# --- Configuration & Secrets ---
.env
.env*
*.local
# --- Miscellaneous ---
TODO.md
streaming_example_test_video.mp4

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

25
.npmignore Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.env
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
# Ignore artifacts:
build
src-tauri
node_modules
pnpm-lock.yaml

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Roman Sirokov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

35154
LLM_CONTEXTS/OpenWork.txt Normal file

File diff suppressed because it is too large Load Diff

26412
LLM_CONTEXTS/ispeakerreact.txt Normal file

File diff suppressed because it is too large Load Diff

27644
LLM_CONTEXTS/koellabs.txt Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

6975
LLM_CONTEXTS/llama.cpp.txt Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

30
LLM_CONTEXTS/role.txt Normal file
View File

@@ -0,0 +1,30 @@
# Role & Expertise
You are an expert AI programming assistant specializing in building ultra-performant, secure, and modern cross-platform desktop & mobile applications using Tauri 2.0, Rust, and React (Vite).
# Technical Stack
- Backend: Rust (Tauri 2.0).
- Frontend: React 18+ with Vite (SPA mode).
- Styling: TailwindCSS 3/4.
- UI Components: ShadCN UI (Radix UI).
- State Management: TanStack Query (React Query) or Zustand.
# Development Principles
- NO NEXT.JS: We use React + Vite in Single Page Application (SPA) mode to ensure perfect compatibility with Tauri's webview.
- Tauri 2.0 Focus: Always use the new Tauri 2.0 syntax (e.g., @tauri-apps/api/core instead of /api/tauri).
- Capabilities: Remember that Tauri 2.0 requires explicit permission definitions in `src-tauri/capabilities/`.
- Type Safety: Full TypeScript for frontend, strict typing in Rust.
# Execution Workflow
1. Requirements Check: Always check the `specs` folder (if it exists) before starting.
2. Step-by-Step Planning: Describe the plan in detailed pseudo-code.
3. User Confirmation: Wait for the user to approve the plan before writing code.
4. Implementation:
- No TODOs or placeholders. Fully functional, production-ready code only.
- Use utility-first styling with TailwindCSS.
- Ensure Rust commands are properly handled with Error/Result types to be caught in TS.
5. Setup: If new files/folders are needed, provide the exact bash script to create the structure.
# Quality Standards
- Readability first, performance second (unless specified).
- Strict security: Use scoped permissions for Tauri plugins (fs, shell, etc.).
- Minimal prose: Be concise and direct.

3941
LLM_CONTEXTS/snc-cards.txt Normal file

File diff suppressed because it is too large Load Diff

3630
LLM_CONTEXTS/snc.txt Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

9163
LLM_CONTEXTS/vite.txt Normal file

File diff suppressed because it is too large Load Diff

107
README.md Normal file
View File

@@ -0,0 +1,107 @@
# Tauri: An Ultimate Project Template
[![NPM Version](https://img.shields.io/npm/v/create-tauri-react)](https://www.npmjs.com/package/create-tauri-react)
[![NPM Downloads](https://img.shields.io/npm/dm/create-tauri-react)](https://www.npmjs.com/package/create-tauri-react)
This template should help get you started developing with [Tauri](https://tauri.app), [React](https://reactjs.org), [Typescript](https://typescriptlang.org) and [Tailwind CSS](https://tailwindcss.com) (w/ [shadcn/ui](https://ui.shadcn.com/)) in [Vite](https://vitejs.dev).
The architecture is based on practices suggested by [@alan2207](https://github.com/alan2207) in his [bulletproof-react](https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md).
In addition, this template configures [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [Husky](https://typicode.github.io/husky/) and [Lint-staged](https://github.com/lint-staged/lint-staged) for pre-commits.
![Demo Screenshot](./assets/demo.png)
## Getting Started
### Basics
Ensure that you have the [Tauri prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites) installed.
#### Create a new project
```bash
npx create-tauri-react@latest
```
## What's included
### Core
A basic Tauri setup with Vite, React, Typescript.
#### Tailwind CSS
A basic Tailwind CSS setup. Includes a `components.json` for Shadcn UI components.
### Dev Tools
#### Eslint 9
A new Eslint 9 setup with flat config. This will help you to keep your code clean and consistent.
#### Prettier
A basic Prettier setup to keep your code formatted.
#### Husky + Lint-staged
Pre-commit hooks to run Eslint and Prettier on staged files.
## How to use?
Once again, the architecture of the template is based on practices proposed by [@alan2207](https://github.com/alan2207) in his [bulletproof-react](https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md).
```
src
|
+-- app # application layer containing:
| | # this folder might differ based on the meta framework used
| +-- routes # application routes / can also be pages
| +-- app.tsx # main application component
| +-- provider.tsx # application provider that wraps the entire application with different global providers - this might also differ based on meta framework used
| +-- router.tsx # application router configuration
+-- assets # assets folder can contain all the static files such as images, fonts, etc.
|
+-- components # shared components used across the entire application
|
+-- config # global configurations, exported env variables etc.
|
+-- features # feature based modules
|
+-- hooks # shared hooks used across the entire application
|
+-- lib # reusable libraries preconfigured for the application
|
+-- stores # global state stores
|
+-- testing # test utilities and mocks
|
+-- types # shared types used across the application
|
+-- utils # shared utility functions
```
```
src/features/awesome-feature
|
+-- api # exported API request declarations and api hooks related to a specific feature
|
+-- assets # assets folder can contain all the static files for a specific feature
|
+-- components # components scoped to a specific feature
|
+-- hooks # hooks scoped to a specific feature
|
+-- stores # state stores for a specific feature
|
+-- types # typescript types used within the feature
|
+-- utils # utility functions for a specific feature
```
So, simply put:
- Define your app's routes in `src/app/router.tsx` and `src/app/routes/*` with minimal business logic.
- The pages from the routes should be using `src/features` to build up functionality on the page.
- The features should be using components from `src/components`, which are pure ui components (like [Shadcn UI](https://ui.shadcn.com/)) or layouts.
- For an extended template, you can look up [`@MrLightful/powersync-tauri`](https://github.com/MrLightful/powersync-tauri), which also defines `src/config` and `src/hooks` examples.

BIN
assets/demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import globals from 'globals'
import pluginJs from '@eslint/js'
import tseslint from 'typescript-eslint'
import pluginReact from 'eslint-plugin-react'
import eslintConfigPrettier from 'eslint-config-prettier'
export default [
{ files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] },
{ ignores: ['**/components/ui', '**/src-tauri'] },
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
settings: {
react: {
version: 'detect'
}
}
},
pluginReact.configs.flat.recommended,
eslintConfigPrettier,
{ rules: { 'react/react-in-jsx-scope': 'off' } }
]

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

64
package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "NordicFlow",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"prepare": "husky",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write ."
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.20",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.3",
"@tauri-apps/plugin-sql": "^2.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.29.2",
"lucide-react": "^0.555.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.68.0",
"react-router": "^7.10.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.1.13"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tauri-apps/cli": "^2.9.5",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.5.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "3.7.3",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6"
}
}

10
prettier.config.js Normal file
View File

@@ -0,0 +1,10 @@
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import('prettier').Config}
*/
export default {
semi: false,
tabWidth: 4,
singleQuote: true,
trailingComma: 'none'
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
src-tauri/.npmignore Normal file
View File

@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

6341
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "NordicFlow"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.0.3", features = [] }
[dependencies]
tauri = { version = "2.7.0", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-shell = "2"
tauri-plugin-process = "2.3.1"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-http = "2"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["stream", "json"] }
futures-util = "0.3"
tauri-plugin-dialog = "2.6.0"
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,21 @@
{
"identifier": "llama-capability",
"description": "Capability to run local AI server",
"local": true,
"windows": [
"main"
],
"permissions": [
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "llama-server",
"cmd": ".*llama-server\\.exe",
"sidecar": false,
"args": true
}
]
}
]
}

View File

@@ -0,0 +1,17 @@
{
"identifier": "migrated",
"description": "permissions that were migrated from v1",
"local": true,
"windows": [
"main"
],
"permissions": [
"core:default",
"process:default",
"shell:default",
"sql:default",
"sql:allow-execute",
"sql:allow-select",
"http:default"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"llama-capability":{"identifier":"llama-capability","description":"Capability to run local AI server","local":true,"windows":["main"],"permissions":[{"identifier":"shell:allow-execute","allow":[{"args":true,"cmd":".*llama-server\\.exe","name":"llama-server","sidecar":false}]}]},"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"permissions":["core:default","process:default","shell:default","sql:default","sql:allow-execute","sql:allow-select","http:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,2 @@
@echo off
echo This is a dummy llama-server for build verification.

637
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,637 @@
use futures_util::StreamExt;
use std::path::PathBuf;
use tauri::{command, AppHandle, Emitter};
use tauri_plugin_shell::process::CommandEvent;
use tauri_plugin_shell::ShellExt;
use tokio::fs; // Added for tokio::fs::File
use tokio::io::AsyncWriteExt; // Added for AsyncWriteExt
const MODEL_FILENAME: &str = "llama-3.1-8b-instruct.gguf"; // Llama 3.1 8B Instruct GGUF
// The user has a specific model in mind: translategemma.
// If we download from HF, we might need to handle huge files.
// For now, I will rename it to 'model.gguf' for simplicity or keep original name.
#[command]
pub async fn check_model_exists(app: AppHandle) -> Result<bool, String> {
let model_path = get_model_path(&app)?;
if !model_path.exists() {
return Ok(false);
}
// Check magic bytes
let mut file = std::fs::File::open(&model_path).map_err(|e| e.to_string())?;
let mut buffer = [0u8; 4];
use std::io::Read;
file.read_exact(&mut buffer).map_err(|e| e.to_string())?;
// GGUF magic is "GGUF" (0x47 0x47 0x55 0x46)
if &buffer == b"GGUF" {
Ok(true)
} else {
// Corrupt file, maybe delete it?
// For now just return false so UI prompts download
let _ = std::fs::remove_file(model_path);
Ok(false)
}
}
#[command]
pub async fn download_model(
app: AppHandle,
window: tauri::Window,
url: String,
token: Option<String>,
) -> Result<String, String> {
let model_path = get_model_path(&app)?;
// Create directory if it doesn't exist
if let Some(parent) = model_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let client = reqwest::Client::new();
let mut request = client
.get(&url)
.header("User-Agent", "NordicFlow/1.0 (Desktop App)");
if let Some(t) = token {
if !t.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", t));
}
}
let response = request.send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("Download failed: Status {}", response.status()));
}
let total_size = response.content_length().unwrap_or(0);
// Use tokio::fs::File
let mut file = fs::File::create(&model_path)
.await
.map_err(|e| e.to_string())?;
let mut stream = response.bytes_stream();
let mut downloaded: u64 = 0;
while let Some(item) = stream.next().await {
let chunk = item.map_err(|e| e.to_string())?;
file.write_all(&chunk).await.map_err(|e| e.to_string())?; // Async write
downloaded += chunk.len() as u64;
let _ = window.emit("download-progress", (downloaded, total_size));
}
Ok("Download complete".to_string())
}
fn kill_llama_server() {
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("taskkill")
.args(&["/IM", "llama-server.exe", "/F"])
.output();
}
#[command]
pub async fn start_chat(app: AppHandle) -> Result<String, String> {
kill_llama_server();
let model_path = get_model_path(&app)?;
if !model_path.exists() {
return Err("Model not found. Please download it first.".to_string());
}
let llama_dir = get_llama_dir(&app)?;
// Assuming llama-server.exe is in the same directory
let server_path = llama_dir.join("llama-server.exe");
// Convert paths to string safe for Command
let server_path_str = server_path.to_string_lossy().to_string();
let model_path_str = model_path.to_string_lossy().to_string();
// Use tauri-plugin-shell to spawn
// accurate command: llama-server.exe -m model.gguf -c 2048 --port 8080
// We need to use "sidecar" or "Command::new"
// command needs to be absolute
let cmd = app
.shell()
.command(server_path_str)
.args(&[
"-m",
&model_path_str,
"-c",
"2048",
"--port",
"8080",
"--n-gpu-layers",
"99", // Try to use GPU if possible
])
.current_dir(llama_dir); // Important for DLLs
let (mut rx, _child) = cmd.spawn().map_err(|e| e.to_string())?;
// We can listen to stdout/stderr in background
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => println!("LLAMA: {:?}", String::from_utf8(line)),
CommandEvent::Stderr(line) => eprintln!("LLAMA ERR: {:?}", String::from_utf8(line)),
_ => {}
}
}
});
Ok("Server started".to_string())
}
fn get_llama_dir(_app: &AppHandle) -> Result<PathBuf, String> {
// In dev, we can resolve relative to the current working directory of the process
// which is src-tauri.
let mut path = std::env::current_dir().map_err(|e| e.to_string())?;
// if we are in src-tauri root
path.push("llama");
Ok(path)
}
fn get_model_path(app: &AppHandle) -> Result<PathBuf, String> {
// Store models in AppData/NordicFlow/models to avoid triggering dev watcher
use tauri::Manager;
let app_dir = app.path().app_local_data_dir().map_err(|e| e.to_string())?;
let model_dir = app_dir.join("models");
Ok(model_dir.join(MODEL_FILENAME))
}
const VISION_MODEL_FILENAME: &str = "minicpm-v-2.6.gguf";
const VISION_PROJ_FILENAME: &str = "minicpm-v-2.6-mmproj.gguf";
// ... existing helper ...
fn get_vision_model_path(app: &AppHandle) -> Result<PathBuf, String> {
use tauri::Manager;
let app_dir = app.path().app_local_data_dir().map_err(|e| e.to_string())?;
let model_dir = app_dir.join("models");
Ok(model_dir.join(VISION_MODEL_FILENAME))
}
fn get_vision_proj_path(app: &AppHandle) -> Result<PathBuf, String> {
use tauri::Manager;
let app_dir = app.path().app_local_data_dir().map_err(|e| e.to_string())?;
let model_dir = app_dir.join("models");
Ok(model_dir.join(VISION_PROJ_FILENAME))
}
fn validate_gguf_file(path: &PathBuf) -> bool {
if !path.exists() {
return false;
}
match std::fs::File::open(path) {
Ok(mut file) => {
let mut buffer = [0u8; 4];
use std::io::Read;
if let Ok(_) = file.read_exact(&mut buffer) {
if &buffer == b"GGUF" {
return true;
}
}
}
Err(_) => {}
}
// If we're here, it's invalid. Remove it.
let _ = std::fs::remove_file(path);
false
}
#[command]
pub async fn check_vision_model_exists(app: AppHandle) -> Result<bool, String> {
let model_path = get_vision_model_path(&app)?;
let proj_path = get_vision_proj_path(&app)?;
let model_valid = validate_gguf_file(&model_path);
let proj_valid = validate_gguf_file(&proj_path);
Ok(model_valid && proj_valid)
}
#[command]
pub async fn download_vision_model(
app: AppHandle,
window: tauri::Window,
url: String,
proj_url: Option<String>,
token: Option<String>,
) -> Result<String, String> {
let model_path = get_vision_model_path(&app)?;
let proj_path = get_vision_proj_path(&app)?;
if let Some(parent) = model_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let client = reqwest::Client::new();
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"User-Agent",
"NordicFlow/1.0 (Desktop App)".parse().unwrap(),
);
if let Some(ref t) = token {
if !t.trim().is_empty() {
headers.insert("Authorization", format!("Bearer {}", t).parse().unwrap());
}
}
// Helper to download a single file
async fn download_file(
client: &reqwest::Client,
url: &str,
headers: reqwest::header::HeaderMap,
dest_path: &PathBuf,
window: &tauri::Window,
event_name: &str,
start_offset: u64,
total_combined_size: u64,
) -> Result<u64, String> {
let response = client
.get(url)
.headers(headers)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!(
"Download failed for {}: Status {}",
url,
response.status()
));
}
let content_len = response.content_length().unwrap_or(0);
let part_path = dest_path.with_extension("gguf.part");
let mut file = fs::File::create(&part_path)
.await
.map_err(|e| e.to_string())?;
let mut stream = response.bytes_stream();
let mut downloaded_bytes: u64 = 0;
while let Some(item) = stream.next().await {
let chunk = item.map_err(|e| e.to_string())?;
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
downloaded_bytes += chunk.len() as u64;
// Emit progress relative to the specific file or total?
// Let's emit simple (current, total) for the specific component OR try to combine.
// For simplicity, let's just emit raw bytes downloaded for THIS file, frontend handles reset.
// OR we can pass a 'base' offset if we want combined bar.
let _ = window.emit(
event_name,
(start_offset + downloaded_bytes, total_combined_size),
);
}
file.flush().await.map_err(|e| e.to_string())?;
fs::rename(&part_path, dest_path)
.await
.map_err(|e| e.to_string())?;
Ok(content_len)
}
// 1. Download Model
// We don't know combined size upfront easily without HEAD requests.
// Let's just do two separate download bars or sequential events.
// Ideally we assume Model is 5GB. Proj is 0.2GB.
// Let's just run them and emit progress. The frontend can handle it resetting or we just sum it up.
// We'll perform HEAD requests first to get sizes?
// HEAD request for Model
let resp_model = client
.head(&url)
.headers(headers.clone())
.send()
.await
.map_err(|e| e.to_string())?;
let mut size_model = resp_model.content_length().unwrap_or(0);
if size_model == 0 {
size_model = 4670 * 1024 * 1024; // ~4.6 GB fallback
}
let mut size_proj = 0;
if let Some(ref p_url) = proj_url {
let resp_proj = client
.head(p_url)
.headers(headers.clone())
.send()
.await
.map_err(|e| e.to_string())?;
let mut s = resp_proj.content_length().unwrap_or(0);
if s == 0 {
s = 200 * 1024 * 1024; // ~200 MB fallback
}
size_proj = s;
}
let total_size = size_model + size_proj;
// Download Main Model
download_file(
&client,
&url,
headers.clone(),
&model_path,
&window,
"download-vision-progress",
0,
total_size,
)
.await?;
// Download Proj
if let Some(ref p_url) = proj_url {
download_file(
&client,
p_url,
headers.clone(),
&proj_path,
&window,
"download-vision-progress",
size_model,
total_size,
)
.await?;
}
Ok("Download complete".to_string())
}
#[command]
pub async fn analyze_image(app: AppHandle, image_path: String) -> Result<String, String> {
// Free up memory by killing potential chat server
kill_llama_server();
let model_path = get_vision_model_path(&app)?;
let proj_path = get_vision_proj_path(&app)?;
if !model_path.exists() {
return Err("Vision model not found".to_string());
}
if !proj_path.exists() {
return Err("Vision projector not found".to_string());
}
let llama_dir = get_llama_dir(&app)?;
let cli_path = llama_dir.join("llama-mtmd-cli.exe");
let mut actual_image_path = PathBuf::from(&image_path);
let mut is_temp = false;
// ... (URL handling code same as before) ...
if image_path.starts_with("http") {
let client = reqwest::Client::new();
let response = client
.get(&image_path)
.header("User-Agent", "NordicFlow/1.0 (Desktop App)")
.send()
.await
.map_err(|e| format!("Failed to fetch image: {}", e))?;
if !response.status().is_success() {
return Err(format!("Image download failed: {}", response.status()));
}
use tauri::Manager;
let temp_dir = app
.path()
.app_cache_dir()
.map_err(|e| e.to_string())?
.join("temp_images");
fs::create_dir_all(&temp_dir)
.await
.map_err(|e| e.to_string())?;
let filename = response
.url()
.path_segments()
.and_then(|segments| segments.last())
.and_then(|name| if name.is_empty() { None } else { Some(name) })
.unwrap_or("temp_image.jpg");
let temp_path = temp_dir.join(filename);
let content = response.bytes().await.map_err(|e| e.to_string())?;
let mut file = fs::File::create(&temp_path)
.await
.map_err(|e| e.to_string())?;
file.write_all(&content).await.map_err(|e| e.to_string())?;
actual_image_path = temp_path;
is_temp = true;
}
// Command: llama-mtmd-cli -m model.gguf --mmproj proj.gguf --image image.jpg -p "Describe this image"
let output = app
.shell()
.command(cli_path.to_string_lossy().to_string())
.args(&[
"-m",
&model_path.to_string_lossy().to_string(),
"--mmproj",
&proj_path.to_string_lossy().to_string(),
"--image",
&actual_image_path.to_string_lossy().to_string(),
"-p",
"Describe this image in detail.",
"--temp",
"0.1",
])
.current_dir(&llama_dir)
.output()
.await
.map_err(|e| e.to_string())?;
// Cleanup temp
if is_temp {
let _ = fs::remove_file(&actual_image_path).await;
}
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
Ok(stdout)
} else {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(format!(
"Analysis failed. Stdout: {}. Stderr: {}",
stdout, stderr
))
}
}
const WHISPER_MODEL_FILENAME: &str = "nb-whisper-medium-q5_0.gguf";
fn get_whisper_model_path(app: &AppHandle) -> Result<PathBuf, String> {
use tauri::Manager;
let app_dir = app.path().app_local_data_dir().map_err(|e| e.to_string())?;
let model_dir = app_dir.join("models");
Ok(model_dir.join(WHISPER_MODEL_FILENAME))
}
#[command]
pub async fn check_whisper_dependencies(app: AppHandle) -> Result<bool, String> {
let llama_dir = get_llama_dir(&app)?;
let whisper_cli = llama_dir.join("whisper-cli.exe");
let ffmpeg = llama_dir.join("ffmpeg.exe");
Ok(whisper_cli.exists() && ffmpeg.exists())
}
#[command]
pub async fn check_whisper_model_exists(app: AppHandle) -> Result<bool, String> {
let model_path = get_whisper_model_path(&app)?;
Ok(validate_gguf_file(&model_path))
}
#[command]
pub async fn download_whisper_model(
app: AppHandle,
window: tauri::Window,
url: String, // Allow overriding URL, but default in frontend
token: Option<String>,
) -> Result<String, String> {
let model_path = get_whisper_model_path(&app)?;
// Check if dir exists
if let Some(parent) = model_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let client = reqwest::Client::new();
let mut request = client.get(&url).header("User-Agent", "NordicFlow/1.0");
if let Some(t) = token {
if !t.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", t));
}
}
let response = request.send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("Download failed: {}", response.status()));
}
let total_size = response.content_length().unwrap_or(0);
let mut file = fs::File::create(&model_path)
.await
.map_err(|e| e.to_string())?;
let mut stream = response.bytes_stream();
let mut downloaded: u64 = 0;
while let Some(item) = stream.next().await {
let chunk = item.map_err(|e| e.to_string())?;
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
downloaded += chunk.len() as u64;
let _ = window.emit("download-whisper-progress", (downloaded, total_size));
}
Ok("Download complete".to_string())
}
#[command]
pub async fn transcribe_media(app: AppHandle, video_path: String) -> Result<String, String> {
let llama_dir = get_llama_dir(&app)?;
let ffmpeg_path = llama_dir.join("ffmpeg.exe");
let whisper_cli_path = llama_dir.join("whisper-cli.exe");
let model_path = get_whisper_model_path(&app)?;
if !ffmpeg_path.exists() || !whisper_cli_path.exists() {
return Err("Missing ffmpeg or whisper-cli".to_string());
}
if !model_path.exists() {
return Err("Whisper model not found".to_string());
}
// 1. Convert to WAV (16kHz, mono)
use tauri::Manager;
let temp_dir = app
.path()
.app_cache_dir()
.map_err(|e| e.to_string())?
.join("temp_audio");
fs::create_dir_all(&temp_dir)
.await
.map_err(|e| e.to_string())?;
let output_wav = temp_dir.join("output.wav");
// ffmpeg -i input.mp4 -ar 16000 -ac 1 -c:a pcm_s16le output.wav -y
let ffmpeg_out = app
.shell()
.command(ffmpeg_path.to_string_lossy().to_string())
.args(&[
"-i",
&video_path,
"-ar",
"16000",
"-ac",
"1",
"-c:a",
"pcm_s16le",
output_wav.to_string_lossy().to_string().as_str(),
"-y",
])
.output()
.await
.map_err(|e| e.to_string())?;
if !ffmpeg_out.status.success() {
let stderr = String::from_utf8_lossy(&ffmpeg_out.stderr).to_string();
return Err(format!("FFmpeg failed: {}", stderr));
}
// 2. Transcribe
// whisper-cli -m model.gguf -f output.wav -l no --output-json
// We want plain text or json? JSON gives timestamps. Let's get JSON.
// The CLI usually writes to a file. We can specify output file base properly?
// command: ./main -m ... -f ... -oj
// It will produce output.wav.json
let whisper_out = app
.shell()
.command(whisper_cli_path.to_string_lossy().to_string())
.args(&[
"-m",
&model_path.to_string_lossy().to_string(),
"-f",
&output_wav.to_string_lossy().to_string(),
"-l",
"no", // Norwegian
"-oj", // Output JSON
"-nt", // No Text output to stdout (cleaner logs)
])
.current_dir(&llama_dir) // Important?
.output()
.await
.map_err(|e| e.to_string())?;
if !whisper_out.status.success() {
let stderr = String::from_utf8_lossy(&whisper_out.stderr).to_string();
return Err(format!("Whisper failed: {}", stderr));
}
// 3. Read the JSON output
// The tool creates [filename].json
let json_path = output_wav.with_extension("wav.json"); // Whisper.cpp appends .json to input filename usually? Or replace?
// Usually it appends. Let's check typical behavior. "output.wav" -> "output.wav.json".
if !json_path.exists() {
return Err(format!("JSON output not found at {:?}", json_path));
}
let json_content = fs::read_to_string(&json_path)
.await
.map_err(|e| e.to_string())?;
// Cleanup
let _ = fs::remove_file(&output_wav).await;
let _ = fs::remove_file(&json_path).await;
Ok(json_content)
}

47
src-tauri/src/db.rs Normal file
View File

@@ -0,0 +1,47 @@
use tauri_plugin_sql::{Migration, MigrationKind};
pub fn get_migrations() -> Vec<Migration> {
let migrations = vec![
Migration {
version: 1,
description: "create_flashcards_tables",
sql: "
CREATE TABLE IF NOT EXISTS flashcard_sets (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
topic TEXT NOT NULL,
color TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS flashcards (
id TEXT PRIMARY KEY,
set_id TEXT NOT NULL,
front TEXT NOT NULL,
back TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'new',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (set_id) REFERENCES flashcard_sets(id) ON DELETE CASCADE
);
",
kind: MigrationKind::Up,
},
Migration {
version: 2,
description: "create_smart_stories_table",
sql: "
CREATE TABLE IF NOT EXISTS smart_stories (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
difficulty TEXT,
topic TEXT,
vocab_json TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
",
kind: MigrationKind::Up,
},
];
migrations
}

39
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,39 @@
mod commands;
mod db;
mod models;
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
let migrations = db::get_migrations();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_dialog::init())
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:flashcards.db", migrations)
.build(),
)
.invoke_handler(tauri::generate_handler![
greet,
commands::start_chat,
commands::check_model_exists,
commands::download_model,
commands::check_vision_model_exists,
commands::download_vision_model,
commands::analyze_image,
commands::check_whisper_dependencies,
commands::check_whisper_model_exists,
commands::download_whisper_model,
commands::transcribe_media
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

32
src-tauri/src/models.rs Normal file
View File

@@ -0,0 +1,32 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct FlashcardSet {
pub id: String,
pub title: String,
pub topic: String,
pub color: String,
pub created_at: Option<String>,
// In Rust, we might not include the cards directly in the set struct when fetching lists,
// but for compatibility with the frontend mock, we can include them or fetch them separately.
// For now, let's keep it simple and fetch cards separately usually, but here is a struct that mirrors the frontend.
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flashcard {
pub id: String,
pub set_id: String,
pub front: String,
pub back: String,
pub status: String,
pub created_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FlashcardSetWithCards {
pub id: String,
pub title: String,
pub topic: String,
pub color: String,
pub cards: Vec<Flashcard>,
}

40
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,40 @@
{
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist",
"devUrl": "http://localhost:1420"
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"productName": "NordicFlow",
"version": "0.1.0",
"identifier": "pl.kamici.nordicflow",
"plugins": {
"process": {
"active": true
}
},
"app": {
"security": {
"csp": null
},
"windows": [
{
"title": "NordicFlow",
"width": 1000,
"height": 600,
"dragDropEnabled": false
}
]
}
}

126
src/app/global.css Normal file
View File

@@ -0,0 +1,126 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
/* For native desktop app feeling disallow text selection. */
@apply select-none;
/* Disable overscroll/bouncing */
@apply h-full overscroll-none;
}
body {
@apply bg-background text-foreground;
}
}

12
src/app/index.tsx Normal file
View File

@@ -0,0 +1,12 @@
import './global.css'
import AppRouter from '@/app/router'
import AppProvider from '@/app/provider'
export default function App() {
return (
<AppProvider>
<AppRouter />
</AppProvider>
)
}

14
src/app/provider.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { ReactNode, Suspense } from 'react'
import AppErrorPage from '@/features/errors/app-error'
import { ErrorBoundary } from 'react-error-boundary'
import { TooltipProvider } from '@/components/ui/tooltip'
export default function AppProvider({ children }: { children: ReactNode }) {
return (
<Suspense fallback={<>Loading...</>}>
<ErrorBoundary FallbackComponent={AppErrorPage}>
<TooltipProvider>{children}</TooltipProvider>
</ErrorBoundary>
</Suspense>
)
}

130
src/app/router.tsx Normal file
View File

@@ -0,0 +1,130 @@
import { createBrowserRouter, RouterProvider } from 'react-router'
import { AppLayout } from '@/components/layout/AppLayout'
const createAppRouter = () =>
createBrowserRouter([
{
element: <AppLayout />,
children: [
{
path: '/',
lazy: () => import('@/app/routes/home')
},
{
path: '/visual-recognition',
lazy: () =>
import(
'@/features/visual-recognition/routes/VisualRecognitionPage'
).then((m) => ({ Component: m.VisualRecognitionPage }))
},
{
path: '/chat',
lazy: () =>
import('@/features/ai-chat/routes/ChatPage').then(
(m) => ({ Component: m.ChatPage })
)
},
{
path: '/chat/sandbox/:missionId',
lazy: () =>
import('@/features/ai-chat/routes/SandboxPage').then(
(m) => ({ Component: m.SandboxPage })
)
},
{
path: '/flashcards',
lazy: () =>
import(
'@/features/flashcards/routes/FlashcardsLayout'
).then((m) => ({ Component: m.FlashcardsLayout })),
children: [
{
index: true,
lazy: () =>
import(
'@/features/flashcards/routes/SetsPage'
).then((m) => ({ Component: m.SetsPage }))
},
{
path: ':setId',
lazy: () =>
import(
'@/features/flashcards/routes/SetOverviewPage'
).then((m) => ({ Component: m.SetOverviewPage }))
},
{
path: ':setId/study',
lazy: () =>
import(
'@/features/flashcards/routes/StudyModePage'
).then((m) => ({ Component: m.StudyModePage }))
},
{
path: ':setId/spelling',
lazy: () =>
import(
'@/features/flashcards/routes/SpellingModePage'
).then((m) => ({ Component: m.SpellingModePage }))
},
{
path: ':setId/transcription',
lazy: () =>
import(
'@/features/flashcards/routes/TranscriptionModePage'
).then((m) => ({ Component: m.TranscriptionModePage }))
},
{
path: ':setId/list',
lazy: () =>
import(
'@/features/flashcards/routes/SetListPage'
).then((m) => ({ Component: m.SetListPage }))
}
]
},
{
path: '/smart-reading',
lazy: () =>
import(
'@/features/smart-reading/routes/SmartReadingDashboard'
).then((m) => ({ Component: m.SmartReadingDashboard }))
},
{
path: '/smart-reading/:storyId',
lazy: () =>
import(
'@/features/smart-reading/routes/StoryReaderPage'
).then((m) => ({ Component: m.StoryReaderPage }))
},
{
path: '/video-learning',
lazy: () => import('@/features/video-learning/routes/VideoLearningPage').then(m => ({ Component: m.VideoLearningPage }))
},
{
path: '/mini-games',
lazy: () => import('@/features/mini-games/routes/MiniGamesDashboard').then(m => ({ Component: m.MiniGamesDashboard }))
},
{
path: '/mini-games/hangman',
lazy: () => import('@/features/mini-games/routes/HangmanPage').then(m => ({ Component: m.HangmanPage }))
},
{
path: '/mini-games/speed',
lazy: () => import('@/features/mini-games/routes/SpeedChallengePage').then(m => ({ Component: m.SpeedChallengePage }))
},
{
path: '/mini-games/stop-the-bus',
lazy: () => import('@/features/mini-games/routes/StopTheBusPage').then(m => ({ Component: m.StopTheBusPage }))
},
{
path: '*',
lazy: () => import('@/app/routes/not-found')
}
]
}
])
export default function AppRouter() {
return <RouterProvider router={createAppRouter()} />
}

13
src/app/routes/home.tsx Normal file
View File

@@ -0,0 +1,13 @@
export function HomePage() {
return (
<div className="flex flex-col gap-4 p-8">
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome to NordicFlow. Select a learning mode to get started.
</p>
</div>
)
}
// Necessary for react router to lazy load.
export const Component = HomePage

View File

@@ -0,0 +1,34 @@
import { Button } from '@/components/ui/button'
import {
ErrorView,
ErrorHeader,
ErrorDescription,
ErrorActions
} from '@/features/errors/error-base'
import { useNavigate } from 'react-router'
export default function NotFoundErrorPage() {
const navigate = useNavigate()
return (
<ErrorView>
<ErrorHeader>Page not found</ErrorHeader>
<ErrorDescription>
Sorry, we couldnt find the page youre looking for.
</ErrorDescription>
<ErrorActions>
<Button size="lg" onClick={() => navigate(-1)}>
Go back
</Button>
<Button size="lg" variant="ghost">
Contact support{' '}
<span aria-hidden="true" className="ml-1">
&rarr;
</span>
</Button>
</ErrorActions>
</ErrorView>
)
}
// Necessary for react router to lazy load.
export const Component = NotFoundErrorPage

View File

@@ -0,0 +1,13 @@
import { Outlet } from 'react-router'
import { Sidebar } from './Sidebar'
export const AppLayout = () => {
return (
<div className="min-h-screen bg-background text-foreground flex">
<Sidebar />
<main className="flex-1 overflow-x-hidden">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { NavLink } from 'react-router'
import { NAVIGATION_ITEMS } from '@/config/navigation'
import { cn } from '@/lib/utils'
import { useTheme } from '@/components/theme-provider'
import { Sun, Moon, Laptop } from 'lucide-react'
import { Button } from '@/components/ui/button'
export const Sidebar = () => {
const { theme, setTheme } = useTheme()
return (
<aside className="border-r bg-card w-64 flex-col hidden md:flex h-screen sticky top-0">
<div className="p-6">
<h1 className="text-2xl font-bold tracking-tight text-primary">
NordicFlow
</h1>
</div>
<nav className="flex-1 px-4 space-y-2">
{NAVIGATION_ITEMS.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)
}
>
<item.icon className="w-5 h-5" />
{item.label}
</NavLink>
))}
</nav>
<div className="p-4 border-t">
<div className="flex items-center justify-between p-2 rounded-lg bg-muted/50 border">
<Button
variant="ghost"
size="icon"
className={cn("w-8 h-8 rounded-md", theme === 'light' && "bg-background shadow-sm")}
onClick={() => setTheme('light')}
>
<Sun className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn("w-8 h-8 rounded-md", theme === 'system' && "bg-background shadow-sm")}
onClick={() => setTheme('system')}
>
<Laptop className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn("w-8 h-8 rounded-md", theme === 'dark' && "bg-background shadow-sm")}
onClick={() => setTheme('dark')}
>
<Moon className="w-4 h-4" />
</Button>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,72 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> { }
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
onValueChange?: (value: string) => void
}
const Select = ({ children, onValueChange, ...props }: any) => {
return <div className="relative">{children}</div>
}
const SelectTrigger = ({ className, children, ...props }: any) => (
<div className={cn("flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
{children}
</div>
)
const SelectValue = ({ placeholder, ...props }: any) => (
<span className="block truncate">{placeholder}</span>
)
const SelectContent = ({ className, children, ...props }: any) => (
<div className={cn("relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80", className)} {...props}>
<div className="p-1">{children}</div>
</div>
)
const SelectItem = ({ className, children, ...props }: any) => (
<div className={cn("relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} {...props}>
{children}
</div>
)
// Simplified Mock Implementation for now since we can't install radix primitives
// Replacing the complex composition with a native select for robustness in this restricted environment
export const SimpleSelect = ({ value, onValueChange, children }: { value: string, onValueChange: (v: string) => void, children: React.ReactNode }) => {
return (
<select
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 appearance-none"
>
{children}
</select>
)
}
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface SliderProps {
value: number[]
max: number
step: number
onValueChange: (val: number[]) => void
className?: string
}
export const Slider = ({ value, max, step, onValueChange, className }: SliderProps) => {
return (
<input
type="range"
min={0}
max={max}
step={step}
value={value[0]}
onChange={(e) => onValueChange([parseFloat(e.target.value)])}
className={cn("w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary", className)}
/>
)
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,59 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

10
src/config/env.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createEnv } from '@/lib/create-env'
import { z } from 'zod'
const EnvSchema = z.object({
// Note: the key in .env file should be prefixed with VITE_.
API_URL: z.string().default('http://localhost:3000')
})
const env = createEnv(EnvSchema) as z.TypeOf<typeof EnvSchema>
export default env

49
src/config/navigation.ts Normal file
View File

@@ -0,0 +1,49 @@
import {
Image as ImageIcon,
MessageSquare,
CreditCard,
LayoutDashboard,
BookOpen,
Youtube,
Gamepad2,
} from 'lucide-react'
export const NAVIGATION_ITEMS = [
{
label: 'Dashboard',
path: '/',
icon: LayoutDashboard,
},
{
label: 'Flashcards',
path: '/flashcards',
icon: CreditCard,
},
{
label: 'Visual Recognition',
path: '/visual-recognition',
icon: ImageIcon,
},
{
label: 'AI Chat',
path: '/chat',
icon: MessageSquare,
},
{
label: 'Smart Reading',
path: '/smart-reading',
icon: BookOpen,
},
{
label: 'Video Learning',
path: '/video-learning',
icon: Youtube,
},
{
label: 'Mini Games',
path: '/mini-games',
icon: Gamepad2,
},
]

View File

@@ -0,0 +1,65 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle2, Circle, Target, Flag } from 'lucide-react'
import type { Mission } from '@/lib/mockData'
interface MissionControlProps {
mission: Mission
completedKeywords: string[]
}
export const MissionControl = ({ mission, completedKeywords }: MissionControlProps) => {
return (
<div className="h-full flex flex-col gap-4 p-4 bg-muted/20 border-r overflow-y-auto">
<div className="flex items-center gap-2 mb-4">
<Target className="w-6 h-6 text-primary" />
<h2 className="text-xl font-bold tracking-tight">Mission Control</h2>
</div>
<Card className="border-0 shadow-none bg-transparent">
<CardHeader className="pb-2 px-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
<Flag className="w-3 h-3" /> Objective
</CardTitle>
</CardHeader>
<CardContent className="px-0">
<p className="text-lg font-medium leading-relaxed font-serif">
{mission.objective}
</p>
</CardContent>
</Card>
<div className="space-y-3">
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">Required Vocabulary</h3>
<div className="grid gap-2">
{mission.keywords.map((keyword, idx) => {
const isCompleted = completedKeywords.includes(keyword.toLowerCase())
return (
<div
key={idx}
className={`flex items-center justify-between p-3 rounded-lg border transition-all duration-300 ${isCompleted
? 'bg-green-500/10 border-green-500/20 shadow-sm'
: 'bg-card border-border/50 opacity-80'
}`}
>
<span className={`font-medium transition-colors ${isCompleted ? 'text-green-700 dark:text-green-400' : 'text-foreground'}`}>
{keyword}
</span>
{isCompleted ? (
<CheckCircle2 className="w-5 h-5 text-green-600 animate-in zoom-in" />
) : (
<Circle className="w-4 h-4 text-muted-foreground/30" />
)}
</div>
)
})}
</div>
</div>
<div className="mt-auto p-4 bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-900/50 rounded-lg text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-bold mb-1">💡 Instructor Tip</p>
<p>Try being polite! Norwegians appreciate "Vær så snill" (Please).</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { Button } from "@/components/ui/button"
import { Trophy, Home, RotateCw } from "lucide-react"
interface MissionReportModalProps {
open: boolean
onOpenChange: (open: boolean) => void
score: number
feedback: string
onHome: () => void
onRestart: () => void
}
export function MissionReportModal({ open, score, feedback, onHome, onRestart }: MissionReportModalProps) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-card w-full max-w-md border rounded-xl shadow-lg p-6 animate-in zoom-in-95 duration-200">
<div className="flex flex-col items-center gap-4 text-center">
<div className="w-20 h-20 bg-yellow-100 rounded-full flex items-center justify-center text-yellow-600 mb-2">
<Trophy className="w-10 h-10" />
</div>
<h2 className="text-2xl font-bold">Mission Complete!</h2>
<p className="text-muted-foreground">
You scored <span className="font-bold text-primary">{score}%</span> on this mission.
</p>
</div>
<div className="bg-muted/50 p-4 rounded-lg text-left text-sm my-6">
<p className="font-semibold mb-2">Feedback Report:</p>
<p className="text-muted-foreground leading-relaxed">{feedback}</p>
</div>
<div className="flex gap-4 justify-center">
<Button variant="outline" onClick={onHome} className="gap-2">
<Home className="w-4 h-4" /> Dashboard
</Button>
<Button onClick={onRestart} className="gap-2">
<RotateCw className="w-4 h-4" /> Try Again
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,310 @@
import { useState, useRef, useEffect } from 'react'
import { ChatMessage } from '@/lib/mockData'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Send, Bot, User, RefreshCw, Download, Loader2, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { LocalAIService } from '@/services/localAi'
const DEFAULT_MODEL_URL = "https://huggingface.co/bartowski/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf";
export const ChatPage = () => {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isTyping, setIsTyping] = useState(false)
const [modelStatus, setModelStatus] = useState<'checking' | 'missing' | 'downloading' | 'ready' | 'error'>('checking')
const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const [isServerReady, setIsServerReady] = useState(false);
// Download settings
const [modelUrl, setModelUrl] = useState(DEFAULT_MODEL_URL);
const [authToken, setAuthToken] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
useEffect(() => {
checkModel();
}, [])
const checkModel = async () => {
try {
const exists = await LocalAIService.checkModelExists();
if (exists) {
setModelStatus('ready');
initServer();
} else {
setModelStatus('missing');
}
} catch (e) {
console.error(e);
setErrorMsg("Failed to check model status.");
setModelStatus('error');
}
}
const initServer = async () => {
try {
await LocalAIService.startServer();
setIsServerReady(true);
} catch (e) {
// It might already be running, or failed.
// We'll assume if it fails it might be because it's already on.
// A robust health check endpoint would be better.
console.warn("Server start response:", e);
setIsServerReady(true); // optimistically assume ready or already running
}
}
const handleDownload = async () => {
setModelStatus('downloading');
setErrorMsg(null);
try {
await LocalAIService.downloadModel(modelUrl, authToken || undefined, (current, total) => {
setDownloadProgress({ current, total });
});
setModelStatus('ready');
initServer();
} catch (e) {
console.error(e);
setErrorMsg(`Failed to download model. ${e}`);
setModelStatus('missing'); // Go back to missing so they can try again
setDownloadProgress(null);
}
}
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, isTyping])
const handleSend = async () => {
if (!input.trim() || !isServerReady) return
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: new Date()
}
setMessages(prev => [...prev, userMsg])
setInput('')
setIsTyping(true)
try {
// Convert history to format expected by AI
// We might want to keep a 'system' prompt first
const apiMessages = [
{ role: "system", content: "You are a helpful Norwegian language tutor. We are having a casual conversation in Norwegian. Please reply in Norwegian." },
...messages.map(m => ({ role: m.role, content: m.content })),
{ role: "user", content: userMsg.content }
];
const response = await LocalAIService.chatCompletion(apiMessages);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: response,
timestamp: new Date()
}
setMessages(prev => [...prev, aiMsg])
} catch (e) {
console.error(e);
const errorMsg: ChatMessage = {
id: Date.now().toString(),
role: 'assistant',
content: "Beklager, jeg fikk problemer med å koble til AI-tjenesten.",
timestamp: new Date()
}
setMessages(prev => [...prev, errorMsg])
} finally {
setIsTyping(false)
}
}
if (modelStatus === 'checking') {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
)
}
if (modelStatus === 'missing' || modelStatus === 'downloading') {
return (
<div className="flex flex-col h-full items-center justify-center p-8 text-center max-w-lg mx-auto space-y-6">
<div className="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-4">
<Download className="w-10 h-10" />
</div>
<h2 className="text-2xl font-bold">AI Model Missing</h2>
<p className="text-muted-foreground">
To use the AI tutor details, we need to download a lightweight language model (approx 2.5GB).
This is a one-time setup.
</p>
{errorMsg && (
<div className="text-destructive text-sm bg-destructive/10 p-2 rounded">
{errorMsg}
</div>
)}
{modelStatus === 'downloading' && downloadProgress ? (
<div className="w-full space-y-2">
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${(downloadProgress.current / downloadProgress.total) * 100}%` }}
/>
</div>
<p className="text-sm text-muted-foreground">
{(downloadProgress.current / 1024 / 1024).toFixed(1)} MB / {(downloadProgress.total / 1024 / 1024).toFixed(1)} MB
</p>
</div>
) : (
<div className="w-full space-y-4">
<Button onClick={handleDownload} size="lg" className="w-full sm:w-auto">
Download Model
</Button>
<div className="pt-4 border-t w-full">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs text-muted-foreground hover:underline"
>
{showAdvanced ? "Hide Advanced Options" : "Advanced Options (Custom URL / Token)"}
</button>
{showAdvanced && (
<div className="space-y-3 mt-3 text-left">
<div className="space-y-1">
<label className="text-xs font-medium">Model URL (GGUF)</label>
<Input
value={modelUrl}
onChange={(e) => setModelUrl(e.target.value)}
className="text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">HuggingFace Token (Optional)</label>
<Input
type="password"
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
placeholder="hf_..."
className="text-xs"
/>
<p className="text-[10px] text-muted-foreground">Required only for gated models.</p>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
if (modelStatus === 'error') {
return (
<div className="flex flex-col h-full items-center justify-center p-8 text-center">
<AlertCircle className="w-16 h-16 text-destructive mb-4" />
<h2 className="text-xl font-bold text-destructive">Something went wrong</h2>
<p className="text-muted-foreground mt-2">{errorMsg}</p>
<Button onClick={checkModel} variant="outline" className="mt-6">Retry</Button>
</div>
)
}
return (
<div className="flex flex-col h-[calc(100vh-2rem)] max-w-4xl mx-auto p-4 md:p-6">
<header className="flex items-center justify-between mb-6 pb-4 border-b">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Bot className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold">Lærer AI</h1>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<span className={cn("w-2 h-2 rounded-full", isServerReady ? "bg-green-500 animate-pulse" : "bg-yellow-500")} />
{isServerReady ? "Online" : "Initializing..."}
</p>
</div>
</div>
<Button variant="ghost" size="icon" onClick={() => setMessages([])}>
<RefreshCw className="w-5 h-5 text-muted-foreground" />
</Button>
</header>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto space-y-6 mb-6 px-2 scroll-smooth"
>
{messages.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-50">
<Bot className="w-16 h-16 mb-4" />
<p>Start a conversation to practice your Norwegian!</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={cn(
"flex gap-4 max-w-[80%]",
msg.role === 'user' ? "ml-auto flex-row-reverse" : ""
)}
>
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-1",
msg.role === 'user' ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}>
{msg.role === 'user' ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
</div>
<div className={cn(
"p-4 rounded-2xl text-sm leading-relaxed shadow-sm",
msg.role === 'user'
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-card border rounded-tl-sm"
)}>
{msg.content}
<span className="block text-[10px] opacity-50 mt-2">
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
))}
{isTyping && (
<div className="flex gap-4 max-w-[80%]">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center shrink-0 mt-1">
<Bot className="w-5 h-5 text-muted-foreground" />
</div>
<div className="bg-card border p-4 rounded-2xl rounded-tl-sm shadow-sm flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-muted-foreground rounded-full animate-bounce [animation-delay:-0.3s]" />
<span className="w-1.5 h-1.5 bg-muted-foreground rounded-full animate-bounce [animation-delay:-0.15s]" />
<span className="w-1.5 h-1.5 bg-muted-foreground rounded-full animate-bounce" />
</div>
</div>
)}
</div>
<div className="flex gap-2 bg-card p-2 rounded-lg border shadow-sm">
<Input
placeholder="Type your message in Norwegian..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
disabled={!isServerReady}
className="border-0 focus-visible:ring-0 bg-transparent text-base"
/>
<Button onClick={handleSend} size="icon" disabled={!isServerReady} className={cn("transition-all", input.trim() ? "scale-100" : "scale-90 opacity-50")}>
<Send className="w-5 h-5" />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { useState, useRef, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router'
import { MOCK_MISSIONS } from '@/lib/mockData'
import { MissionControl } from '../components/sandbox/MissionControl'
import { MissionReportModal } from '../components/sandbox/MissionReportModal'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Send, Sparkles, LogOut } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Watermark } from '@/features/flashcards/components/Watermark'
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
}
export const SandboxPage = () => {
const { missionId } = useParams()
const navigate = useNavigate()
const mission = MOCK_MISSIONS.find(m => m.id === missionId)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [completedKeywords, setCompletedKeywords] = useState<string[]>([])
const [showReport, setShowReport] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
// Init mission
useEffect(() => {
if (mission) {
setMessages([
{ id: 'init', role: 'assistant', content: mission.initialPrompt }
])
setCompletedKeywords([])
}
}, [mission])
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
if (!mission) return <div>Mission not found</div>
const handleSendMessage = (e?: React.FormEvent) => {
e?.preventDefault()
if (!inputValue.trim()) return
const newMessage: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: inputValue
}
setMessages(prev => [...prev, newMessage])
setInputValue('')
// Check for keywords
const lowerInput = inputValue.toLowerCase()
const newKeywords = mission.keywords.filter(k =>
lowerInput.includes(k.toLowerCase()) && !completedKeywords.includes(k.toLowerCase())
)
if (newKeywords.length > 0) {
setCompletedKeywords(prev => [...prev, ...newKeywords])
}
// Mock AI response
setTimeout(() => {
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: "Det forstår jeg. Vil du ha noe annet?"
}])
}, 1000)
}
const handleEndMission = () => {
setShowReport(true)
}
return (
<div className="flex h-[calc(100vh-4rem)] overflow-hidden relative">
<Watermark text="mission" size="lg" className="opacity-5 z-0" />
{/* Left Panel: Mission Control */}
<div className="w-1/3 min-w-[320px] h-full border-r bg-card/60 backdrop-blur-sm hidden md:block relative z-10 shadow-xl">
<MissionControl
mission={mission}
completedKeywords={completedKeywords}
/>
</div>
{/* Right Panel: Chat Interface */}
<div className="flex-1 flex flex-col h-full bg-transparent relative z-10">
{/* Header Overlay */}
<div className="absolute top-4 right-4 z-20 flex gap-2">
<Button variant="destructive" size="sm" className="shadow-lg hover:shadow-xl transition-all" onClick={handleEndMission}>
<LogOut className="w-4 h-4 mr-2" /> End Mission
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6 pb-24 scroll-smooth">
{messages.map((msg) => (
<div
key={msg.id}
className={cn(
"flex w-full animate-in slide-in-from-bottom-2 duration-300",
msg.role === 'user' ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
"max-w-[80%] rounded-2xl px-6 py-4 shadow-md text-base leading-relaxed relative",
msg.role === 'user'
? "bg-primary text-primary-foreground rounded-br-sm shadow-primary/20"
: "bg-card text-card-foreground rounded-bl-sm border shadow-black/5"
)}
>
{msg.content}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-6 bg-gradient-to-t from-background via-background/95 to-transparent absolute bottom-0 w-full z-20">
<form onSubmit={handleSendMessage} className="max-w-3xl mx-auto flex gap-3 items-center">
<div className="relative flex-1 group">
<div className="absolute inset-0 bg-primary/20 blur-xl rounded-full opacity-0 group-focus-within:opacity-100 transition-opacity duration-500" />
<Input
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="Type your response in Norwegian..."
className="pr-10 h-14 pl-6 rounded-full shadow-lg border-primary/20 bg-card/80 backdrop-blur text-lg focus-visible:ring-primary/50 relative z-10"
autoFocus
/>
<Button
type="button"
size="icon"
variant="ghost"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-primary z-20 hover:bg-transparent"
title="Get Hint"
>
<Sparkles className="w-5 h-5" />
</Button>
</div>
<Button type="submit" size="icon" className="h-14 w-14 rounded-full shadow-lg shrink-0" disabled={!inputValue.trim()}>
<Send className="w-6 h-6 ml-0.5" />
</Button>
</form>
</div>
</div>
<MissionReportModal
open={showReport}
onOpenChange={setShowReport}
score={Math.round((completedKeywords.length / mission.keywords.length) * 100)}
feedback="You did a great job using the required vocabulary. Try to focus more on sentence structure next time."
onHome={() => navigate('/chat')}
onRestart={() => window.location.reload()}
/>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { relaunch } from '@tauri-apps/plugin-process'
import { Button } from '@/components/ui/button'
import {
ErrorView,
ErrorHeader,
ErrorDescription,
ErrorActions
} from '@/features/errors/error-base'
export default function AppErrorPage() {
return (
<ErrorView>
<ErrorHeader>We&apos;re fixing it</ErrorHeader>
<ErrorDescription>
The app encountered an error and needs to be restarted.
<br />
We know about it and we&apos;re working to fix it.
</ErrorDescription>
<ErrorActions>
<Button size="lg" onClick={relaunch}>
Relaunch app
</Button>
</ErrorActions>
</ErrorView>
)
}

View File

@@ -0,0 +1,76 @@
import { cn } from '@/lib/utils'
import { ReactNode } from 'react'
export function ErrorView({
children,
className
}: {
children: ReactNode
className?: string
}) {
return (
<main
className={cn(
'h-full flex flex-col items-center justify-center text-center bg-red-50 p-8',
className
)}
>
<div className="text-center">
<p className="text-base font-semibold text-red-600">Error</p>
{children}
</div>
</main>
)
}
export function ErrorHeader({
children,
className
}: {
children: ReactNode
className?: string
}) {
return (
<h1
className={cn(
'mt-4 text-3xl font-bold tracking-tight text-gray-900 sm:text-5xl',
className
)}
>
{children}
</h1>
)
}
export function ErrorDescription({
children,
className
}: {
children: ReactNode
className?: string
}) {
return (
<p className={cn('mt-6 text-base leading-7 text-gray-600', className)}>
{children}
</p>
)
}
export function ErrorActions({
children,
className
}: {
children: ReactNode
className?: string
}) {
return (
<div
className={cn(
'mt-10 flex items-center justify-center gap-x-6',
className
)}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as db from '@/lib/db';
import { FlashcardSet, Flashcard } from '@/lib/mockData';
export const useFlashcardSets = () => {
return useQuery({
queryKey: ['flashcardSets'],
queryFn: db.getFlashcardSets
});
}
export const useFlashcardSet = (id: string) => {
return useQuery({
queryKey: ['flashcardSet', id],
queryFn: () => db.getFlashcardSetById(id),
enabled: !!id
});
}
export const useCreateFlashcardSet = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { title: string, topic: string, color: string }) =>
db.createFlashcardSet(data.title, data.topic, data.color),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['flashcardSets'] });
}
});
}
export const useDeleteFlashcardSet = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: db.deleteFlashcardSet,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['flashcardSets'] });
}
});
}
export const useCreateFlashcard = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { setId: string, front: string, back: string }) =>
db.createFlashcard(data.setId, data.front, data.back),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['flashcardSet', variables.setId] });
queryClient.invalidateQueries({ queryKey: ['flashcardSets'] }); // Update counts
}
});
}
export const useUpdateFlashcard = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { id: string, front: string, back: string, setId: string }) =>
db.updateFlashcard(data.id, data.front, data.back),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['flashcardSet', variables.setId] });
queryClient.invalidateQueries({ queryKey: ['flashcardSets'] });
}
});
}
export const useDeleteFlashcard = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { id: string, setId: string }) => db.deleteFlashcard(data.id),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['flashcardSet', variables.setId] });
queryClient.invalidateQueries({ queryKey: ['flashcardSets'] });
}
});
}

View File

@@ -0,0 +1,68 @@
import { motion } from 'framer-motion'
import { Button } from '@/components/ui/button'
import { RotateCw, ArrowLeft, CheckCircle2, XCircle } from 'lucide-react'
import { useNavigate } from 'react-router'
interface EndCardProps {
score: number
total: number
streak: number
onRestart: () => void
setId: string
}
export const EndCard = ({ score, total, streak, onRestart, setId }: EndCardProps) => {
const navigate = useNavigate()
const percentage = Math.round((score / total) * 100)
return (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center min-h-[60vh] w-full max-w-2xl mx-auto p-4"
>
<div className="text-center w-full">
<h1 className="text-4xl md:text-6xl font-black tracking-tight mb-2">
{percentage}%
</h1>
<p className="text-muted-foreground uppercase tracking-widest font-bold mb-8">
Final Score
</p>
{streak >= 5 && (
<div className="mb-8 inline-block animate-bounce">
<span className="bg-orange-100 text-orange-600 px-4 py-2 rounded-full font-bold border border-orange-200">
🔥 Highest Streak: {streak}
</span>
</div>
)}
<div className="grid gap-4 max-w-sm mx-auto w-full">
<div className="grid grid-cols-2 gap-4">
<div className="bg-green-50 dark:bg-green-900/20 p-4 rounded-xl border border-green-100 dark:border-green-800">
<span className="block text-2xl font-bold text-green-600 dark:text-green-400">{score}</span>
<span className="text-xs uppercase font-bold text-green-600/60 dark:text-green-400/60">Correct</span>
</div>
<div className="bg-red-50 dark:bg-red-900/20 p-4 rounded-xl border border-red-100 dark:border-red-800">
<span className="block text-2xl font-bold text-red-600 dark:text-red-400">{total - score}</span>
<span className="text-xs uppercase font-bold text-red-600/60 dark:text-red-400/60">Incorrect</span>
</div>
</div>
<div className="h-px bg-border my-2" />
<div className="grid gap-3">
<Button onClick={onRestart} className="w-full" size="lg">
<RotateCw className="w-4 h-4 mr-2" />
Restart Session
</Button>
<Button onClick={() => navigate(`/flashcards/${setId}`)} variant="outline" className="w-full" size="lg">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Set
</Button>
</div>
</div>
</div>
</motion.div >
)
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Check, X, RotateCw } from 'lucide-react'
import type { Flashcard as FlashcardType } from '@/lib/mockData'
interface FlashcardProps {
card: FlashcardType
onResult: (correct: boolean) => void
}
export function Flashcard({ card, onResult }: FlashcardProps) {
const [isFlipped, setIsFlipped] = useState(false)
const [showResult, setShowResult] = useState(false)
const handleFlip = () => {
setIsFlipped(!isFlipped)
if (!isFlipped) {
setShowResult(true)
}
}
return (
<div className="flex flex-col items-center gap-8 w-full max-w-xl mx-auto">
<div
className="group h-[400px] w-full [perspective:1000px] cursor-pointer"
onClick={handleFlip}
>
<div
className={cn(
"relative h-full w-full rounded-xl transition-all duration-500 [transform-style:preserve-3d]",
isFlipped ? "[transform:rotateY(180deg)]" : ""
)}
>
{/* Front */}
<Card className="absolute h-full w-full [backface-visibility:hidden] flex items-center justify-center p-8 border-2 border-primary/20 hover:border-primary/50 transition-colors shadow-lg">
<CardContent>
<h2 className="text-4xl font-bold text-center text-primary">{card.front}</h2>
<p className="mt-4 text-center text-muted-foreground text-sm uppercase tracking-widest font-semibold">Question</p>
</CardContent>
</Card>
{/* Back */}
<Card className="absolute h-full w-full [transform:rotateY(180deg)] [backface-visibility:hidden] flex items-center justify-center p-8 border-2 border-primary shadow-lg bg-accent/5">
<CardContent>
<h2 className="text-4xl font-bold text-center text-primary">{card.back}</h2>
<p className="mt-4 text-center text-muted-foreground text-sm uppercase tracking-widest font-semibold">Answer</p>
</CardContent>
</Card>
</div>
</div>
{showResult && (
<div className="flex gap-4 animate-in fade-in slide-in-from-bottom-4 duration-300">
<Button
variant="outline"
size="lg"
className="w-32 border-red-500 hover:bg-red-500/10 hover:text-red-500 text-red-500 gap-2"
onClick={() => {
setIsFlipped(false)
setShowResult(false)
onResult(false)
}}
>
<X className="w-5 h-5" />
Incorrect
</Button>
<Button
size="lg"
className="w-32 bg-green-600 hover:bg-green-700 text-white gap-2"
onClick={() => {
setIsFlipped(false)
setShowResult(false)
onResult(true)
}}
>
<Check className="w-5 h-5" />
Correct
</Button>
</div>
)}
{!showResult && (
<div className="h-12 flex items-center text-muted-foreground text-sm">
<RotateCw className="w-4 h-4 mr-2" />
Click card to flip
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,122 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { cn } from '@/lib/utils'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Check, X, RotateCw } from 'lucide-react'
import type { Flashcard } from '@/lib/mockData'
interface FlipCardProps {
card: Flashcard
onResult: (correct: boolean) => void
dataClass?: string // For future theming support
}
export const FlipCard = ({ card, onResult }: FlipCardProps) => {
const [isFlipped, setIsFlipped] = useState(false)
const [showResult, setShowResult] = useState(false)
const handleFlip = () => {
setIsFlipped(!isFlipped)
if (!isFlipped) {
setShowResult(true)
}
}
const variants = {
front: { rotateY: 0 },
back: { rotateY: 180 },
}
return (
<div className="flex flex-col items-center gap-8 w-full max-w-xl mx-auto perspective-1000">
<div
className="relative h-[400px] w-full cursor-pointer perspective-1000"
onClick={handleFlip}
>
<motion.div
className="w-full h-full relative preserve-3d"
initial="front"
animate={isFlipped ? "back" : "front"}
variants={variants}
transition={{ type: "spring", stiffness: 260, damping: 20 }}
style={{ transformStyle: "preserve-3d" }}
>
{/* Front */}
<div
className="absolute inset-0 w-full h-full backface-hidden"
style={{ backfaceVisibility: 'hidden' }}
>
<Card className="h-full w-full flex items-center justify-center p-8 border-2 border-primary/20 hover:border-primary/50 transition-colors shadow-2xl bg-card">
<CardContent>
<h2 className="text-4xl font-bold text-center text-primary">{card.front}</h2>
<p className="mt-8 text-center text-muted-foreground text-xs uppercase tracking-[0.2em] font-bold">Question</p>
</CardContent>
</Card>
</div>
{/* Back */}
<div
className="absolute inset-0 w-full h-full backface-hidden"
style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}
>
<Card className="h-full w-full flex items-center justify-center p-8 border-2 border-primary shadow-2xl bg-primary/5">
<CardContent>
<h2 className="text-4xl font-bold text-center text-primary">{card.back}</h2>
<p className="mt-8 text-center text-muted-foreground text-xs uppercase tracking-[0.2em] font-bold">Answer</p>
</CardContent>
</Card>
</div>
</motion.div>
</div>
<AnimatePresence mode="wait">
{showResult ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="flex gap-4"
>
<Button
variant="outline"
size="lg"
className="w-32 border-red-500 hover:bg-red-500/10 hover:text-red-500 text-red-500 gap-2 font-bold"
onClick={(e) => {
e.stopPropagation()
setIsFlipped(false)
setShowResult(false)
onResult(false)
}}
>
<X className="w-5 h-5" />
Incorrect
</Button>
<Button
size="lg"
className="w-32 bg-green-600 hover:bg-green-700 text-white gap-2 font-bold shadow-lg shadow-green-600/20"
onClick={(e) => {
e.stopPropagation()
setIsFlipped(false)
setShowResult(false)
onResult(true)
}}
>
<Check className="w-5 h-5" />
Correct
</Button>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="h-12 flex items-center text-muted-foreground text-sm font-medium"
>
<RotateCw className="w-4 h-4 mr-2 animate-spin-slow" />
Click card to flip
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { useEffect } from 'react'
import { motion, animate, useMotionValue, useTransform } from 'framer-motion'
import { Streak } from './Streak'
interface ProgressBarProps {
current: number
total: number
streak: number
}
export const ProgressBar = ({ current, total, streak }: ProgressBarProps) => {
const progress = total > 0 ? (current / total) : 0
const progressPercent = progress * 100
// Animate the number counting up
const count = useMotionValue(0)
const rounded = useTransform(count, latest => `${latest.toFixed(0)}%`)
useEffect(() => {
const controls = animate(count, progressPercent, { duration: 0.5 })
return controls.stop
}, [progressPercent])
return (
<div className="fixed top-4 left-4 right-4 z-50 flex flex-col items-center pointer-events-none">
<div className="w-full max-w-[450px] h-[25px] bg-background/80 backdrop-blur-sm border border-border rounded shadow-md overflow-hidden relative">
<motion.div
className="h-full bg-gradient-to-r from-orange-400 to-red-400"
initial={{ width: 0 }}
animate={{ width: `${progressPercent}%` }}
transition={{ ease: "easeInOut", duration: 0.5 }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<motion.span className="text-[10px] font-bold tracking-widest text-foreground mix-blend-difference">
{rounded}
</motion.span>
</div>
</div>
<div className="pointer-events-auto">
<Streak streak={streak} />
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { motion, AnimatePresence } from 'framer-motion'
import { useEffect, useState } from 'react'
interface StreakProps {
streak: number
}
const ACTIVATE_AT = 5
export const Streak = ({ streak }: StreakProps) => {
const [shouldPulse, setShouldPulse] = useState(false)
useEffect(() => {
if (streak >= ACTIVATE_AT) {
setShouldPulse(true)
}
}, [streak])
return (
<AnimatePresence>
{streak >= 3 && (
<motion.div
key="streak"
initial={{ scale: 0.85, opacity: 0, y: -10 }}
animate={{ scale: shouldPulse ? 1.125 : 1, opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="relative mt-4 mx-auto w-fit z-10"
>
<div className="relative z-10 bg-card border-2 border-orange-400 text-orange-400 rounded px-5 py-1 text-xs font-bold uppercase tracking-wider shadow-sm">
Streak x{streak} 🔥
</div>
{shouldPulse && (
<motion.div
key="pulse"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full bg-orange-400 rounded-full -z-10"
initial={{ scale: 1, opacity: 0.8 }}
animate={{ scale: 2.5, opacity: 0 }}
transition={{ duration: 0.8, ease: "easeOut" }}
onAnimationComplete={() => setShouldPulse(false)}
/>
)}
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,32 @@
import { cn } from '@/lib/utils'
interface WatermarkProps {
text: string
size?: 'md' | 'lg'
className?: string
}
export const Watermark = ({ text, size = 'md', className }: WatermarkProps) => {
// We replicate the "duplicated text" effect using pseudo-elements in CSS,
// or simply render multiple spans for simplicity in Tailwind.
// The reference uses ::before and ::after for duplicates.
// Let's use a distinct visual approach that looks similar.
return (
<div
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 -rotate-12",
"pointer-events-none select-none z-0",
"font-black tracking-widest uppercase opacity-[0.03] text-foreground",
"flex flex-col items-center justify-center leading-[0.85]",
size === 'lg' ? "text-[8rem] md:text-[12rem]" : "text-[4rem] md:text-[6rem]",
className
)}
aria-hidden="true"
>
<span className="translate-x-4">{text}</span>
<span>{text}</span>
<span className="-translate-x-4">{text}</span>
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from 'react-router'
export const FlashcardsLayout = () => {
return (
<div className="w-full h-full">
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,220 @@
import { useNavigate, useParams } from 'react-router'
import { useFlashcardSet, useCreateFlashcard, useUpdateFlashcard, useDeleteFlashcard } from '@/features/flashcards/api/queries'
import { Button } from '@/components/ui/button'
import { ArrowLeft, ArrowDownCircle, Search, Plus, Trash2, Edit2, Loader2, Save } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { useState, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { useQueryClient } from '@tanstack/react-query'
export const SetListPage = () => {
const { setId } = useParams()
const navigate = useNavigate()
const { data: set, isLoading } = useFlashcardSet(setId || '')
const createCardMutation = useCreateFlashcard()
const updateCardMutation = useUpdateFlashcard()
const deleteCardMutation = useDeleteFlashcard()
const [searchTerm, setSearchTerm] = useState('')
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [editingCardId, setEditingCardId] = useState<string | null>(null)
// Form state
const [front, setFront] = useState('')
const [back, setBack] = useState('')
const filteredCards = useMemo(() => {
if (!set?.cards) return []
if (!searchTerm) return set.cards
return set.cards.filter(c =>
c.front.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.back.toLowerCase().includes(searchTerm.toLowerCase())
)
}, [set, searchTerm])
const handleAddCard = () => {
if (!setId || !front.trim() || !back.trim()) return
createCardMutation.mutate({ setId, front, back }, {
onSuccess: () => {
setIsAddDialogOpen(false)
setFront('')
setBack('')
}
})
}
const startEditing = (card: any) => {
setEditingCardId(card.id)
setFront(card.front)
setBack(card.back)
setIsAddDialogOpen(true)
}
const handleUpdateCard = () => {
if (!setId || !editingCardId || !front.trim() || !back.trim()) return
updateCardMutation.mutate({ id: editingCardId, setId, front, back }, {
onSuccess: () => {
setIsAddDialogOpen(false)
setEditingCardId(null)
setFront('')
setBack('')
}
})
}
const handleSave = () => {
if (editingCardId) {
handleUpdateCard()
} else {
handleAddCard()
}
}
const handleDelete = (id: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!setId) return
if (confirm('Are you sure you want to delete this card?')) {
deleteCardMutation.mutate({ id, setId })
}
}
const openAddDialog = () => {
setEditingCardId(null)
setFront('')
setBack('')
setIsAddDialogOpen(true)
}
if (isLoading) return <div className="flex justify-center items-center h-screen"><Loader2 className="w-8 h-8 animate-spin" /></div>
if (!set) return <div className="p-8">Set not found</div>
return (
<div className="flex flex-col min-h-screen p-4 md:p-8 max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8 space-y-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" className="-ml-3" onClick={() => navigate('/flashcards')}>
<ArrowLeft className="w-5 h-5 mr-2" /> Back
</Button>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button onClick={openAddDialog}>
<Plus className="w-4 h-4 mr-2" /> Add Card
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingCardId ? 'Edit Card' : 'Add New Card'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="front">Front (Question)</Label>
<Input
id="front"
value={front}
onChange={(e) => setFront(e.target.value)}
placeholder="e.g., God morgen"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="back">Back (Answer)</Label>
<Input
id="back"
value={back}
onChange={(e) => setBack(e.target.value)}
placeholder="e.g., Good morning"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={createCardMutation.isPending || updateCardMutation.isPending}>
{(createCardMutation.isPending || updateCardMutation.isPending) && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">{set.title}</h1>
<p className="text-muted-foreground font-medium flex items-center gap-2 mt-1">
<span className="w-8 h-[2px] bg-primary/20 block" />
List View ({set.cards?.length || 0} cards)
</p>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Filter cards..."
className="pl-9 bg-card/50 backdrop-blur-sm border-2 focus-visible:ring-offset-0 focus-visible:ring-2 focus-visible:ring-primary/20"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</motion.div>
<div className="space-y-3 pb-20">
<AnimatePresence mode="popLayout">
{filteredCards.map((card, idx) => (
<motion.div
key={`${card.id}-${idx}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ delay: idx * 0.05 }}
layout
className="group bg-card hover:bg-accent/5 transition-colors border rounded-xl p-4 md:p-6 shadow-sm flex flex-col md:flex-row md:items-center gap-4 relative"
>
<div className="flex-1 font-medium text-lg text-primary/90">
{card.front}
</div>
<div className="hidden md:flex items-center justify-center text-muted-foreground/30">
<ArrowDownCircle className="w-6 h-6 -rotate-90 group-hover:text-primary/50 transition-colors" />
</div>
<div className="md:hidden flex items-center justify-center text-muted-foreground/30 py-2">
<ArrowDownCircle className="w-6 h-6 group-hover:text-primary/50 transition-colors" />
</div>
<div className="flex-1 md:text-right font-bold text-xl text-primary">
{card.back}
</div>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary" onClick={() => startEditing(card)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10" onClick={(e) => handleDelete(card.id, e)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</motion.div>
))}
</AnimatePresence>
{filteredCards.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
No cards found matching your search.
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useParams, useNavigate } from 'react-router'
import { useFlashcardSet } from '@/features/flashcards/api/queries'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { RotateCw, PenTool, List, ArrowLeft, Volume2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Loader2 } from 'lucide-react'
export const SetOverviewPage = () => {
const { setId } = useParams()
const navigate = useNavigate()
const { data: set, isLoading } = useFlashcardSet(setId || '')
if (isLoading) return <div className="flex justify-center items-center h-full"><Loader2 className="w-8 h-8 animate-spin" /></div>
if (!set) {
return <div className="p-8">Set not found</div>
}
return (
<div className="p-8 max-w-4xl mx-auto">
<Button
variant="ghost"
className="mb-6 -ml-4 text-muted-foreground hover:text-foreground"
onClick={() => navigate('/flashcards')}
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Sets
</Button>
<div className={cn("rounded-3xl p-8 text-white mb-8 bg-gradient-to-r shadow-xl", set.color)}>
<h1 className="text-4xl font-bold mb-2">{set.title}</h1>
<p className="opacity-90 text-lg">{set.topic} {set.cards?.length || 0} Cards</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card
className="cursor-pointer hover:border-primary transition-colors hover:shadow-md"
onClick={() => navigate(`/flashcards/${set.id}/study`)}
>
<CardContent className="p-6 flex flex-col items-center text-center gap-4 pt-10">
<div className="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex items-center justify-center">
<RotateCw className="w-8 h-8" />
</div>
<div>
<h3 className="text-xl font-bold mb-1">Flashcards</h3>
<p className="text-sm text-muted-foreground">Traditional flip mode</p>
</div>
</CardContent>
</Card>
<Card
className="cursor-pointer hover:border-primary transition-colors hover:shadow-md"
onClick={() => navigate(`/flashcards/${set.id}/transcription`)}
>
<CardContent className="p-6 flex flex-col items-center text-center gap-4 pt-10">
<div className="w-16 h-16 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 flex items-center justify-center">
<Volume2 className="w-8 h-8" />
</div>
<div>
<h3 className="text-xl font-bold mb-1">Listening</h3>
<p className="text-sm text-muted-foreground">Type what you hear</p>
</div>
</CardContent>
</Card>
<Card
className="cursor-pointer hover:border-primary transition-colors hover:shadow-md"
onClick={() => navigate(`/flashcards/${set.id}/spelling`)}
>
<CardContent className="p-6 flex flex-col items-center text-center gap-4 pt-10">
<div className="w-16 h-16 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 flex items-center justify-center">
<PenTool className="w-8 h-8" />
</div>
<div>
<h3 className="text-xl font-bold mb-1">Spelling Mode</h3>
<p className="text-sm text-muted-foreground">Type what you hear/see</p>
</div>
</CardContent>
</Card>
<Card
className="cursor-pointer hover:border-primary transition-colors hover:shadow-md"
onClick={() => navigate(`/flashcards/${set.id}/list`)}
>
<CardContent className="p-6 flex flex-col items-center text-center gap-4 pt-10">
<div className="w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 flex items-center justify-center">
<List className="w-8 h-8" />
</div>
<div>
<h3 className="text-xl font-bold mb-1">Card List</h3>
<p className="text-sm text-muted-foreground">View and manage cards</p>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,148 @@
import { useFlashcardSets, useCreateFlashcardSet } from '@/features/flashcards/api/queries'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useNavigate } from 'react-router'
import { cn } from '@/lib/utils'
import { Layers, Plus, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useState } from 'react'
export const SetsPage = () => {
const navigate = useNavigate()
const { data: sets, isLoading } = useFlashcardSets()
const createSetMutation = useCreateFlashcardSet()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [title, setTitle] = useState('')
const [topic, setTopic] = useState('')
// Random gradient generator or selector could be here.
const getRandomGradient = () => {
const gradients = [
'from-blue-500 to-cyan-500',
'from-purple-500 to-pink-500',
'from-orange-500 to-red-500',
'from-green-500 to-emerald-500',
'from-indigo-500 to-violet-500'
]
return gradients[Math.floor(Math.random() * gradients.length)]
}
const handleCreateSet = () => {
console.log("Attempting to create set:", { title, topic });
if (!title.trim() || !topic.trim()) {
console.log("Validation failed: Empty fields");
return;
}
createSetMutation.mutate({
title,
topic,
color: getRandomGradient()
}, {
onSuccess: () => {
console.log("Set created successfully");
setIsDialogOpen(false)
setTitle('')
setTopic('')
},
onError: (error) => {
console.error("Failed to create set:", error);
alert(`Failed to create set: ${error}`);
}
})
}
if (isLoading) return <div className="flex justify-center items-center h-full"><Loader2 className="w-8 h-8 animate-spin" /></div>
return (
<div className="p-8 max-w-5xl mx-auto">
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Flashcard Sets</h1>
<p className="text-muted-foreground">Select a deck to start learning or create a new one.</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" /> New Deck
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Deck</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="col-span-3"
placeholder="e.g., Common Verbs"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="topic" className="text-right">Topic</Label>
<Input
id="topic"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="col-span-3"
placeholder="e.g., Grammar"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreateSet} disabled={createSetMutation.isPending || !title.trim() || !topic.trim()}>
{createSetMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sets?.map((set) => (
<Card
key={set.id}
className="cursor-pointer hover:shadow-lg transition-all hover:scale-[1.02] border-2 hover:border-primary/50 group"
onClick={() => navigate(`/flashcards/${set.id}`)}
>
<div className={cn("h-32 w-full bg-gradient-to-br p-6 text-white flex flex-col justify-end", set.color)}>
<Layers className="w-8 h-8 mb-2 opacity-80" />
<h2 className="text-2xl font-bold">{set.title}</h2>
</div>
<CardContent className="p-6">
<div className="flex justify-between items-center mb-4">
<Badge variant="secondary">{set.topic}</Badge>
<span className="text-sm text-muted-foreground font-medium">
{set.cards?.length || 0} cards
</span>
</div>
<p className="text-sm text-muted-foreground">
Tap to view study options, edit cards, or check your progress.
</p>
</CardContent>
</Card>
))}
{sets && sets.length === 0 && (
<div className="col-span-full text-center py-20 text-muted-foreground">
<p>No flashcard sets found. Create one to get started!</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,294 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router'
import { useFlashcardSet } from '@/features/flashcards/api/queries'
import { Button } from '@/components/ui/button'
import { HelpCircle, ArrowRight, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { motion, AnimatePresence } from 'framer-motion'
import { Watermark } from '../components/Watermark'
import { ProgressBar } from '../components/ProgressBar'
import { EndCard } from '../components/EndCard'
// Helper to tokenize sentence into [word, punctuation, word, ...]
const tokenize = (text: string) => {
return text.split(/([^\w\u00C0-\u017F]+)/).filter(t => t.length > 0)
}
const isWord = (token: string) => {
return /^[\w\u00C0-\u017F]+$/.test(token)
}
interface WordInputProps {
expected: string
value: string
status: 'idle' | 'correct' | 'incorrect'
onChange: (val: string) => void
onComplete: () => void
onBackspaceFromEmpty: () => void
inputRef: (el: HTMLInputElement | null) => void
isFocused: boolean
}
const WordInput = ({ expected, value, status, onChange, onComplete, onBackspaceFromEmpty, inputRef, isFocused }: WordInputProps) => {
const displayChars = useMemo(() => {
const chars = []
for (let i = 0; i < expected.length; i++) {
chars.push(value[i] || '_')
}
return chars
}, [expected, value])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Strip spaces to prevent them from "eating" the underscores
const rawVal = e.target.value.replace(/\s/g, '')
const newVal = rawVal.slice(0, expected.length)
onChange(newVal)
if (newVal.length === expected.length) {
onComplete()
}
}
return (
<div className="relative inline-flex justify-center mx-1 my-2">
<div
className={cn(
"flex justify-center w-full font-mono text-xl md:text-2xl font-bold pointer-events-none select-none transition-all duration-200",
"bg-card border-none shadow-[0_3px_0_hsl(var(--background)),0_3px_8px_rgba(0,0,0,0.1)] rounded px-2 py-1",
status === 'correct' && "shadow-[0_3px_0_hsl(var(--success)),0_3px_8px_rgba(0,0,0,0.1)] text-green-600",
status === 'incorrect' && "shadow-[0_3px_0_hsl(var(--destructive)),0_3px_8px_rgba(0,0,0,0.1)] text-red-500",
isFocused && status === 'idle' && "shadow-[0_3px_0_hsl(var(--primary)),0_3px_8px_rgba(0,0,0,0.1)] -translate-y-[3px]"
)}
>
{/* Render spaced characters */}
<span className="tracking-[0.5em] mr-[-0.5em]">{displayChars.join('')}</span>
</div>
<input
ref={inputRef}
value={value}
onChange={handleChange}
onKeyDown={(e) => {
// Navigate on Space
if (e.key === ' ') {
e.preventDefault()
onComplete()
}
if (e.key === 'Backspace' && value.length === 0) onBackspaceFromEmpty()
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-text"
autoComplete="off"
disabled={status !== 'idle'}
/>
</div>
)
}
export const SpellingModePage = () => {
const { setId } = useParams()
const navigate = useNavigate()
const { data: set, isLoading } = useFlashcardSet(setId || '')
const [currentIndex, setCurrentIndex] = useState(0)
const [status, setStatus] = useState<'idle' | 'correct' | 'incorrect'>('idle')
const [score, setScore] = useState(0)
const [streak, setStreak] = useState(0)
const [completed, setCompleted] = useState(false)
const [showAnswer, setShowAnswer] = useState(false)
const [focusedIndex, setFocusedIndex] = useState<number>(0)
const [inputValues, setInputValues] = useState<string[]>([])
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
if (isLoading) return <div className="flex justify-center items-center h-screen"><Loader2 className="w-8 h-8 animate-spin" /></div>
if (!set) return <div>Set not found</div>
if (!set.cards || set.cards.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-4rem)] p-8 text-center">
<h2 className="text-2xl font-bold mb-4">No Cards Found</h2>
<p className="text-muted-foreground mb-6">There are no cards in this set. Add some to continue!</p>
<Button onClick={() => navigate(`/flashcards/${setId}/list`)}>
Manage Cards
</Button>
</div>
)
}
// Ensure card exists before accessing
const card = set.cards && set.cards[currentIndex] ? set.cards[currentIndex] : null
if (!card) return <div>Card not found</div>
const tokens = useMemo(() => tokenize(card.front), [card])
useEffect(() => {
setInputValues(tokens.map(t => isWord(t) ? '' : t))
setStatus('idle')
setShowAnswer(false)
setFocusedIndex(0)
// Find first word
const firstWordIdx = tokens.findIndex(t => isWord(t))
if (firstWordIdx !== -1) {
setFocusedIndex(firstWordIdx)
setTimeout(() => inputRefs.current[firstWordIdx]?.focus(), 50)
}
}, [currentIndex, tokens])
const handleInputChange = (index: number, val: string) => {
const newValues = [...inputValues]
newValues[index] = val
setInputValues(newValues)
}
const focusNext = (currIdx: number) => {
for (let i = currIdx + 1; i < tokens.length; i++) {
if (isWord(tokens[i])) {
inputRefs.current[i]?.focus()
setFocusedIndex(i)
return
}
}
}
const focusPrev = (currIdx: number) => {
for (let i = currIdx - 1; i >= 0; i--) {
if (isWord(tokens[i])) {
inputRefs.current[i]?.focus()
setFocusedIndex(i)
return
}
}
}
const checkAnswer = (e?: React.FormEvent) => {
e?.preventDefault()
if (status !== 'idle') return
const userSentence = inputValues.join('')
const normalize = (s: string) => s.toLowerCase().trim()
const isCorrect = normalize(userSentence) === normalize(card.front)
setStatus(isCorrect ? 'correct' : 'incorrect')
if (isCorrect) {
setScore(score + 1)
setStreak(streak + 1)
} else {
setStreak(0)
}
}
const handleGiveUp = () => {
setStatus('incorrect')
setShowAnswer(true)
setStreak(0)
}
const nextCard = () => {
if (currentIndex < set.cards.length - 1) {
setCurrentIndex(currentIndex + 1)
} else {
setCompleted(true)
}
}
const handleRestart = () => {
setCurrentIndex(0)
setScore(0)
setStreak(0)
setCompleted(false)
setStatus('idle')
}
if (completed) {
return <EndCard score={score} total={set.cards.length} streak={streak} onRestart={handleRestart} setId={setId || ''} />
}
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-4rem)] p-4 md:p-8 max-w-5xl mx-auto relative overflow-hidden">
<ProgressBar current={currentIndex} total={set.cards.length} streak={streak} />
<Watermark text="spelling" size="lg" />
<div className="w-full max-w-3xl relative z-10 mt-16 md:mt-0">
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, x: 50, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -50, scale: 0.9 }}
transition={{ type: "spring", stiffness: 200, damping: 25 }}
className={cn(
"bg-card/80 backdrop-blur-md border-2 rounded-2xl p-8 md:p-12 shadow-2xl text-center flex flex-col items-center gap-8 transition-colors duration-300",
status === 'correct' ? "border-green-400" :
status === 'incorrect' ? "border-red-400" : "border-primary/20"
)}
>
<div>
<p className="text-sm font-bold text-muted-foreground uppercase tracking-widest mb-4">Translate to Norwegian</p>
<h2 className="text-3xl md:text-5xl font-bold text-foreground mb-2">{card.back}</h2>
</div>
<form onSubmit={checkAnswer} className="flex flex-wrap justify-center items-center gap-1 w-full relative min-h-[80px]">
{tokens.map((token, idx) => {
if (!isWord(token)) {
return <span key={idx} className="text-2xl font-mono mx-1 select-none opacity-50">{token}</span>
}
return (
<WordInput
key={idx}
expected={token}
value={inputValues[idx] || ''}
status={status}
onChange={(val) => handleInputChange(idx, val)}
onComplete={() => focusNext(idx)}
onBackspaceFromEmpty={() => focusPrev(idx)}
inputRef={(el) => (inputRefs.current[idx] = el)}
isFocused={focusedIndex === idx}
/>
)
})}
</form>
<div className="min-h-[60px] w-full flex justify-center items-center">
{status === 'idle' ? (
<div className="flex gap-4 w-full max-w-xs">
<Button size="lg" className="flex-1 font-bold tracking-wide shadow-lg hover:translate-y-[-2px] transition-all" onClick={checkAnswer}>
CHECK
</Button>
<Button variant="outline" size="icon" className="shrink-0" onClick={handleGiveUp} title="Give Up">
<HelpCircle className="w-5 h-5" />
</Button>
</div>
) : (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md"
>
{(status === 'incorrect' || showAnswer) && (
<div className="bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 px-4 py-2 rounded-lg font-mono text-lg font-bold mb-4 shadow-inner">
{card.front}
</div>
)}
<Button
size="lg"
className={cn(
"w-full font-bold tracking-wide shadow-lg group",
status === 'correct' ? "bg-green-600 hover:bg-green-700 text-white" : "bg-red-600 hover:bg-red-700 text-white"
)}
onClick={nextCard}
autoFocus
>
CONTINUE <ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</motion.div>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router'
import { useFlashcardSet } from '@/features/flashcards/api/queries'
import { Loader2 } from 'lucide-react'
import { FlipCard } from '../components/FlipCard'
import { Button } from '@/components/ui/button'
import { Check, X, ArrowLeft, RotateCw } from 'lucide-react'
import { cn } from '@/lib/utils'
import { motion, AnimatePresence } from 'framer-motion'
import { Watermark } from '../components/Watermark'
import { ProgressBar } from '../components/ProgressBar'
import { EndCard } from '../components/EndCard'
export const StudyModePage = () => {
const { setId } = useParams()
const navigate = useNavigate()
const { data: set, isLoading } = useFlashcardSet(setId || '')
const [currentIndex, setCurrentIndex] = useState(0)
const [completed, setCompleted] = useState(false)
const [score, setScore] = useState(0)
const [streak, setStreak] = useState(0)
if (isLoading) return <div className="flex justify-center items-center h-screen"><Loader2 className="w-8 h-8 animate-spin" /></div>
if (!set) return <div>Set not found</div>
if (!set.cards || set.cards.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-4rem)] p-8 text-center">
<h2 className="text-2xl font-bold mb-4">No Cards Found</h2>
<p className="text-muted-foreground mb-6">There are no cards in this set. Add some to continue!</p>
<Button onClick={() => navigate(`/flashcards/${setId}/list`)}>
Manage Cards
</Button>
</div>
)
}
const handleResult = (correct: boolean) => {
if (correct) {
setScore(score + 1)
setStreak(streak + 1)
} else {
setStreak(0)
}
if (currentIndex < set.cards.length - 1) {
setTimeout(() => setCurrentIndex(currentIndex + 1), 300)
} else {
setCompleted(true)
}
}
const reset = () => {
setCurrentIndex(0)
setScore(0)
setStreak(0)
setCompleted(false)
}
if (completed) {
return <EndCard score={score} total={set.cards.length} streak={streak} onRestart={reset} setId={setId || ''} />
}
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-4rem)] p-8 max-w-4xl mx-auto overflow-hidden relative">
<ProgressBar current={currentIndex} total={set.cards.length} streak={streak} />
<Watermark text="cards" size="lg" />
<div className="w-full relative z-10 pt-16">
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ type: "spring", stiffness: 200, damping: 25 }}
className="w-full flex justify-center"
>
<FlipCard
key={`${setId}-${currentIndex}`}
card={set.cards[currentIndex]}
onResult={handleResult}
/>
</motion.div>
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -0,0 +1,191 @@
import { useState, useRef, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router'
import { useFlashcardSet } from '@/features/flashcards/api/queries'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card'
import { Play, Pause, Check, SkipForward, AlertCircle, Volume2, Loader2, ArrowLeft } from 'lucide-react'
import { cn } from '@/lib/utils'
import { speak } from '@/lib/tts'
import { Watermark } from '../components/Watermark'
import { ProgressBar } from '../components/ProgressBar'
import { EndCard } from '../components/EndCard'
export const TranscriptionModePage = () => {
const { setId } = useParams()
const navigate = useNavigate()
const { data: set, isLoading } = useFlashcardSet(setId || '')
const [currentIndex, setCurrentIndex] = useState(0)
const [userInput, setUserInput] = useState('')
const [isPlaying, setIsPlaying] = useState(false)
const [result, setResult] = useState<'correct' | 'incorrect' | null>(null)
const [score, setScore] = useState(0)
const [streak, setStreak] = useState(0)
const [completed, setCompleted] = useState(false)
// Derived state
const card = set?.cards?.[currentIndex]
// Play audio when card changes or when explicitly requested
useEffect(() => {
if (card && !completed) {
playAudio()
}
}, [currentIndex, card, completed])
const playAudio = () => {
if (!card) return
setIsPlaying(true)
speak(card.front, 'no-NO', 0.9)
// Reset playing state after a rough estimate or just immediately since speak is fire-and-forget mostly
// A better way would be using onend callback if we promisified it, but for now:
setTimeout(() => setIsPlaying(false), 2000)
}
if (isLoading) return <div className="flex justify-center items-center h-screen"><Loader2 className="w-8 h-8 animate-spin" /></div>
if (!set) return <div>Set not found</div>
if (!set.cards || set.cards.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-4rem)] p-8 text-center">
<h2 className="text-2xl font-bold mb-4">No Cards Found</h2>
<p className="text-muted-foreground mb-6">There are no cards in this set. Add some to continue!</p>
<Button onClick={() => navigate(`/flashcards/${setId}/list`)}>
Manage Cards
</Button>
</div>
)
}
if (!card) return <div>Card not found</div>
const checkAnswer = () => {
const normalize = (s: string) => s.toLowerCase().trim().replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
const isCorrect = normalize(userInput) === normalize(card.front)
setResult(isCorrect ? 'correct' : 'incorrect')
if (isCorrect) {
setScore(score + 1)
setStreak(streak + 1)
} else {
setStreak(0)
}
}
const nextCard = () => {
if (currentIndex < (set.cards?.length || 0) - 1) {
setCurrentIndex(currentIndex + 1)
setUserInput('')
setResult(null)
} else {
setCompleted(true)
}
}
const reset = () => {
setCurrentIndex(0)
setScore(0)
setStreak(0)
setCompleted(false)
setResult(null)
setUserInput('')
}
if (completed) {
return <EndCard score={score} total={set.cards.length} streak={streak} onRestart={reset} setId={setId || ''} />
}
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-4rem)] p-4 md:p-8 max-w-3xl mx-auto relative">
<ProgressBar current={currentIndex} total={set.cards.length} streak={streak} />
<Watermark text="transcription" size="lg" />
<div className="w-full relative z-10 mt-8">
<Button
variant="ghost"
className="absolute top-0 left-0 -mt-12 md:-ml-12 text-muted-foreground hover:text-foreground"
onClick={() => navigate(`/flashcards/${setId}`)}
>
<ArrowLeft className="w-4 h-4 mr-2" /> Exit
</Button>
<Card className="w-full border-2 shadow-xl bg-card/90 backdrop-blur-sm">
<CardContent className="p-8 flex flex-col items-center gap-8">
<div className="text-center">
<h2 className="text-sm font-bold text-muted-foreground uppercase tracking-widest mb-2">Listen & Type</h2>
<p className="text-xs text-muted-foreground">Type the Norwegian word or phrase you hear.</p>
</div>
<div className="w-32 h-32 rounded-full bg-primary/10 flex items-center justify-center relative group cursor-pointer" onClick={playAudio}>
<div className={cn("absolute inset-0 rounded-full border-4 border-primary/30 scale-100 transition-transform duration-1000", isPlaying && "animate-ping")} />
<Button
variant="outline"
size="icon"
className="w-24 h-24 rounded-full border-4 border-primary text-primary hover:bg-primary hover:text-white transition-all scale-100 group-hover:scale-105 active:scale-95 z-10"
>
<Volume2 className={cn("w-10 h-10", isPlaying && "animate-pulse")} />
</Button>
</div>
<div className="w-full space-y-4 max-w-md">
<Input
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !result) checkAnswer()
else if (e.key === 'Enter' && result === 'correct') nextCard()
}}
placeholder="Type here..."
className="text-xl p-6 h-auto text-center shadow-inner"
disabled={result === 'correct'}
autoFocus
/>
<div className="flex justify-center gap-4 min-h-[50px]">
{result === null && (
<Button onClick={checkAnswer} size="lg" className="w-full font-bold">
Check Answer
</Button>
)}
{result === 'incorrect' && (
<Button onClick={checkAnswer} variant="destructive" size="lg" className="w-full font-bold">
Try Again
</Button>
)}
{result === 'correct' && (
<Button onClick={nextCard} variant="default" size="lg" className="w-full bg-green-600 hover:bg-green-700 font-bold">
Next <SkipForward className="ml-2 w-4 h-4" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{result === 'incorrect' && (
<div className="mt-6 flex items-center justify-center gap-2 text-red-500 animate-in slide-in-from-top-2 bg-red-100/50 dark:bg-red-900/20 p-2 rounded-lg">
<AlertCircle className="w-5 h-5" />
<span className="font-medium">Incorrect. Listen closely and try again!</span>
</div>
)}
{result === 'correct' && (
<div className="mt-6 flex flex-col items-center gap-2 text-green-600 animate-in slide-in-from-top-2 bg-green-100/50 dark:bg-green-900/20 p-4 rounded-lg">
<div className="flex items-center gap-2 font-bold text-xl">
<Check className="w-6 h-6" />
<span>Correct!</span>
</div>
<div className="text-center">
<p className="text-foreground font-medium text-lg">{card.front}</p>
<p className="text-muted-foreground">{card.back}</p>
</div>
</div>
)}
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More