Initialize repo
72
.gitignore
vendored
Normal 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
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
25
.npmignore
Normal 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
@@ -0,0 +1,5 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
src-tauri
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
21
LICENSE
Normal 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
26412
LLM_CONTEXTS/ispeakerreact.txt
Normal file
27644
LLM_CONTEXTS/koellabs.txt
Normal file
1830
LLM_CONTEXTS/konradkalemba.txt
Normal file
6975
LLM_CONTEXTS/llama.cpp.txt
Normal file
17015
LLM_CONTEXTS/llms-full-tauri.txt
Normal file
56983
LLM_CONTEXTS/radix-ui-primitives.txt
Normal file
30
LLM_CONTEXTS/role.txt
Normal 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
3630
LLM_CONTEXTS/snc.txt
Normal file
10858
LLM_CONTEXTS/stunning-system.txt
Normal file
9163
LLM_CONTEXTS/vite.txt
Normal file
107
README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Tauri: An Ultimate Project Template
|
||||
|
||||
[](https://www.npmjs.com/package/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.
|
||||
|
||||

|
||||
|
||||
## 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
|
After Width: | Height: | Size: 602 KiB |
21
components.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
28
src-tauri/Cargo.toml
Normal 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
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
21
src-tauri/capabilities/llama.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
17
src-tauri/capabilities/migrated.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal 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"]}}
|
||||
2918
src-tauri/gen/schemas/desktop-schema.json
Normal file
2918
src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
echo This is a dummy llama-server for build verification.
|
||||
637
src-tauri/src/commands.rs
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
34
src/app/routes/not-found.tsx
Normal 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 couldn’t find the page you’re 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">
|
||||
→
|
||||
</span>
|
||||
</Button>
|
||||
</ErrorActions>
|
||||
</ErrorView>
|
||||
)
|
||||
}
|
||||
|
||||
// Necessary for react router to lazy load.
|
||||
export const Component = NotFoundErrorPage
|
||||
13
src/components/layout/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
src/components/theme-provider.tsx
Normal 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
|
||||
}
|
||||
59
src/components/ui/alert.tsx
Normal 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 }
|
||||
36
src/components/ui/badge.tsx
Normal 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 }
|
||||
57
src/components/ui/button.tsx
Normal 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 }
|
||||
79
src/components/ui/card.tsx
Normal 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 }
|
||||
122
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
25
src/components/ui/input.tsx
Normal 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 }
|
||||
26
src/components/ui/label.tsx
Normal 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 }
|
||||
26
src/components/ui/progress.tsx
Normal 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 }
|
||||
48
src/components/ui/select.tsx
Normal 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 }
|
||||
24
src/components/ui/slider.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
24
src/components/ui/textarea.tsx
Normal 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 }
|
||||
59
src/components/ui/tooltip.tsx
Normal 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
@@ -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
@@ -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,
|
||||
},
|
||||
]
|
||||
65
src/features/ai-chat/components/sandbox/MissionControl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
310
src/features/ai-chat/routes/ChatPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
src/features/ai-chat/routes/SandboxPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/features/errors/app-error.tsx
Normal 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're fixing it</ErrorHeader>
|
||||
<ErrorDescription>
|
||||
The app encountered an error and needs to be restarted.
|
||||
<br />
|
||||
We know about it and we're working to fix it.
|
||||
</ErrorDescription>
|
||||
<ErrorActions>
|
||||
<Button size="lg" onClick={relaunch}>
|
||||
Relaunch app
|
||||
</Button>
|
||||
</ErrorActions>
|
||||
</ErrorView>
|
||||
)
|
||||
}
|
||||
76
src/features/errors/error-base.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
src/features/flashcards/api/queries.ts
Normal 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'] });
|
||||
}
|
||||
});
|
||||
}
|
||||
68
src/features/flashcards/components/EndCard.tsx
Normal 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 >
|
||||
)
|
||||
}
|
||||
92
src/features/flashcards/components/Flashcard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
122
src/features/flashcards/components/FlipCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
src/features/flashcards/components/ProgressBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
src/features/flashcards/components/Streak.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/features/flashcards/components/Watermark.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
src/features/flashcards/routes/FlashcardsLayout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from 'react-router'
|
||||
|
||||
export const FlashcardsLayout = () => {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
220
src/features/flashcards/routes/SetListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
src/features/flashcards/routes/SetOverviewPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
src/features/flashcards/routes/SetsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
294
src/features/flashcards/routes/SpellingModePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
src/features/flashcards/routes/StudyModePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
191
src/features/flashcards/routes/TranscriptionModePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||