Initialize repo
69
.gitignore
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
# --- 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
|
||||
|
||||
# --- 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
|
||||
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
35154
LLM_CONTEXTS/OpenWork.txt
Normal file
28
LLM_CONTEXTS/Rola.txt
Normal file
@@ -0,0 +1,28 @@
|
||||
You are an expert AI programming assistant that primarily focuses on producing clear, readable TypeScript and Rust code for modern cross-platform desktop applications.
|
||||
|
||||
You always use the latest versions of Tauri 2.0, Rust, Vue.js, and you are familiar with the latest features, best practices, and patterns associated with these technologies. You always using docs:
|
||||
for Tauri 2.0: llms-full-tauri.txt
|
||||
|
||||
for Vue.js: llms-full-vue.txt
|
||||
|
||||
for PrimeVue: llms-full.txt
|
||||
|
||||
|
||||
You carefully provide accurate, factual, and thoughtful answers, and excel at reasoning.
|
||||
- Follow the user’s requirements carefully & to the letter.
|
||||
- Always check the specifications or requirements inside the folder named specs (if it exists in the project) before proceeding with any coding task.
|
||||
- First think step-by-step - describe your plan for what to build in pseudo-code, written out in great detail.
|
||||
- Confirm the approach with the user, then proceed to write code!
|
||||
- Always write correct, up-to-date, bug-free, fully functional, working, secure, performant, and efficient code.
|
||||
- Focus on readability over performance, unless otherwise specified.
|
||||
- Fully implement all requested functionality.
|
||||
- Leave NO todos, placeholders, or missing pieces in your code.
|
||||
- Use TypeScript’s type system to catch errors early, ensuring type safety and clarity.
|
||||
- Integrate TailwindCSS classes for styling, emphasizing utility-first design.
|
||||
- Utilize PrimeVue components effectively, adhering to best practices for component-driven architecture.
|
||||
- Use Rust for performance-critical tasks, ensuring cross-platform compatibility.
|
||||
- Ensure seamless integration between Tauri, Rust, and Vue.js for a smooth desktop experience.
|
||||
- Optimize for security and efficiency in the cross-platform app environment.
|
||||
- Be concise. Minimize any unnecessary prose in your explanations.
|
||||
- If there might not be a correct answer, state so. If you do not know the answer, admit it instead of guessing.
|
||||
- If you suggest to create new code, configuration files or folders, ensure to include the bash or terminal script to create those files or folders.
|
||||
17015
LLM_CONTEXTS/llms-full-tauri.txt
Normal file
28074
LLM_CONTEXTS/llms-full-vue.txt
Normal file
43673
LLM_CONTEXTS/llms-full.txt
Normal file
10378
LLM_CONTEXTS/sakaifrontend.txt
Normal file
7801
LLM_CONTEXTS/web-llm.txt
Normal file
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Tauri + Vue + TypeScript
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
21
index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!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 + Vue + Typescript App</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "trainhubapp",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mediapipe/pose": "^0.5.1675469404",
|
||||
"@mlc-ai/web-llm": "^0.2.80",
|
||||
"@primeuix/themes": "^2.0.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-http": "^2.5.6",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.0.0",
|
||||
"marked": "^17.0.1",
|
||||
"primeflex": "^4.0.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.5.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"sass": "^1.97.3",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
6
public/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
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 |
34
scripts/health.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
console.log("Checking project health...");
|
||||
|
||||
// 1. Check for valid package.json
|
||||
if (!fs.existsSync('package.json')) {
|
||||
console.error("❌ package.json not found!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Check source directory structure
|
||||
const requiredDirs = [
|
||||
'src/app',
|
||||
'src/app/services',
|
||||
'src/app/components',
|
||||
'src/app/pages',
|
||||
'src/app/config.ts'
|
||||
];
|
||||
|
||||
let allDirsExist = true;
|
||||
requiredDirs.forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
console.error(`❌ Missing directory: ${dir}`);
|
||||
allDirsExist = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!allDirsExist) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("✅ Project structure looks correct.");
|
||||
console.log("✅ Health check passed!");
|
||||
7
src-tauri/.gitignore
vendored
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
|
||||
7
src-tauri/2
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
up to date, audited 97 packages in 980ms
|
||||
|
||||
12 packages are looking for funding
|
||||
run `npm fund` for details
|
||||
|
||||
found 0 vulnerabilities
|
||||
5956
src-tauri/Cargo.lock
generated
Normal file
35
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "trainhubapp"
|
||||
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
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "trainhubapp_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rfd = "0.15"
|
||||
tauri-plugin-fs = "2"
|
||||
r2d2 = "0.8"
|
||||
r2d2_sqlite = "0.25"
|
||||
thiserror = "1"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
78
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"fs:default",
|
||||
"fs:allow-applocaldata-write",
|
||||
"fs:allow-applocaldata-write-recursive",
|
||||
"fs:allow-applocaldata-read",
|
||||
"fs:allow-applocaldata-read-recursive",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-create",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
"$VIDEO/**",
|
||||
"$DESKTOP/**",
|
||||
"$DOWNLOADS/**",
|
||||
"$DOCUMENTS/**",
|
||||
"$APP_LOCAL_DATA/**",
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"shell:allow-kill",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [
|
||||
{
|
||||
"name": "llama-server",
|
||||
"cmd": "",
|
||||
"sidecar": true,
|
||||
"args": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "shell:allow-spawn",
|
||||
"allow": [
|
||||
{
|
||||
"name": "llama-server",
|
||||
"cmd": "",
|
||||
"sidecar": true,
|
||||
"args": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"http:default",
|
||||
{
|
||||
"identifier": "http:allow-fetch",
|
||||
"allow": [
|
||||
{
|
||||
"url": "http://127.0.0.1:8080/*"
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:8081/*"
|
||||
},
|
||||
{
|
||||
"url": "https://huggingface.co/**"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn-lfs.huggingface.co/**"
|
||||
},
|
||||
{
|
||||
"url": "https://*.huggingface.co/**"
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/**"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
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 |
66
src-tauri/knowledge/Dieta Ketogeniczna.md
Normal file
@@ -0,0 +1,66 @@
|
||||
\# Kompendium Diety Ketogenicznej (Keto)
|
||||
|
||||
|
||||
|
||||
\## Czym jest dieta keto?
|
||||
|
||||
Dieta ketogeniczna to model żywienia o wysokiej zawartości tłuszczu, umiarkowanej ilości białka i bardzo niskiej zawartości węglowodanów. Głównym celem jest wprowadzenie organizmu w stan \*\*ketozy\*\*, w którym ciało spala tłuszcz jako główne źródło energii zamiast glukozy.
|
||||
|
||||
|
||||
|
||||
\## Główne zasady (Makroskładniki)
|
||||
|
||||
Standardowy rozkład makroskładników na diecie keto wygląda następująco:
|
||||
|
||||
\- \*\*Tłuszcze:\*\* 70-75% dziennego zapotrzebowania kalorycznego.
|
||||
|
||||
\- \*\*Białko:\*\* 20-25%.
|
||||
|
||||
\- \*\*Węglowodany:\*\* 5-10% (zazwyczaj poniżej 20-50g węglowodanów netto dziennie).
|
||||
|
||||
|
||||
|
||||
\## Co jeść na diecie keto? (Lista zakupów)
|
||||
|
||||
1\. \*\*Zdrowe tłuszcze:\*\* Oliwa z oliwek, olej kokosowy, masło klarowane, awokado.
|
||||
|
||||
2\. \*\*Mięso i ryby:\*\* Wołowina, boczek, drób, tłuste ryby (łosoś, makrela).
|
||||
|
||||
3\. \*\*Nabiał:\*\* Tłuste sery, śmietana 30%, jajka.
|
||||
|
||||
4\. \*\*Warzywa niskowęglowodanowe:\*\* Szpinak, jarmuż, brokuły, kalafior, cukinia.
|
||||
|
||||
5\. \*\*Orzechy i nasiona:\*\* Orzechy włoskie, makadamia, nasiona chia, pestki dyni.
|
||||
|
||||
|
||||
|
||||
\## Produkty zakazane
|
||||
|
||||
\- Cukier i słodycze.
|
||||
|
||||
\- Produkty mączne (chleb, makaron, ciasta).
|
||||
|
||||
\- Owoce o wysokiej zawartości cukru (banany, winogrona, jabłka).
|
||||
|
||||
\- Warzywa skrobiowe (ziemniaki, bataty).
|
||||
|
||||
\- Rośliny strączkowe (fasola, groch).
|
||||
|
||||
|
||||
|
||||
\## Korzyści zdrowotne
|
||||
|
||||
\- Szybsza utrata tkanki tłuszczowej.
|
||||
|
||||
\- Stabilizacja poziomu cukru we krwi i insuliny.
|
||||
|
||||
\- Poprawa koncentracji i jasności umysłu.
|
||||
|
||||
\- Zmniejszenie uczucia głodu.
|
||||
|
||||
|
||||
|
||||
\## Ważna uwaga
|
||||
|
||||
Przed rozpoczęciem diety ketogenicznej zaleca się konsultację z lekarzem lub dietetykiem, szczególnie w przypadku osób z chorobami nerek, wątroby lub cukrzycą typu 1.
|
||||
|
||||
1
src-tauri/knowledge_base.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
92
src-tauri/src/commands/analysis.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use crate::db::DbPool;
|
||||
use crate::engine::analysis; // Import engine
|
||||
use crate::error::CommandError;
|
||||
use crate::models::{AnalysisSession, Annotation};
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_analysis_sessions(
|
||||
state: State<'_, DbPool>,
|
||||
) -> Result<Vec<AnalysisSession>, CommandError> {
|
||||
let conn = state.get()?;
|
||||
analysis::get_sessions(&conn)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_analysis_session(
|
||||
state: State<'_, DbPool>,
|
||||
name: String,
|
||||
video_path: Option<String>,
|
||||
) -> Result<AnalysisSession, CommandError> {
|
||||
let conn = state.get()?;
|
||||
analysis::create_session(&conn, &name, video_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_analysis_session(state: State<'_, DbPool>, id: String) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
analysis::delete_session(&conn, &id)
|
||||
}
|
||||
|
||||
// --- Annotations ---
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_annotations(
|
||||
state: State<'_, DbPool>,
|
||||
session_id: String,
|
||||
) -> Result<Vec<Annotation>, CommandError> {
|
||||
let conn = state.get()?;
|
||||
analysis::get_annotations(&conn, &session_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_annotation(
|
||||
state: State<'_, DbPool>,
|
||||
session_id: String,
|
||||
start_time: f64,
|
||||
end_time: f64,
|
||||
name: String,
|
||||
description: String,
|
||||
color: String,
|
||||
) -> Result<Annotation, CommandError> {
|
||||
let conn = state.get()?;
|
||||
analysis::add_annotation(
|
||||
&conn,
|
||||
&session_id,
|
||||
start_time,
|
||||
end_time,
|
||||
&name,
|
||||
&description,
|
||||
&color,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_annotation(
|
||||
state: State<'_, DbPool>,
|
||||
annotation: Annotation,
|
||||
) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
analysis::update_annotation(&conn, &annotation)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_annotation(state: State<'_, DbPool>, id: String) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
analysis::delete_annotation(&conn, &id)
|
||||
}
|
||||
|
||||
// --- Utils (Video Picker) ---
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pick_video_file() -> Result<Option<String>, CommandError> {
|
||||
let file = rfd::AsyncFileDialog::new()
|
||||
.add_filter("Video", &["mp4", "mov", "avi", "mkv", "webm"])
|
||||
.pick_file()
|
||||
.await;
|
||||
|
||||
match file {
|
||||
Some(f) => Ok(Some(f.path().to_string_lossy().to_string())),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
56
src-tauri/src/commands/chat.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use crate::db::DbPool;
|
||||
use crate::engine::chat;
|
||||
use crate::error::CommandError;
|
||||
use crate::models::{ChatMessage, ChatSession};
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_chat_session(
|
||||
state: State<'_, DbPool>,
|
||||
title: Option<String>,
|
||||
) -> Result<ChatSession, CommandError> {
|
||||
let conn = state.get()?;
|
||||
chat::create_session(&conn, title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_chat_sessions(state: State<'_, DbPool>) -> Result<Vec<ChatSession>, CommandError> {
|
||||
let conn = state.get()?;
|
||||
chat::get_sessions(&conn)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_chat_title(
|
||||
state: State<'_, DbPool>,
|
||||
id: String,
|
||||
title: String,
|
||||
) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
chat::update_title(&conn, &id, &title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_chat_session(state: State<'_, DbPool>, id: String) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
chat::delete_session(&conn, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_chat_message(
|
||||
state: State<'_, DbPool>,
|
||||
session_id: String,
|
||||
role: String,
|
||||
content: String,
|
||||
) -> Result<ChatMessage, CommandError> {
|
||||
let conn = state.get()?;
|
||||
chat::save_message(&conn, &session_id, &role, &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_chat_history(
|
||||
state: State<'_, DbPool>,
|
||||
session_id: String,
|
||||
) -> Result<Vec<ChatMessage>, CommandError> {
|
||||
let conn = state.get()?;
|
||||
chat::get_history(&conn, &session_id)
|
||||
}
|
||||
72
src-tauri/src/commands/exercises.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use crate::db::DbPool;
|
||||
use crate::error::CommandError;
|
||||
use crate::models::Exercise;
|
||||
use rusqlite::params;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_exercises(state: State<'_, DbPool>) -> Result<Vec<Exercise>, CommandError> {
|
||||
let conn = state.get()?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, name, instructions, enrichment, tags, video_url FROM exercises")?;
|
||||
let exercise_iter = stmt.query_map([], |row| {
|
||||
Ok(Exercise {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
instructions: row.get(2)?,
|
||||
enrichment: row.get(3)?,
|
||||
tags: row.get(4)?,
|
||||
video_url: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut exercises = Vec::new();
|
||||
for exercise in exercise_iter {
|
||||
exercises.push(exercise?);
|
||||
}
|
||||
Ok(exercises)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_exercise(
|
||||
state: State<'_, DbPool>,
|
||||
name: String,
|
||||
instructions: String,
|
||||
enrichment: String,
|
||||
tags: String,
|
||||
video_url: String,
|
||||
) -> Result<Exercise, CommandError> {
|
||||
let conn = state.get()?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO exercises (id, name, instructions, enrichment, tags, video_url) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![id, name, instructions, enrichment, tags, video_url],
|
||||
)?;
|
||||
|
||||
Ok(Exercise {
|
||||
id,
|
||||
name,
|
||||
instructions,
|
||||
enrichment,
|
||||
tags,
|
||||
video_url,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_exercise(state: State<'_, DbPool>, exercise: Exercise) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
conn.execute(
|
||||
"UPDATE exercises SET name = ?1, instructions = ?2, enrichment = ?3, tags = ?4, video_url = ?5 WHERE id = ?6",
|
||||
params![exercise.name, exercise.instructions, exercise.enrichment, exercise.tags, exercise.video_url, exercise.id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_exercise(state: State<'_, DbPool>, id: String) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
conn.execute("DELETE FROM exercises WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
5
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod analysis;
|
||||
pub mod chat;
|
||||
pub mod exercises;
|
||||
pub mod plans;
|
||||
pub mod programs;
|
||||
61
src-tauri/src/commands/plans.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use crate::db::DbPool;
|
||||
use crate::error::CommandError;
|
||||
use crate::models::TrainingPlan;
|
||||
use rusqlite::params;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_plans(state: State<'_, DbPool>) -> Result<Vec<TrainingPlan>, CommandError> {
|
||||
println!("get_plans called");
|
||||
let conn = state.get()?;
|
||||
let mut stmt = conn.prepare("SELECT id, name, sections FROM training_plans")?;
|
||||
let plan_iter = stmt.query_map([], |row| {
|
||||
Ok(TrainingPlan {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
sections: row.get(2)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut plans = Vec::new();
|
||||
for plan in plan_iter {
|
||||
plans.push(plan?);
|
||||
}
|
||||
println!("get_plans returning {} plans", plans.len());
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_plan(
|
||||
state: State<'_, DbPool>,
|
||||
name: String,
|
||||
sections: String,
|
||||
) -> Result<TrainingPlan, CommandError> {
|
||||
println!("create_plan called: {}", name);
|
||||
let conn = state.get()?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO training_plans (id, name, sections) VALUES (?1, ?2, ?3)",
|
||||
params![id, name, sections],
|
||||
)?;
|
||||
println!("create_plan inserted id: {}", id);
|
||||
Ok(TrainingPlan { id, name, sections })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_plan(state: State<'_, DbPool>, plan: TrainingPlan) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
conn.execute(
|
||||
"UPDATE training_plans SET name = ?1, sections = ?2 WHERE id = ?3",
|
||||
params![plan.name, plan.sections, plan.id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_plan(state: State<'_, DbPool>, id: String) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
conn.execute("DELETE FROM training_plans WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
112
src-tauri/src/commands/programs.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use crate::db::DbPool;
|
||||
use crate::engine::programs; // Import engine
|
||||
use crate::error::CommandError;
|
||||
use crate::models::{Program, Week, Workout};
|
||||
use tauri::State;
|
||||
|
||||
// --- Programs ---
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_programs(state: State<'_, DbPool>) -> Result<Vec<Program>, CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::get_programs(&conn)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_program(state: State<'_, DbPool>, name: String) -> Result<Program, CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::create_program(&conn, &name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_program(state: State<'_, DbPool>, id: String) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::delete_program(&conn, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn duplicate_program(state: State<'_, DbPool>, id: String) -> Result<Program, CommandError> {
|
||||
let mut conn = state.get()?;
|
||||
programs::duplicate_program(&mut conn, &id)
|
||||
}
|
||||
|
||||
// --- Weeks ---
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_weeks(state: State<'_, DbPool>, program_id: String) -> Result<Vec<Week>, CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::get_weeks(&conn, &program_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_week(
|
||||
state: State<'_, DbPool>,
|
||||
program_id: String,
|
||||
position: i32,
|
||||
) -> Result<Week, CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::add_week(&conn, &program_id, position)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_week_note(
|
||||
state: State<'_, DbPool>,
|
||||
id: String,
|
||||
notes: String,
|
||||
) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::update_week_note(&conn, &id, ¬es)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_week(state: State<'_, DbPool>, id: String) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::delete_week(&conn, &id)
|
||||
}
|
||||
|
||||
// --- Workouts ---
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_workouts(
|
||||
state: State<'_, DbPool>,
|
||||
program_id: String,
|
||||
) -> Result<Vec<Workout>, CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::get_workouts(&conn, &program_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_workout(
|
||||
state: State<'_, DbPool>,
|
||||
week_id: String,
|
||||
program_id: String,
|
||||
day: String,
|
||||
type_: String,
|
||||
ref_id: Option<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
) -> Result<Workout, CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::add_workout(
|
||||
&conn,
|
||||
&week_id,
|
||||
&program_id,
|
||||
&day,
|
||||
&type_,
|
||||
ref_id,
|
||||
&name,
|
||||
&description,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_workout(state: State<'_, DbPool>, workout: Workout) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::update_workout(&conn, &workout)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_workout(state: State<'_, DbPool>, id: String) -> Result<(), CommandError> {
|
||||
let conn = state.get()?;
|
||||
programs::delete_workout(&conn, &id)
|
||||
}
|
||||
166
src-tauri/src/db.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use std::fs;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::error::CommandError;
|
||||
|
||||
pub type DbPool = Pool<SqliteConnectionManager>;
|
||||
|
||||
pub fn init_db(app_handle: &AppHandle) -> Result<DbPool, CommandError> {
|
||||
let app_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| CommandError::Internal(e.to_string()))?;
|
||||
|
||||
if !app_dir.exists() {
|
||||
fs::create_dir_all(&app_dir)?;
|
||||
}
|
||||
|
||||
let db_path = app_dir.join("trainhub.db");
|
||||
println!("Database path: {:?}", db_path);
|
||||
let manager = SqliteConnectionManager::file(&db_path);
|
||||
let pool = Pool::new(manager)?;
|
||||
|
||||
let conn = pool.get()?;
|
||||
|
||||
// PRAGMA settings for performance/integrity
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])?;
|
||||
// WAL mode removed to ensure data persistence stability in dev environment
|
||||
|
||||
// --- Migrations ---
|
||||
// In a real app, use a migration tool like `refinery` or `sqlx`.
|
||||
// For now, we keep the idempotent "CREATE TABLE IF NOT EXISTS" approach.
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS exercises (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
instructions TEXT,
|
||||
enrichment TEXT,
|
||||
tags TEXT,
|
||||
video_url TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS training_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
sections TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS programs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS program_weeks (
|
||||
id TEXT PRIMARY KEY,
|
||||
program_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
FOREIGN KEY(program_id) REFERENCES programs(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS program_workouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
week_id TEXT NOT NULL,
|
||||
program_id TEXT NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
ref_id TEXT,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
completed BOOLEAN DEFAULT 0,
|
||||
FOREIGN KEY(week_id) REFERENCES program_weeks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(program_id) REFERENCES programs(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS analysis_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
video_path TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
start_time REAL NOT NULL,
|
||||
end_time REAL NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
color TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES analysis_sessions(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS flashcard_sets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS flashcards (
|
||||
id TEXT PRIMARY KEY,
|
||||
set_id TEXT,
|
||||
front TEXT NOT NULL,
|
||||
back TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(set_id) REFERENCES flashcard_sets(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Primitive Migration: Check if column exists, if not ignore error or handle gracefully
|
||||
// Since sqlite doesn't support IF NOT EXISTS for columns easily without logic:
|
||||
// We try to add it, if it fails (duplicate column), we ignore.
|
||||
let _ = conn.execute("ALTER TABLE flashcards ADD COLUMN set_id TEXT", []);
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
112
src-tauri/src/engine/analysis.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use crate::error::CommandError;
|
||||
use crate::models::{AnalysisSession, Annotation};
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn get_sessions(conn: &Connection) -> Result<Vec<AnalysisSession>, CommandError> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, date, video_path FROM analysis_sessions ORDER BY date DESC")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(AnalysisSession {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
date: row.get(2)?,
|
||||
video_path: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for r in rows {
|
||||
sessions.push(r?);
|
||||
}
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
pub fn create_session(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
video_path: Option<String>,
|
||||
) -> Result<AnalysisSession, CommandError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let date = chrono::Local::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO analysis_sessions (id, name, date, video_path) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, name, date, video_path],
|
||||
)?;
|
||||
|
||||
Ok(AnalysisSession {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
date,
|
||||
video_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_session(conn: &Connection, id: &str) -> Result<(), CommandError> {
|
||||
conn.execute("DELETE FROM analysis_sessions WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_annotations(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
) -> Result<Vec<Annotation>, CommandError> {
|
||||
let mut stmt = conn.prepare("SELECT id, session_id, start_time, end_time, name, description, color FROM annotations WHERE session_id = ?1")?;
|
||||
let rows = stmt.query_map(params![session_id], |row| {
|
||||
Ok(Annotation {
|
||||
id: row.get(0)?,
|
||||
session_id: row.get(1)?,
|
||||
start_time: row.get(2)?,
|
||||
end_time: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
description: row.get(5)?,
|
||||
color: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut list = Vec::new();
|
||||
for r in rows {
|
||||
list.push(r?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
pub fn add_annotation(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
start_time: f64,
|
||||
end_time: f64,
|
||||
name: &str,
|
||||
description: &str,
|
||||
color: &str,
|
||||
) -> Result<Annotation, CommandError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO annotations (id, session_id, start_time, end_time, name, description, color) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![id, session_id, start_time, end_time, name, description, color],
|
||||
)?;
|
||||
|
||||
Ok(Annotation {
|
||||
id,
|
||||
session_id: session_id.to_string(),
|
||||
start_time,
|
||||
end_time,
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
color: color.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_annotation(conn: &Connection, annotation: &Annotation) -> Result<(), CommandError> {
|
||||
conn.execute(
|
||||
"UPDATE annotations SET start_time=?1, end_time=?2, name=?3, description=?4, color=?5 WHERE id=?6",
|
||||
params![annotation.start_time, annotation.end_time, annotation.name, annotation.description, annotation.color, annotation.id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_annotation(conn: &Connection, id: &str) -> Result<(), CommandError> {
|
||||
conn.execute("DELETE FROM annotations WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
106
src-tauri/src/engine/chat.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use crate::error::CommandError;
|
||||
use crate::models::{ChatMessage, ChatSession};
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn create_session(
|
||||
conn: &Connection,
|
||||
title: Option<String>,
|
||||
) -> Result<ChatSession, CommandError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Local::now().to_rfc3339();
|
||||
let title = title.unwrap_or_else(|| "Nowy czat".to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chat_sessions (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, title, now, now],
|
||||
)?;
|
||||
|
||||
Ok(ChatSession {
|
||||
id,
|
||||
title,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_sessions(conn: &Connection) -> Result<Vec<ChatSession>, CommandError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title, created_at, updated_at FROM chat_sessions ORDER BY updated_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(ChatSession {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for r in rows {
|
||||
sessions.push(r?);
|
||||
}
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
pub fn update_title(conn: &Connection, id: &str, title: &str) -> Result<(), CommandError> {
|
||||
conn.execute(
|
||||
"UPDATE chat_sessions SET title = ?1 WHERE id = ?2",
|
||||
params![title, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_session(conn: &Connection, id: &str) -> Result<(), CommandError> {
|
||||
conn.execute("DELETE FROM chat_sessions WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_message(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
role: &str,
|
||||
content: &str,
|
||||
) -> Result<ChatMessage, CommandError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Local::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![id, session_id, role, content, now],
|
||||
)?;
|
||||
|
||||
// Update session updated_at
|
||||
conn.execute(
|
||||
"UPDATE chat_sessions SET updated_at = ?1 WHERE id = ?2",
|
||||
params![now, session_id],
|
||||
)?;
|
||||
|
||||
Ok(ChatMessage {
|
||||
id,
|
||||
session_id: session_id.to_string(),
|
||||
role: role.to_string(),
|
||||
content: content.to_string(),
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_history(conn: &Connection, session_id: &str) -> Result<Vec<ChatMessage>, CommandError> {
|
||||
let mut stmt = conn.prepare("SELECT id, session_id, role, content, created_at FROM chat_messages WHERE session_id = ?1 ORDER BY created_at ASC")?;
|
||||
let rows = stmt.query_map(params![session_id], |row| {
|
||||
Ok(ChatMessage {
|
||||
id: row.get(0)?,
|
||||
session_id: row.get(1)?,
|
||||
role: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for r in rows {
|
||||
messages.push(r?);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
3
src-tauri/src/engine/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod analysis;
|
||||
pub mod chat;
|
||||
pub mod programs;
|
||||
240
src-tauri/src/engine/programs.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
use crate::error::CommandError;
|
||||
use crate::models::{Program, Week, Workout};
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- Programs ---
|
||||
|
||||
pub fn get_programs(conn: &Connection) -> Result<Vec<Program>, CommandError> {
|
||||
let mut stmt = conn.prepare("SELECT id, name, created_at FROM programs")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(Program {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut programs = Vec::new();
|
||||
for r in rows {
|
||||
programs.push(r?);
|
||||
}
|
||||
Ok(programs)
|
||||
}
|
||||
|
||||
pub fn create_program(conn: &Connection, name: &str) -> Result<Program, CommandError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let created_at = chrono::Local::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO programs (id, name, created_at) VALUES (?1, ?2, ?3)",
|
||||
params![id, name, created_at],
|
||||
)?;
|
||||
|
||||
Ok(Program {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_program(conn: &Connection, id: &str) -> Result<(), CommandError> {
|
||||
conn.execute("DELETE FROM programs WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn duplicate_program(conn: &mut Connection, id: &str) -> Result<Program, CommandError> {
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
// 1. Get original program
|
||||
let mut stmt = tx.prepare("SELECT name FROM programs WHERE id = ?1")?;
|
||||
let original_name: String = stmt.query_row(params![id], |row| row.get(0))?;
|
||||
drop(stmt); // Close statement to release borrow
|
||||
|
||||
// 2. Create new program
|
||||
let new_program_id = Uuid::new_v4().to_string();
|
||||
let new_name = format!("{} (Kopia)", original_name);
|
||||
let created_at = chrono::Local::now().to_rfc3339();
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO programs (id, name, created_at) VALUES (?1, ?2, ?3)",
|
||||
params![new_program_id, new_name, created_at],
|
||||
)?;
|
||||
|
||||
// 3. Get original weeks
|
||||
let mut stmt_weeks =
|
||||
tx.prepare("SELECT id, position, notes FROM program_weeks WHERE program_id = ?1")?;
|
||||
let weeks_rows = stmt_weeks.query_map(params![id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?, // id
|
||||
row.get::<_, i32>(1)?, // position
|
||||
row.get::<_, String>(2)?, // notes
|
||||
))
|
||||
})?;
|
||||
|
||||
// Collect into vec to avoid borrow issues with stmt_weeks while inserting
|
||||
let mut weeks_data = Vec::new();
|
||||
for w in weeks_rows {
|
||||
weeks_data.push(w?);
|
||||
}
|
||||
drop(stmt_weeks);
|
||||
|
||||
// 4. Clone weeks and their workouts
|
||||
for (old_week_id, position, notes) in weeks_data {
|
||||
let new_week_id = Uuid::new_v4().to_string();
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO program_weeks (id, program_id, position, notes) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![new_week_id, new_program_id, position, notes],
|
||||
)?;
|
||||
|
||||
// Clone workouts for this week
|
||||
let mut stmt_workouts = tx.prepare(
|
||||
"SELECT day, type, ref_id, name, description FROM program_workouts WHERE week_id = ?1",
|
||||
)?;
|
||||
let workouts_rows = stmt_workouts.query_map(params![old_week_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?, // day
|
||||
row.get::<_, String>(1)?, // type
|
||||
row.get::<_, Option<String>>(2)?, // ref_id
|
||||
row.get::<_, String>(3)?, // name
|
||||
row.get::<_, String>(4)?, // description
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut workouts_data = Vec::new();
|
||||
for wo in workouts_rows {
|
||||
workouts_data.push(wo?);
|
||||
}
|
||||
drop(stmt_workouts);
|
||||
|
||||
for (day, type_, ref_id, name, description) in workouts_data {
|
||||
let new_workout_id = Uuid::new_v4().to_string();
|
||||
tx.execute(
|
||||
"INSERT INTO program_workouts (id, week_id, program_id, day, type, ref_id, name, description, completed) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0)",
|
||||
params![new_workout_id, new_week_id, new_program_id, day, type_, ref_id, name, description],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
Ok(Program {
|
||||
id: new_program_id,
|
||||
name: new_name,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Weeks ---
|
||||
|
||||
pub fn get_weeks(conn: &Connection, program_id: &str) -> Result<Vec<Week>, CommandError> {
|
||||
let mut stmt = conn.prepare("SELECT id, program_id, position, notes FROM program_weeks WHERE program_id = ?1 ORDER BY position ASC")?;
|
||||
let rows = stmt.query_map(params![program_id], |row| {
|
||||
Ok(Week {
|
||||
id: row.get(0)?,
|
||||
program_id: row.get(1)?,
|
||||
position: row.get(2)?,
|
||||
notes: row.get(3).unwrap_or_default(),
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut weeks = Vec::new();
|
||||
for r in rows {
|
||||
weeks.push(r?);
|
||||
}
|
||||
Ok(weeks)
|
||||
}
|
||||
|
||||
pub fn add_week(conn: &Connection, program_id: &str, position: i32) -> Result<Week, CommandError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO program_weeks (id, program_id, position, notes) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, program_id, position, ""],
|
||||
)?;
|
||||
Ok(Week {
|
||||
id,
|
||||
program_id: program_id.to_string(),
|
||||
position,
|
||||
notes: "".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_week_note(conn: &Connection, id: &str, notes: &str) -> Result<(), CommandError> {
|
||||
conn.execute(
|
||||
"UPDATE program_weeks SET notes = ?1 WHERE id = ?2",
|
||||
params![notes, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_week(conn: &Connection, id: &str) -> Result<(), CommandError> {
|
||||
conn.execute("DELETE FROM program_weeks WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Workouts ---
|
||||
|
||||
pub fn get_workouts(conn: &Connection, program_id: &str) -> Result<Vec<Workout>, CommandError> {
|
||||
let mut stmt = conn.prepare("SELECT id, week_id, program_id, day, type, ref_id, name, description, completed FROM program_workouts WHERE program_id = ?1")?;
|
||||
let rows = stmt.query_map(params![program_id], |row| {
|
||||
Ok(Workout {
|
||||
id: row.get(0)?,
|
||||
week_id: row.get(1)?,
|
||||
program_id: row.get(2)?,
|
||||
day: row.get(3)?,
|
||||
type_: row.get(4)?,
|
||||
ref_id: row.get(5)?,
|
||||
name: row.get(6)?,
|
||||
description: row.get(7)?,
|
||||
completed: row.get(8)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut workouts = Vec::new();
|
||||
for r in rows {
|
||||
workouts.push(r?);
|
||||
}
|
||||
Ok(workouts)
|
||||
}
|
||||
|
||||
pub fn add_workout(
|
||||
conn: &Connection,
|
||||
week_id: &str,
|
||||
program_id: &str,
|
||||
day: &str,
|
||||
type_: &str,
|
||||
ref_id: Option<String>,
|
||||
name: &str,
|
||||
description: &str,
|
||||
) -> Result<Workout, CommandError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO program_workouts (id, week_id, program_id, day, type, ref_id, name, description, completed) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0)",
|
||||
params![id, week_id, program_id, day, type_, ref_id, name, description],
|
||||
)?;
|
||||
Ok(Workout {
|
||||
id,
|
||||
week_id: week_id.to_string(),
|
||||
program_id: program_id.to_string(),
|
||||
day: day.to_string(),
|
||||
type_: type_.to_string(),
|
||||
ref_id,
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
completed: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_workout(conn: &Connection, workout: &Workout) -> Result<(), CommandError> {
|
||||
conn.execute(
|
||||
"UPDATE program_workouts SET day = ?1, week_id = ?2, type = ?3, ref_id = ?4, name = ?5, description = ?6, completed = ?7 WHERE id = ?8",
|
||||
params![workout.day, workout.week_id, workout.type_, workout.ref_id, workout.name, workout.description, workout.completed, workout.id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_workout(conn: &Connection, id: &str) -> Result<(), CommandError> {
|
||||
conn.execute("DELETE FROM program_workouts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
33
src-tauri/src/error.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CommandError {
|
||||
#[error("Database error: {0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
|
||||
#[error("Pool error: {0}")]
|
||||
Pool(#[from] r2d2::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl Serialize for CommandError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::ser::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to convert generic strings to CommandError
|
||||
impl From<String> for CommandError {
|
||||
fn from(s: String) -> Self {
|
||||
CommandError::Internal(s)
|
||||
}
|
||||
}
|
||||
65
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
mod commands;
|
||||
mod db;
|
||||
mod engine;
|
||||
mod error;
|
||||
mod models;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
// Database init
|
||||
let pool = db::init_db(app.handle())?;
|
||||
app.manage(pool);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Exercises
|
||||
commands::exercises::get_exercises,
|
||||
commands::exercises::add_exercise,
|
||||
commands::exercises::update_exercise,
|
||||
commands::exercises::delete_exercise,
|
||||
// Plans
|
||||
commands::plans::get_plans,
|
||||
commands::plans::create_plan,
|
||||
commands::plans::update_plan,
|
||||
commands::plans::delete_plan,
|
||||
// Programs
|
||||
commands::programs::get_programs,
|
||||
commands::programs::create_program,
|
||||
commands::programs::duplicate_program,
|
||||
commands::programs::delete_program,
|
||||
commands::programs::get_weeks,
|
||||
commands::programs::add_week,
|
||||
commands::programs::update_week_note,
|
||||
commands::programs::delete_week,
|
||||
commands::programs::get_workouts,
|
||||
commands::programs::add_workout,
|
||||
commands::programs::update_workout,
|
||||
commands::programs::delete_workout,
|
||||
// Analysis
|
||||
commands::analysis::get_analysis_sessions,
|
||||
commands::analysis::create_analysis_session,
|
||||
commands::analysis::delete_analysis_session,
|
||||
commands::analysis::get_annotations,
|
||||
commands::analysis::add_annotation,
|
||||
commands::analysis::update_annotation,
|
||||
commands::analysis::delete_annotation,
|
||||
commands::analysis::pick_video_file, // Video Picker
|
||||
// Chat
|
||||
commands::chat::create_chat_session,
|
||||
commands::chat::get_chat_sessions,
|
||||
commands::chat::update_chat_title,
|
||||
commands::chat::delete_chat_session,
|
||||
commands::chat::save_chat_message,
|
||||
commands::chat::get_chat_history
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
trainhubapp_lib::run()
|
||||
}
|
||||
110
src-tauri/src/models.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Exercise {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub instructions: String,
|
||||
pub enrichment: String,
|
||||
pub tags: String, // stored as JSON array string
|
||||
pub video_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrainingPlan {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub sections: String, // stored as JSON String
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Program {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Week {
|
||||
pub id: String,
|
||||
pub program_id: String,
|
||||
pub position: i32,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Workout {
|
||||
pub id: String,
|
||||
pub week_id: String,
|
||||
pub program_id: String,
|
||||
pub day: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String, // "exercise" | "plan" (mapped from 'type' in DB)
|
||||
pub ref_id: Option<String>,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisSession {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub date: String,
|
||||
pub video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Annotation {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub start_time: f64,
|
||||
pub end_time: f64,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Flashcard {
|
||||
pub id: String,
|
||||
pub set_id: String,
|
||||
pub front: String,
|
||||
pub back: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlashcardSet {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatSession {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
50
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "trainhubapp",
|
||||
"version": "0.1.0",
|
||||
"identifier": "pl.kciolek.trainhub",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "trainhubapp",
|
||||
"maximized": true,
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' asset: http://asset.localhost https://asset.localhost http://ipc.localhost http://localhost:1420 ws://localhost:1420 blob: data: https://huggingface.co https://cdn-lfs.huggingface.co https://*.huggingface.co https://raw.githubusercontent.com http://127.0.0.1:8080; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://ipc.localhost http://localhost:1420 ws://localhost:1420 asset: http://asset.localhost https://asset.localhost https://huggingface.co https://cdn-lfs.huggingface.co https://*.huggingface.co https://raw.githubusercontent.com https://cas-bridge.xethub.hf.co https://*.hf.co http://127.0.0.1:8080",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"externalBin": [
|
||||
"llama-server"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [
|
||||
"*.dll"
|
||||
]
|
||||
}
|
||||
}
|
||||
7
src/app/App.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import MainLayout from './layouts/MainLayout.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainLayout />
|
||||
</template>
|
||||
183
src/app/api/index.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type {
|
||||
Exercise,
|
||||
TrainingPlan,
|
||||
Program,
|
||||
Week,
|
||||
Workout,
|
||||
AnalysisSession,
|
||||
Annotation,
|
||||
FlashcardSet,
|
||||
Flashcard,
|
||||
ChatSession,
|
||||
ChatMessage
|
||||
} from '../types';
|
||||
|
||||
export const api = {
|
||||
// Exercises
|
||||
getExercises: async (): Promise<Exercise[]> => {
|
||||
return await invoke('get_exercises');
|
||||
},
|
||||
addExercise: async (exercise: Omit<Exercise, 'id'>): Promise<Exercise> => {
|
||||
return await invoke('add_exercise', {
|
||||
name: exercise.name,
|
||||
instructions: exercise.instructions,
|
||||
enrichment: exercise.enrichment,
|
||||
tags: exercise.tags,
|
||||
videoUrl: exercise.videoUrl
|
||||
});
|
||||
},
|
||||
updateExercise: async (exercise: Exercise): Promise<void> => {
|
||||
await invoke('update_exercise', { exercise });
|
||||
},
|
||||
deleteExercise: async (id: string): Promise<void> => {
|
||||
await invoke('delete_exercise', { id });
|
||||
},
|
||||
|
||||
// Plans
|
||||
getPlans: async (): Promise<TrainingPlan[]> => {
|
||||
return await invoke('get_plans');
|
||||
},
|
||||
createPlan: async (name: string, sections: string): Promise<TrainingPlan> => {
|
||||
return await invoke('create_plan', { name, sections });
|
||||
},
|
||||
updatePlan: async (plan: TrainingPlan): Promise<void> => {
|
||||
await invoke('update_plan', { plan });
|
||||
},
|
||||
deletePlan: async (id: string): Promise<void> => {
|
||||
await invoke('delete_plan', { id });
|
||||
},
|
||||
|
||||
// Programs
|
||||
getPrograms: async (): Promise<Program[]> => {
|
||||
return await invoke('get_programs');
|
||||
},
|
||||
createProgram: async (name: string): Promise<Program> => {
|
||||
return await invoke('create_program', { name });
|
||||
},
|
||||
deleteProgram: async (id: string): Promise<void> => {
|
||||
await invoke('delete_program', { id });
|
||||
},
|
||||
duplicateProgram: async (id: string): Promise<Program> => {
|
||||
return await invoke('duplicate_program', { id });
|
||||
},
|
||||
|
||||
// Weeks
|
||||
getWeeks: async (programId: string): Promise<Week[]> => {
|
||||
return await invoke('get_weeks', { programId });
|
||||
},
|
||||
addWeek: async (programId: string, position: number): Promise<Week> => {
|
||||
return await invoke('add_week', { programId, position });
|
||||
},
|
||||
updateWeekNote: async (id: string, notes: string): Promise<void> => {
|
||||
await invoke('update_week_note', { id, notes });
|
||||
},
|
||||
deleteWeek: async (id: string): Promise<void> => {
|
||||
await invoke('delete_week', { id });
|
||||
},
|
||||
|
||||
// Workouts
|
||||
getWorkouts: async (programId: string): Promise<Workout[]> => {
|
||||
return await invoke('get_workouts', { programId });
|
||||
},
|
||||
addWorkout: async (workout: Omit<Workout, 'id' | 'completed'>): Promise<Workout> => {
|
||||
return await invoke('add_workout', {
|
||||
weekId: workout.weekId,
|
||||
programId: workout.programId,
|
||||
day: workout.day,
|
||||
type_: workout.type, // Map 'type' to 'type_' arg for rust function
|
||||
refId: workout.refId,
|
||||
name: workout.name,
|
||||
description: workout.description
|
||||
});
|
||||
},
|
||||
updateWorkout: async (workout: Workout): Promise<void> => {
|
||||
// Rust struct maps "type" -> "type_" automatically via serde, so passing json with "type" is fine.
|
||||
// But wait, the update expects a Workout struct. Our TS Workout has 'type'.
|
||||
// Backend Workout struct has field `type_` with `#[serde(rename="type")]`.
|
||||
// So sending JSON `{ type: "..." }` will work.
|
||||
await invoke('update_workout', { workout });
|
||||
},
|
||||
deleteWorkout: async (id: string): Promise<void> => {
|
||||
await invoke('delete_workout', { id });
|
||||
},
|
||||
|
||||
// Analysis
|
||||
getAnalysisSessions: async (): Promise<AnalysisSession[]> => {
|
||||
return await invoke('get_analysis_sessions');
|
||||
},
|
||||
createAnalysisSession: async (name: string, videoPath: string | null): Promise<AnalysisSession> => {
|
||||
return await invoke('create_analysis_session', { name, videoPath });
|
||||
},
|
||||
deleteAnalysisSession: async (id: string): Promise<void> => {
|
||||
await invoke('delete_analysis_session', { id });
|
||||
},
|
||||
getAnnotations: async (sessionId: string): Promise<Annotation[]> => {
|
||||
return await invoke('get_annotations', { sessionId });
|
||||
},
|
||||
addAnnotation: async (annotation: Omit<Annotation, 'id'>): Promise<Annotation> => {
|
||||
return await invoke('add_annotation', {
|
||||
sessionId: annotation.sessionId,
|
||||
startTime: annotation.startTime,
|
||||
endTime: annotation.endTime,
|
||||
name: annotation.name,
|
||||
description: annotation.description,
|
||||
color: annotation.color
|
||||
});
|
||||
},
|
||||
updateAnnotation: async (annotation: Annotation): Promise<void> => {
|
||||
await invoke('update_annotation', { annotation });
|
||||
},
|
||||
deleteAnnotation: async (id: string): Promise<void> => {
|
||||
await invoke('delete_annotation', { id });
|
||||
},
|
||||
pickVideoFile: async (): Promise<string | null> => {
|
||||
return await invoke('pick_video_file');
|
||||
},
|
||||
|
||||
// Flashcards
|
||||
getFlashcardSets: async (): Promise<FlashcardSet[]> => {
|
||||
return await invoke('get_flashcard_sets');
|
||||
},
|
||||
createFlashcardSet: async (name: string): Promise<FlashcardSet> => {
|
||||
return await invoke('create_flashcard_set', { name });
|
||||
},
|
||||
updateFlashcardSet: async (id: string, name: string): Promise<void> => {
|
||||
return await invoke('update_flashcard_set', { id, name });
|
||||
},
|
||||
deleteFlashcardSet: async (id: string): Promise<void> => {
|
||||
return await invoke('delete_flashcard_set', { id });
|
||||
},
|
||||
getFlashcards: async (setId: string): Promise<Flashcard[]> => {
|
||||
return await invoke('get_flashcards', { setId });
|
||||
},
|
||||
createFlashcard: async (setId: string, front: string, back: string): Promise<Flashcard> => {
|
||||
return await invoke('create_flashcard', { setId, front, back });
|
||||
},
|
||||
updateFlashcard: async (id: string, front: string, back: string): Promise<void> => {
|
||||
return await invoke('update_flashcard', { id, front, back });
|
||||
},
|
||||
deleteFlashcard: async (id: string): Promise<void> => {
|
||||
return await invoke('delete_flashcard', { id });
|
||||
},
|
||||
|
||||
// Chat
|
||||
createChatSession: async (title?: string): Promise<ChatSession> => {
|
||||
return await invoke('create_chat_session', { title });
|
||||
},
|
||||
getChatSessions: async (): Promise<ChatSession[]> => {
|
||||
return await invoke('get_chat_sessions');
|
||||
},
|
||||
updateChatTitle: async (id: string, title: string): Promise<void> => {
|
||||
return await invoke('update_chat_title', { id, title });
|
||||
},
|
||||
deleteChatSession: async (id: string): Promise<void> => {
|
||||
return await invoke('delete_chat_session', { id });
|
||||
},
|
||||
saveChatMessage: async (sessionId: string, role: string, content: string): Promise<ChatMessage> => {
|
||||
return await invoke('save_chat_message', { sessionId, role, content });
|
||||
},
|
||||
getChatHistory: async (sessionId: string): Promise<ChatMessage[]> => {
|
||||
return await invoke('get_chat_history', { sessionId });
|
||||
}
|
||||
};
|
||||
74
src/app/assets/global.css
Normal file
@@ -0,0 +1,74 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--glass-bg: rgba(20, 20, 23, 0.65);
|
||||
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
background-color: #09090b;
|
||||
/* zinc-950/almost black */
|
||||
color: var(--text-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
letter-spacing: -0.025em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Glassmorphism Utilities */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 4px 24px -2px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #a1a1aa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Custom Selection */
|
||||
::selection {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
1
src/app/assets/vue.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="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
97
src/app/components/ActiveFlipCard.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
import type { Flashcard } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
card: Flashcard;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['answer']);
|
||||
|
||||
const isFlipped = ref(false);
|
||||
|
||||
// Reset flip state when card changes
|
||||
watch(() => props.card, () => {
|
||||
isFlipped.value = false;
|
||||
});
|
||||
|
||||
const submitAnswer = (isCorrect: boolean) => {
|
||||
emit('answer', isCorrect);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-column align-items-center gap-4">
|
||||
<!-- Card -->
|
||||
<div class="flashcard-container" @click="isFlipped = !isFlipped">
|
||||
<div class="flashcard-inner" :class="{ 'is-flipped': isFlipped }">
|
||||
<div class="flashcard-front">
|
||||
<div class="content">{{ card.front }}</div>
|
||||
<span class="text-sm text-500 absolute bottom-0 mb-3">Kliknij by odwrócić</span>
|
||||
</div>
|
||||
<div class="flashcard-back">
|
||||
<div class="content">{{ card.back }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex gap-4 h-3rem">
|
||||
<template v-if="isFlipped">
|
||||
<Button label="Nie wiem" severity="danger" icon="pi pi-times" @click.stop="submitAnswer(false)" />
|
||||
<Button label="Wiem" severity="success" icon="pi pi-check" @click.stop="submitAnswer(true)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flashcard-container {
|
||||
perspective: 1000px;
|
||||
width: 20rem;
|
||||
height: 14rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flashcard-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
transition: transform 0.6s;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.flashcard-inner.is-flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.flashcard-front, .flashcard-back {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backface-visibility: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||
padding: 2rem;
|
||||
background-color: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.flashcard-back {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
251
src/app/components/ActiveSpellingCard.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
import type { Flashcard } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
card: Flashcard;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['answer']);
|
||||
|
||||
// State using 2D array: words -> chars
|
||||
const charInputs = ref<string[][]>([]);
|
||||
const charInputRefs = ref<HTMLInputElement[]>([]);
|
||||
const isChecked = ref(false);
|
||||
const isCorrect = ref(false);
|
||||
|
||||
// Split answer into words and chars
|
||||
const expectedWords = computed(() => {
|
||||
return props.card.back.trim().split(/\s+/).filter(w => w.length > 0);
|
||||
});
|
||||
|
||||
// Flattened expected string for easy comparison
|
||||
const expectedString = computed(() => expectedWords.value.join('').toLowerCase());
|
||||
|
||||
const init = () => {
|
||||
// Initialize 2D array for inputs based on expected words length
|
||||
charInputs.value = expectedWords.value.map(word => new Array(word.length).fill(''));
|
||||
|
||||
isChecked.value = false;
|
||||
isCorrect.value = false;
|
||||
charInputRefs.value = []; // Reset refs
|
||||
|
||||
nextTick(() => {
|
||||
// Focus first input
|
||||
if (charInputRefs.value[0]) {
|
||||
charInputRefs.value[0].focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watch(() => props.card, init, { immediate: true });
|
||||
|
||||
// Handle character input
|
||||
const handleInput = (wordIdx: number, charIdx: number, event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const val = target.value;
|
||||
|
||||
// Use only the last character entered (if multiple pasted, or overwrite)
|
||||
if (val.length > 1) {
|
||||
charInputs.value[wordIdx][charIdx] = val.slice(-1);
|
||||
}
|
||||
|
||||
// Auto-advance
|
||||
if (val) {
|
||||
focusNext(wordIdx, charIdx);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (wordIdx: number, charIdx: number, event: KeyboardEvent) => {
|
||||
if (event.key === 'Backspace') {
|
||||
if (!charInputs.value[wordIdx][charIdx]) {
|
||||
// Check if we need to prevent default (browser back)
|
||||
event.preventDefault();
|
||||
focusPrev(wordIdx, charIdx);
|
||||
}
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
focusPrev(wordIdx, charIdx);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
focusNext(wordIdx, charIdx); // Don't skip filled
|
||||
} else if (event.key === 'Enter') {
|
||||
checkAnswer();
|
||||
}
|
||||
};
|
||||
|
||||
const focusNext = (wordIdx: number, charIdx: number) => {
|
||||
// Calculate flat index
|
||||
let flatIdx = 0;
|
||||
for (let i = 0; i < wordIdx; i++) flatIdx += expectedWords.value[i].length;
|
||||
flatIdx += charIdx;
|
||||
|
||||
const nextIdx = flatIdx + 1;
|
||||
if (nextIdx < charInputRefs.value.length) {
|
||||
charInputRefs.value[nextIdx].focus();
|
||||
}
|
||||
};
|
||||
|
||||
const focusPrev = (wordIdx: number, charIdx: number) => {
|
||||
// Calculate flat index
|
||||
let flatIdx = 0;
|
||||
for (let i = 0; i < wordIdx; i++) flatIdx += expectedWords.value[i].length;
|
||||
flatIdx += charIdx;
|
||||
|
||||
const prevIdx = flatIdx - 1;
|
||||
if (prevIdx >= 0) {
|
||||
charInputRefs.value[prevIdx].focus();
|
||||
}
|
||||
};
|
||||
|
||||
const checkAnswer = () => {
|
||||
if (isChecked.value) {
|
||||
nextCard();
|
||||
return;
|
||||
}
|
||||
|
||||
// Construct user answer
|
||||
const userString = charInputs.value.flat().join('').toLowerCase();
|
||||
|
||||
isCorrect.value = userString === expectedString.value;
|
||||
isChecked.value = true;
|
||||
};
|
||||
|
||||
const nextCard = () => {
|
||||
emit('answer', isCorrect.value);
|
||||
};
|
||||
|
||||
// Helper to get flat index for ref
|
||||
const setRef = (el: any, index: number) => {
|
||||
if (el) {
|
||||
charInputRefs.value[index] = el as HTMLInputElement;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat index calculator for template
|
||||
const getFlatIndex = (wordIdx: number, charIdx: number) => {
|
||||
let count = 0;
|
||||
for(let i=0; i<wordIdx; i++) count += expectedWords.value[i].length;
|
||||
return count + charIdx;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-column align-items-center gap-5 w-full max-w-40rem p-2">
|
||||
<!-- Question Card -->
|
||||
<div class="surface-card border-1 surface-border border-round-xl p-5 w-full text-center shadow-1">
|
||||
<span class="text-sm text-500 uppercase font-bold tracking-wide">Przetłumacz</span>
|
||||
<div class="text-3xl font-bold mt-3 text-900">{{ card.front }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Word Containers -->
|
||||
<div class="flex flex-wrap gap-4 justify-content-center w-full">
|
||||
<div
|
||||
v-for="(word, wordIdx) in expectedWords"
|
||||
:key="wordIdx"
|
||||
class="flex gap-2"
|
||||
>
|
||||
<!-- Character Inputs -->
|
||||
<div
|
||||
v-for="(char, charIdx) in word"
|
||||
:key="charIdx"
|
||||
class="relative"
|
||||
>
|
||||
<input
|
||||
:ref="(el) => setRef(el, getFlatIndex(wordIdx, charIdx))"
|
||||
v-model="charInputs[wordIdx][charIdx]"
|
||||
type="text"
|
||||
class="char-input"
|
||||
:class="{
|
||||
'is-correct': isChecked && charInputs[wordIdx][charIdx]?.toLowerCase() === char.toLowerCase(),
|
||||
'is-error': isChecked && charInputs[wordIdx][charIdx]?.toLowerCase() !== char.toLowerCase()
|
||||
}"
|
||||
maxlength="1"
|
||||
:disabled="isChecked"
|
||||
@input="(e) => handleInput(wordIdx, charIdx, e)"
|
||||
@keydown="(e) => handleKeydown(wordIdx, charIdx, e)"
|
||||
/>
|
||||
<!-- Underscore Placeholder (visual only, if input empty) -->
|
||||
<div v-if="!charInputs[wordIdx][charIdx]" class="placeholder-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feedback & Correct Answer -->
|
||||
<div v-if="isChecked && !isCorrect" class="w-full text-center animate-fadein surface-ground p-3 border-round">
|
||||
<div class="text-sm text-500 mb-1">Poprawna odpowiedź:</div>
|
||||
<div class="text-xl font-bold text-green-600 tracking-wide">{{ card.back }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<Button
|
||||
v-if="!isChecked"
|
||||
label="Sprawdź"
|
||||
size="large"
|
||||
class="w-full mt-2"
|
||||
@click="checkAnswer"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
:label="isCorrect ? 'Dalej' : 'Rozumiem, dalej'"
|
||||
:severity="isCorrect ? 'success' : 'secondary'"
|
||||
class="w-full mt-2"
|
||||
size="large"
|
||||
icon="pi pi-arrow-right"
|
||||
iconPos="right"
|
||||
@click="nextCard"
|
||||
ref="nextButton"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.char-input {
|
||||
width: 2.5rem;
|
||||
height: 3rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
border-bottom: 2px solid var(--surface-400);
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.char-input:focus {
|
||||
border-bottom-color: var(--primary-color);
|
||||
background-color: var(--primary-50);
|
||||
}
|
||||
|
||||
.char-input.is-correct {
|
||||
border-bottom-color: var(--green-500);
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.char-input.is-error {
|
||||
border-bottom-color: var(--red-500);
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
/* Fallback placeholder line if needed explicitly?
|
||||
Currently strictly using border-bottom on input itself is cleaner
|
||||
and matches 'underscore' look.
|
||||
*/
|
||||
|
||||
.animate-fadein {
|
||||
animation: fadein 0.3s ease-out;
|
||||
}
|
||||
@keyframes fadein {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
128
src/app/components/ReviewSession.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
import ProgressBar from 'primevue/progressbar';
|
||||
import ActiveFlipCard from './ActiveFlipCard.vue';
|
||||
import ActiveSpellingCard from './ActiveSpellingCard.vue';
|
||||
import type { Flashcard } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
cards: Flashcard[];
|
||||
mode: 'flip' | 'spelling';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['exit']);
|
||||
|
||||
// State
|
||||
const queue = ref<Flashcard[]>([...props.cards].sort(() => Math.random() - 0.5)); // Shuffle on init
|
||||
const currentIndex = ref(0);
|
||||
const streak = ref(0);
|
||||
const maxStreak = ref(0);
|
||||
const correctCount = ref(0);
|
||||
const incorrectCount = ref(0);
|
||||
const isFinished = ref(false);
|
||||
|
||||
const currentCard = computed(() => queue.value[currentIndex.value]);
|
||||
const progress = computed(() => (currentIndex.value / queue.value.length) * 100);
|
||||
|
||||
const handleAnswer = (isCorrect: boolean) => {
|
||||
if (isCorrect) {
|
||||
streak.value++;
|
||||
if (streak.value > maxStreak.value) maxStreak.value = streak.value;
|
||||
correctCount.value++;
|
||||
} else {
|
||||
streak.value = 0;
|
||||
incorrectCount.value++;
|
||||
}
|
||||
|
||||
if (currentIndex.value < queue.value.length - 1) {
|
||||
currentIndex.value++;
|
||||
} else {
|
||||
isFinished.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
queue.value = [...props.cards].sort(() => Math.random() - 0.5);
|
||||
currentIndex.value = 0;
|
||||
streak.value = 0;
|
||||
correctCount.value = 0;
|
||||
incorrectCount.value = 0;
|
||||
isFinished.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-column h-full w-full align-items-center justify-content-center p-4">
|
||||
<!-- Progress Header -->
|
||||
<div class="w-full max-w-30rem mb-4 flex flex-column gap-2" v-if="!isFinished">
|
||||
<div class="flex justify-content-between text-sm text-500">
|
||||
<span>Postęp: {{ currentIndex + 1 }} / {{ queue.length }}</span>
|
||||
<span v-if="streak > 1" class="text-orange-500 font-bold flex align-items-center gap-1">
|
||||
<i class="pi pi-fire"></i> {{ streak }}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar :value="progress" :showValue="false" class="h-1rem" />
|
||||
</div>
|
||||
|
||||
<!-- Active Card Area -->
|
||||
<div v-if="!isFinished" class="flex-grow-1 flex flex-column align-items-center justify-content-center w-full">
|
||||
<ActiveFlipCard
|
||||
v-if="mode === 'flip'"
|
||||
:card="currentCard"
|
||||
@answer="handleAnswer"
|
||||
/>
|
||||
<ActiveSpellingCard
|
||||
v-else
|
||||
:card="currentCard"
|
||||
@answer="handleAnswer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- End Screen -->
|
||||
<div v-else class="flex flex-column align-items-center gap-4 animate-fadein">
|
||||
<i class="pi pi-check-circle text-green-500 text-6xl"></i>
|
||||
<h2 class="text-3xl m-0">Koniec Sesji!</h2>
|
||||
|
||||
<div class="grid w-full max-w-30rem text-center">
|
||||
<div class="col-4">
|
||||
<div class="text-2xl font-bold text-green-500">{{ Math.round((correctCount / queue.length) * 100) }}%</div>
|
||||
<div class="text-sm text-500">Poprawność</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="text-2xl font-bold text-orange-500">{{ maxStreak }}</div>
|
||||
<div class="text-sm text-500">Max Seria</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="text-2xl font-bold text-blue-500">{{ queue.length }}</div>
|
||||
<div class="text-sm text-500">Kart</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-4">
|
||||
<Button label="Wróć" severity="secondary" icon="pi pi-arrow-left" @click="$emit('exit')" />
|
||||
<Button label="Powtórz" icon="pi pi-refresh" @click="restart" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exit Button (always visible if not finished) -->
|
||||
<Button
|
||||
v-if="!isFinished"
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
class="absolute top-0 right-0 m-4"
|
||||
@click="$emit('exit')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-fadein {
|
||||
animation: fadein 0.5s;
|
||||
}
|
||||
@keyframes fadein {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
109
src/app/components/Sidebar.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
// Define navigation items with icons
|
||||
const items = [
|
||||
{ label: 'Dashboard', icon: 'pi pi-home', to: '/' },
|
||||
{ label: 'Exercises', icon: 'pi pi-list', to: '/exercises' },
|
||||
{ label: 'Calendar', icon: 'pi pi-calendar', to: '/calendar' },
|
||||
{ label: 'Training Plans', icon: 'pi pi-book', to: '/trainings' },
|
||||
{ label: 'Workout Timer', icon: 'pi pi-clock', to: '/timer' },
|
||||
{ label: 'Analysis', icon: 'pi pi-chart-bar', to: '/analysis' },
|
||||
{ label: 'AI Chat', icon: 'pi pi-comments', to: '/chat' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar h-screen flex flex-column flex-shrink-0 relative z-5">
|
||||
<!-- Glass Background via CSS class is safest, but we can also use direct style for complex layering -->
|
||||
<div class="glass-effect absolute inset-0 -z-1"></div>
|
||||
|
||||
<!-- Logo / Brand -->
|
||||
<div class="flex align-items-center gap-3 px-4 py-4 mb-2">
|
||||
<div class="flex align-items-center justify-content-center border-circle surface-900 w-3rem h-3rem shadow-2">
|
||||
<i class="pi pi-bolt text-primary text-xl"></i>
|
||||
</div>
|
||||
<span class="text-xl font-semibold text-color tracking-tight">TrainHub</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Menu -->
|
||||
<ul class="list-none p-3 m-0 flex-1 overflow-y-auto">
|
||||
<li v-for="item in items" :key="item.label" class="mb-1">
|
||||
<router-link :to="item.to"
|
||||
custom
|
||||
v-slot="{ navigate, href, isActive, isExactActive }"
|
||||
>
|
||||
<a :href="href" @click="navigate"
|
||||
class="nav-link flex align-items-center cursor-pointer p-3 border-round-xl transition-all transition-duration-200 no-underline"
|
||||
:class="{ 'active-nav': isActive || (item.to === '/' && isExactActive) }"
|
||||
>
|
||||
<i :class="[item.icon, 'mr-3 text-lg transition-transform transition-duration-200']"></i>
|
||||
<span class="font-medium text-sm">{{ item.label }}</span>
|
||||
|
||||
<!-- Active Indicator -->
|
||||
<div v-if="isActive || (item.to === '/' && isExactActive)"
|
||||
class="ml-auto w-1 h-1 border-circle bg-primary shadow-1"></div>
|
||||
</a>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="p-3 mt-auto">
|
||||
<div class="user-card flex align-items-center gap-3 p-3 border-round-xl cursor-pointer transition-colors transition-duration-200">
|
||||
<div class="w-2rem h-2rem border-circle surface-700 flex align-items-center justify-content-center">
|
||||
<i class="pi pi-user text-xs text-white"></i>
|
||||
</div>
|
||||
<div class="flex flex-column">
|
||||
<span class="text-sm font-medium text-color">User Account</span>
|
||||
<span class="text-xs text-color-secondary">Pro Plan</span>
|
||||
</div>
|
||||
<i class="pi pi-cog ml-auto text-color-secondary hover:text-color transition-colors"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(9, 9, 11, 0.7); /* Deep dark tint */
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-color-secondary);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-color);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.nav-link:hover i {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.active-nav {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--primary-color);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.active-nav i {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
</style>
|
||||
63
src/app/components/chat/ChatInput.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const props = defineProps<{
|
||||
isLoading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'send', text: string): void;
|
||||
}>();
|
||||
|
||||
const text = ref("");
|
||||
|
||||
const handleSend = () => {
|
||||
if (!text.value.trim() || props.isLoading) return;
|
||||
emit('send', text.value);
|
||||
text.value = "";
|
||||
};
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="surface-card border-top-1 surface-border p-3">
|
||||
<div class="relative max-w-50rem mx-auto border-1 surface-border border-round-xl shadow-1 surface-ground focus-within:border-primary transition-colors">
|
||||
<Textarea
|
||||
v-model="text"
|
||||
autoResize
|
||||
rows="1"
|
||||
placeholder="Zapytaj asystenta..."
|
||||
class="w-full border-none shadow-none bg-transparent p-3 text-base"
|
||||
style="max-height: 150px; min-height: 50px; resize: none;"
|
||||
@keydown="handleKeydown"
|
||||
:disabled="props.isLoading"
|
||||
/>
|
||||
|
||||
<div class="flex align-items-center justify-content-between px-2 pb-2">
|
||||
<div class="flex gap-1">
|
||||
<!-- Placeholder buttons -->
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<small class="text-color-secondary hidden md:block mr-2">
|
||||
Enter to send
|
||||
</small>
|
||||
<Button
|
||||
icon="pi pi-arrow-up"
|
||||
rounded
|
||||
:disabled="!text.trim() || props.isLoading"
|
||||
@click="handleSend"
|
||||
class="h-2rem w-2rem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
120
src/app/components/chat/ChatMessageList.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUpdated, nextTick, watch } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import ThinkingBlock from './ThinkingBlock.vue';
|
||||
import type { UiChatMessage } from '../../composables/useChatAI';
|
||||
|
||||
const props = defineProps<{
|
||||
messages: UiChatMessage[];
|
||||
}>();
|
||||
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
if (container.value) {
|
||||
container.value.scrollTop = container.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(scrollToBottom);
|
||||
|
||||
// Watch for changes to scroll
|
||||
watch(() => props.messages.length, scrollToBottom);
|
||||
watch(() => props.messages[props.messages.length - 1]?.content, scrollToBottom); // deep watch not needed, just length or last msg content updates
|
||||
|
||||
const renderMarkdown = (text: string) => {
|
||||
return marked.parse(text || "");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-grow-1 overflow-y-auto p-4 flex flex-column gap-4" ref="container">
|
||||
<!-- Spacer -->
|
||||
<div class="h-1rem"></div>
|
||||
|
||||
<div v-for="(msg, index) in props.messages" :key="index" class="w-full">
|
||||
<!-- System Messages -->
|
||||
<div v-if="msg.role === 'system'" class="flex justify-content-center my-2">
|
||||
<!-- Hidden or debug only -->
|
||||
</div>
|
||||
|
||||
<!-- User/Assistant Messages -->
|
||||
<div v-else class="flex gap-3" :class="{ 'justify-content-end': msg.role === 'user' }">
|
||||
|
||||
<!-- Assistant Avatar -->
|
||||
<div v-if="msg.role === 'assistant'" class="flex-shrink-0 mt-1">
|
||||
<div class="w-2rem h-2rem border-circle bg-primary-100 flex align-items-center justify-content-center text-primary shadow-1">
|
||||
<i class="pi pi-bolt"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-2 max-w-30rem" style="min-width: 0;">
|
||||
|
||||
<!-- Thinking/Steps Block -->
|
||||
<ThinkingBlock
|
||||
v-if="msg.steps && msg.steps.length > 0"
|
||||
:steps="msg.steps"
|
||||
/>
|
||||
|
||||
<!-- Message Bubble -->
|
||||
<div
|
||||
class="p-3 border-round-xl shadow-1 text-sm line-height-3"
|
||||
:class="[
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-white border-round-bottom-right-xs'
|
||||
: 'surface-card text-color border-round-bottom-left-xs'
|
||||
]"
|
||||
>
|
||||
<div v-if="msg.role === 'assistant'" class="markdown-body" v-html="renderMarkdown(msg.content)"></div>
|
||||
<div v-else class="white-space-pre-wrap">{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Avatar -->
|
||||
<div v-if="msg.role === 'user'" class="flex-shrink-0 mt-1">
|
||||
<div class="w-2rem h-2rem border-circle surface-200 flex align-items-center justify-content-center text-600">
|
||||
<i class="pi pi-user"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="props.messages.length <= 1" class="flex flex-column align-items-center justify-content-center h-full text-500 opacity-50">
|
||||
<i class="pi pi-comments text-5xl mb-3"></i>
|
||||
<p>Rozpocznij konwersację...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Markdown Styles integrated with PrimeFlex colors */
|
||||
.markdown-body p { margin-bottom: 0.75em; }
|
||||
.markdown-body p:last-child { margin-bottom: 0; }
|
||||
.markdown-body ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 0.75em; }
|
||||
.markdown-body ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 0.75em; }
|
||||
.markdown-body code {
|
||||
background: var(--surface-hover);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.bg-primary .markdown-body code {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
}
|
||||
.markdown-body pre {
|
||||
background: var(--surface-ground);
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 0.75em;
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
.markdown-body pre code { background: none; padding: 0; color: var(--text-color); }
|
||||
.markdown-body strong { font-weight: 700; }
|
||||
.markdown-body a { color: var(--primary-color); text-decoration: underline; }
|
||||
</style>
|
||||
73
src/app/components/chat/ThinkingBlock.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { ThinkingStep } from '../../composables/useChatAI';
|
||||
|
||||
const props = defineProps<{
|
||||
steps: ThinkingStep[];
|
||||
isReasoning?: boolean;
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(true);
|
||||
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
const statusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running': return 'pi pi-spin pi-spinner text-blue-500';
|
||||
case 'completed': return 'pi pi-check-circle text-green-500';
|
||||
case 'error': return 'pi pi-times-circle text-red-500';
|
||||
default: return 'pi pi-circle text-500';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="thinking-block my-2 border-1 surface-border border-round surface-card overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex align-items-center justify-content-between p-3 cursor-pointer hover:surface-hover transition-colors"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i v-if="props.isReasoning" class="pi pi-bolt text-purple-500"></i>
|
||||
<i v-else class="pi pi-cog text-orange-500"></i>
|
||||
<span class="text-sm font-medium text-color">
|
||||
{{ props.isReasoning ? 'Proces myślowy' : 'Działania asystenta' }}
|
||||
</span>
|
||||
<span class="text-xs text-color-secondary ml-2">({{ props.steps.length }} kroki)</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-down text-color-secondary transition-transform duration-200" :class="{ 'rotate-180': isExpanded }"></i>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div v-show="isExpanded" class="border-top-1 surface-border p-3 surface-ground">
|
||||
<div class="flex flex-column gap-2">
|
||||
<div
|
||||
v-for="step in props.steps"
|
||||
:key="step.id"
|
||||
class="flex align-items-start gap-2 text-sm"
|
||||
>
|
||||
<div class="mt-1 flex-shrink-0">
|
||||
<i :class="statusIcon(step.status)"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<div class="font-medium text-color">
|
||||
{{ step.title }}
|
||||
</div>
|
||||
<div v-if="step.details" class="mt-1 text-color-secondary text-xs font-mono surface-card p-2 border-round border-1 surface-border overflow-x-auto white-space-pre-wrap">
|
||||
{{ step.details }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.thinking-block {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
</style>
|
||||
642
src/app/components/posture/PostureAnalysis.vue
Normal file
@@ -0,0 +1,642 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
import Card from 'primevue/card';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
import PostureCanvas from './PostureCanvas.vue';
|
||||
import { usePostureAnalysis, type PostureAnalysisResult, type PosturePoints } from '../../composables/usePostureAnalysis';
|
||||
import { drawGrid, drawSkeleton, drawPoints, drawMedicalLines, drawAngleVisuals, drawCalibration, drawSagittalAnalysis } from '../../utils/postureDrawing';
|
||||
|
||||
const { analyzeImage, recalculateMetrics } = usePostureAnalysis();
|
||||
|
||||
const views = [
|
||||
{ id: 'front', label: 'Przód' },
|
||||
{ id: 'back', label: 'Tył' },
|
||||
{ id: 'side_left', label: 'Bok L' },
|
||||
{ id: 'side_right', label: 'Bok P' }
|
||||
];
|
||||
|
||||
const selectedView = ref('front');
|
||||
const images = ref<Record<string, string | null>>({
|
||||
front: null, back: null, side_left: null, side_right: null
|
||||
});
|
||||
const results = ref<Record<string, PostureAnalysisResult | null>>({
|
||||
front: null, back: null, side_left: null, side_right: null
|
||||
});
|
||||
|
||||
const isAnalyzing = ref(false);
|
||||
// canvasRef removed as unused
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Interaction
|
||||
const interactionMode = ref<string>('none');
|
||||
|
||||
// Session Persistence
|
||||
const showSaveDialog = ref(false);
|
||||
const showLoadDialog = ref(false);
|
||||
const saveSessionName = ref('');
|
||||
const savedSessions = ref<{id: string, name: string, date: string, data: any}[]>([]);
|
||||
|
||||
// PDF Helpers
|
||||
const pdfTableRef = ref<HTMLDivElement | null>(null);
|
||||
const pdfTableData = ref<Record<string, { value: string | number, status: string }> | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
loadSessionsList();
|
||||
});
|
||||
|
||||
const loadSessionsList = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem('posture_sessions');
|
||||
if (raw) savedSessions.value = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error('Failed to load sessions', e);
|
||||
}
|
||||
};
|
||||
|
||||
const pickImage = (viewId: string) => {
|
||||
selectedView.value = viewId;
|
||||
if (fileInput.value) fileInput.value.click();
|
||||
};
|
||||
|
||||
const onFileSelected = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
const file = target.files[0];
|
||||
const src = URL.createObjectURL(file);
|
||||
const viewId = selectedView.value;
|
||||
images.value[viewId] = src;
|
||||
setTimeout(() => runAnalysis(viewId), 500);
|
||||
}
|
||||
if (target) target.value = '';
|
||||
};
|
||||
|
||||
const runAnalysis = async (viewId: string) => {
|
||||
const src = images.value[viewId];
|
||||
if (!src) return;
|
||||
isAnalyzing.value = true;
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = src;
|
||||
img.onload = async () => {
|
||||
const res = await analyzeImage(img, viewId);
|
||||
results.value[viewId] = res;
|
||||
isAnalyzing.value = false;
|
||||
};
|
||||
};
|
||||
|
||||
// Handle Updates
|
||||
const currentResult = computed(() => results.value[selectedView.value]);
|
||||
|
||||
const updateAnalysis = () => {
|
||||
const res = currentResult.value;
|
||||
if (!res) return;
|
||||
const metrics = recalculateMetrics(selectedView.value, res.points, res.calibration, res.manualPlumbLineX);
|
||||
res.angles = metrics;
|
||||
};
|
||||
|
||||
const onPointsUpdate = (newPoints: PosturePoints) => {
|
||||
if (currentResult.value) {
|
||||
currentResult.value.points = newPoints;
|
||||
updateAnalysis();
|
||||
}
|
||||
};
|
||||
|
||||
const onCalibrationUpdate = (newCalib: {p1: any, p2: any}) => {
|
||||
if (currentResult.value) {
|
||||
const cal = currentResult.value.calibration;
|
||||
cal.p1 = newCalib.p1;
|
||||
cal.p2 = newCalib.p2;
|
||||
if (cal.p1 && cal.p2) {
|
||||
const pxDist = Math.hypot(cal.p2.x - cal.p1.x, cal.p2.y - cal.p1.y);
|
||||
if (pxDist > 0) cal.ratio = cal.realLengthMm / pxDist;
|
||||
}
|
||||
updateAnalysis();
|
||||
}
|
||||
};
|
||||
|
||||
const onPointClick = (e: {mode: string, x: number, y: number}) => {
|
||||
if (!currentResult.value) return;
|
||||
|
||||
if (e.mode === 'calibration') {
|
||||
const cal = currentResult.value.calibration;
|
||||
if (!cal.p1) cal.p1 = {x: e.x, y: e.y};
|
||||
else if (!cal.p2) {
|
||||
cal.p2 = {x: e.x, y: e.y};
|
||||
const pxDist = Math.hypot(cal.p2.x - cal.p1.x, cal.p2.y - cal.p1.y);
|
||||
if (pxDist > 0) cal.ratio = cal.realLengthMm / pxDist;
|
||||
interactionMode.value = 'none';
|
||||
updateAnalysis();
|
||||
}
|
||||
} else {
|
||||
const field = e.mode as keyof PosturePoints;
|
||||
// @ts-ignore
|
||||
currentResult.value.points[field] = { x: e.x, y: e.y };
|
||||
interactionMode.value = 'none';
|
||||
updateAnalysis();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlumbLineUpdate = (x: number) => {
|
||||
if (currentResult.value) {
|
||||
currentResult.value.manualPlumbLineX = x;
|
||||
updateAnalysis();
|
||||
}
|
||||
};
|
||||
|
||||
const startCalibration = () => {
|
||||
interactionMode.value = 'calibration';
|
||||
};
|
||||
|
||||
const setCalibrationLength = () => {
|
||||
const val = prompt("Podaj długość odcinka w mm:", "1000");
|
||||
if (val && currentResult.value) {
|
||||
currentResult.value.calibration.realLengthMm = parseFloat(val);
|
||||
onCalibrationUpdate(currentResult.value.calibration as any);
|
||||
}
|
||||
};
|
||||
|
||||
// --- PERSISTENCE ---
|
||||
|
||||
const openSaveDialog = () => {
|
||||
saveSessionName.value = `Sesja ${new Date().toLocaleDateString()}`;
|
||||
showSaveDialog.value = true;
|
||||
};
|
||||
|
||||
const blobToBase64 = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
|
||||
const saveSession = async () => {
|
||||
const data: any = { activeView: selectedView.value, views: {} };
|
||||
let hasData = false;
|
||||
|
||||
// Iterate all views to save state
|
||||
for(const view of views) {
|
||||
const res = results.value[view.id];
|
||||
const src = images.value[view.id];
|
||||
if(res && src) {
|
||||
hasData = true;
|
||||
let b64 = src;
|
||||
if (src.startsWith('blob:')) {
|
||||
try {
|
||||
b64 = await blobToBase64(src);
|
||||
} catch (e) {
|
||||
console.error("Blob convert error", e);
|
||||
}
|
||||
}
|
||||
data.views[view.id] = {
|
||||
points: res.points,
|
||||
calibration: res.calibration,
|
||||
imageSrc: b64,
|
||||
viewType: view.id
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasData) {
|
||||
alert("Brak danych do zapisania w żadnym widoku.");
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionObj = {
|
||||
id: crypto.randomUUID(),
|
||||
name: saveSessionName.value,
|
||||
date: new Date().toLocaleString(),
|
||||
data: data
|
||||
};
|
||||
|
||||
storeSession(sessionObj);
|
||||
};
|
||||
|
||||
const storeSession = (session: any) => {
|
||||
try {
|
||||
const existing = JSON.parse(localStorage.getItem('posture_sessions') || '[]');
|
||||
existing.push(session);
|
||||
localStorage.setItem('posture_sessions', JSON.stringify(existing));
|
||||
savedSessions.value = existing;
|
||||
showSaveDialog.value = false;
|
||||
alert("Sesja (wszystkie widoki) została zapisana!");
|
||||
} catch (e) {
|
||||
alert("Błąd zapisu (limit pamięci przeglądarki?): " + e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSession = (sessionData: any) => {
|
||||
try {
|
||||
// Handle Legacy (Single View) vs New (Multi View)
|
||||
if (sessionData.views) {
|
||||
// New Format
|
||||
// Clear current
|
||||
for(const k of Object.keys(images.value)) images.value[k] = null;
|
||||
for(const k of Object.keys(results.value)) results.value[k] = null;
|
||||
|
||||
// Load
|
||||
for (const [key, viewData] of Object.entries(sessionData.views) as any) {
|
||||
images.value[key] = viewData.imageSrc;
|
||||
|
||||
// Recalculate metrics for robustness
|
||||
const metrics = recalculateMetrics(key, viewData.points, viewData.calibration);
|
||||
results.value[key] = {
|
||||
points: viewData.points,
|
||||
angles: metrics,
|
||||
viewType: key,
|
||||
calibration: viewData.calibration,
|
||||
manualPlumbLineX: viewData.manualPlumbLineX || null
|
||||
};
|
||||
}
|
||||
selectedView.value = sessionData.activeView || 'front';
|
||||
|
||||
} else {
|
||||
// Legacy Format
|
||||
images.value[sessionData.view] = sessionData.imageSrc;
|
||||
selectedView.value = sessionData.view;
|
||||
const metrics = recalculateMetrics(sessionData.view, sessionData.points, sessionData.calibration);
|
||||
results.value[sessionData.view] = {
|
||||
points: sessionData.points,
|
||||
angles: metrics,
|
||||
viewType: sessionData.view,
|
||||
calibration: sessionData.calibration,
|
||||
manualPlumbLineX: sessionData.manualPlumbLineX || null
|
||||
};
|
||||
}
|
||||
|
||||
showLoadDialog.value = false;
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Błąd wczytywania: " + e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSession = (id: string) => {
|
||||
const existing = savedSessions.value.filter(s => s.id !== id);
|
||||
localStorage.setItem('posture_sessions', JSON.stringify(existing));
|
||||
savedSessions.value = existing;
|
||||
};
|
||||
|
||||
const generatePDF = async () => {
|
||||
|
||||
const btn = document.activeElement as HTMLElement;
|
||||
if(btn) btn.classList.add('p-disabled');
|
||||
|
||||
try {
|
||||
// 1. Validation & Setup
|
||||
const validViews = views.filter(v => results.value[v.id] && images.value[v.id]);
|
||||
|
||||
if (validViews.length === 0) {
|
||||
alert("Brak danych do wygenerowania raportu. Wykonaj analizę przynajmniej jednego widoku.");
|
||||
if(btn) btn.classList.remove('p-disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = `Raport_${saveSessionName.value || 'Pacjent'}_${new Date().toISOString().slice(0,10)}.pdf`;
|
||||
let fileHandle: any = null;
|
||||
|
||||
// 2. Request File Handle IMMEDIATELY (User Gesture Token)
|
||||
// @ts-ignore
|
||||
if (window.showSaveFilePicker) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: filename,
|
||||
types: [{ description: 'PDF Document', accept: {'application/pdf': ['.pdf']} }],
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
if(btn) btn.classList.remove('p-disabled');
|
||||
return; // User cancelled
|
||||
}
|
||||
// If other error, show it
|
||||
alert("Błąd zapisu pliku: " + err.message);
|
||||
if(btn) btn.classList.remove('p-disabled');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate Content (Async/Heavy)
|
||||
const pdf = new jsPDF('l', 'mm', 'a4');
|
||||
const w = pdf.internal.pageSize.getWidth();
|
||||
const h = pdf.internal.pageSize.getHeight();
|
||||
let pageCount = 0;
|
||||
|
||||
for (const view of views) {
|
||||
const res = results.value[view.id];
|
||||
const imgSrc = images.value[view.id];
|
||||
|
||||
if (!res || !imgSrc) continue;
|
||||
|
||||
if (pageCount > 0) pdf.addPage();
|
||||
pageCount++;
|
||||
|
||||
// Header
|
||||
pdf.setFontSize(24);
|
||||
pdf.setTextColor(34, 197, 94);
|
||||
pdf.text("TrainHub", 10, 15);
|
||||
|
||||
pdf.setFontSize(12);
|
||||
pdf.setTextColor(100);
|
||||
pdf.text("Raport Diagnostyki Postawy", 10, 22);
|
||||
|
||||
// Meta Top Right
|
||||
pdf.setFontSize(10);
|
||||
pdf.setTextColor(0);
|
||||
const dateStr = new Date().toLocaleDateString('pl-PL');
|
||||
pdf.text(`Data: ${dateStr}`, w - 10, 15, { align: 'right' });
|
||||
pdf.text(`Pacjent: ${saveSessionName.value || 'Bez nazwy'}`, w - 10, 20, { align: 'right' });
|
||||
pdf.text(`Widok: ${view.label}`, w - 10, 25, { align: 'right' });
|
||||
|
||||
pdf.setDrawColor(200);
|
||||
pdf.line(10, 30, w-10, 30); // Separator
|
||||
|
||||
// Main Image (Canvas Refresh)
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
// Load Image
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = imgSrc;
|
||||
await new Promise((resolve, _) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = resolve;
|
||||
});
|
||||
|
||||
tempCanvas.width = img.width;
|
||||
tempCanvas.height = img.height;
|
||||
|
||||
if (tempCtx) {
|
||||
tempCtx.drawImage(img, 0, 0);
|
||||
|
||||
const unit = Math.max(10, img.width * 0.015);
|
||||
// Draw Analysis Layers
|
||||
drawGrid(tempCtx, img.width, img.height);
|
||||
|
||||
if (res.points) {
|
||||
drawSkeleton(tempCtx, res.points, unit);
|
||||
drawPoints(tempCtx, res.points, unit, 1);
|
||||
drawMedicalLines(tempCtx, res.points, unit, 1, view.id);
|
||||
drawAngleVisuals(tempCtx, res.points, unit, 1, view.id);
|
||||
drawAngleVisuals(tempCtx, res.points, unit, 1, view.id);
|
||||
drawSagittalAnalysis(tempCtx, res.points, unit, 1, view.id, res.calibration, res.manualPlumbLineX);
|
||||
}
|
||||
drawCalibration(tempCtx, res.calibration, unit, 1);
|
||||
}
|
||||
|
||||
const imgData = tempCanvas.toDataURL('image/png');
|
||||
|
||||
// Layout (same as before)
|
||||
const areaX = 10;
|
||||
const areaY = 35;
|
||||
const areaW = (w - 20) * 0.65;
|
||||
const areaH = h - 45;
|
||||
|
||||
const imgProps = pdf.getImageProperties(imgData);
|
||||
const imgRatio = imgProps.width / imgProps.height;
|
||||
const areaRatio = areaW / areaH;
|
||||
|
||||
let finalW, finalH;
|
||||
if (imgRatio > areaRatio) {
|
||||
finalW = areaW;
|
||||
finalH = areaW / imgRatio;
|
||||
} else {
|
||||
finalW = areaH * imgRatio;
|
||||
finalH = areaH;
|
||||
}
|
||||
|
||||
const offX = areaX + (areaW - finalW) / 2;
|
||||
const offY = areaY + (areaH - finalH) / 2;
|
||||
|
||||
pdf.addImage(imgData, 'PNG', offX, offY, finalW, finalH);
|
||||
|
||||
// Analysis Table using html2canvas (Fixes Font & Layout)
|
||||
let tableX = (w - 20) * 0.65 + 15; // Default position
|
||||
|
||||
if (res.angles && pdfTableRef.value) {
|
||||
// Populate template
|
||||
pdfTableData.value = res.angles;
|
||||
await nextTick();
|
||||
// Wait specifically for DOM paint/style appplication
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Capture
|
||||
const tableCanvas = await html2canvas(pdfTableRef.value, {
|
||||
scale: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
ignoreElements: (_) => false
|
||||
});
|
||||
const tableImg = tableCanvas.toDataURL('image/png');
|
||||
|
||||
// Calculate dimensions to fit PDF column
|
||||
const columnW = (w - 20) * 0.35 - 5;
|
||||
// tableX set above
|
||||
const tableY = 40;
|
||||
|
||||
const tableRatio = tableCanvas.width / tableCanvas.height;
|
||||
const finalTableH = columnW / tableRatio;
|
||||
|
||||
pdf.addImage(tableImg, 'PNG', tableX, tableY, columnW, finalTableH);
|
||||
} else {
|
||||
tableX = (w - 20) * 0.65 + 20; // Slight adjust
|
||||
pdf.setFontSize(10);
|
||||
pdf.setFont("helvetica", "italic");
|
||||
pdf.setTextColor(150);
|
||||
pdf.text("Brak danych pomiarowych", tableX, 40);
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerY = h - 20;
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(100);
|
||||
pdf.text("Raport generowany automatycznie przez TrainHub AI.", tableX, footerY, { maxWidth: (w - 10 - tableX) });
|
||||
|
||||
} // end Loop
|
||||
|
||||
// 4. Save Logic
|
||||
if (fileHandle) {
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(pdf.output('blob'));
|
||||
await writable.close();
|
||||
|
||||
} else {
|
||||
// Fallback for browsers without proper FS API support
|
||||
pdf.save(filename);
|
||||
alert("Raport PDF został zapisany w folderze Pobrane (fallback).");
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Błąd: " + e);
|
||||
} finally {
|
||||
if(btn) btn.classList.remove('p-disabled');
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full overflow-hidden relative surface-ground" id="report-area">
|
||||
<input type="file" ref="fileInput" accept="image/*" class="hidden" @change="onFileSelected" />
|
||||
|
||||
<!-- SIDEBAR: Views (Fixed Width) -->
|
||||
<div class="w-16rem flex flex-column gap-2 overflow-y-auto border-right-1 surface-border p-2 bg-surface-0 flex-shrink-0">
|
||||
<div v-for="view in views" :key="view.id"
|
||||
class="p-2 cursor-pointer transition-colors border-left-4"
|
||||
:class="selectedView === view.id ? 'surface-hover border-primary' : 'border-transparent hover:surface-ground'"
|
||||
@click="selectedView = view.id">
|
||||
|
||||
<div class="font-bold mb-1">{{ view.label }}</div>
|
||||
<div class="w-full aspect-ratio-16-9 bg-surface-200 border-round overflow-hidden relative flex align-items-center justify-content-center bg-black-alpha-90">
|
||||
<img v-if="images[view.id]" :src="images[view.id]!" class="max-w-full max-h-full w-auto h-auto" />
|
||||
<div v-else class="text-white-alpha-50"><i class="pi pi-image"></i></div>
|
||||
</div>
|
||||
<Button label="Wybierz" icon="pi pi-upload" class="w-full mt-2 p-button-sm p-button-outlined" @click.stop="pickImage(view.id)" />
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-column gap-2">
|
||||
<Button label="Zapisz Sesję" icon="pi pi-save" class="p-button-secondary p-button-outlined" @click="openSaveDialog" />
|
||||
<Button label="Wczytaj Sesję" icon="pi pi-folder-open" class="p-button-secondary p-button-outlined" @click="showLoadDialog = true" />
|
||||
<Button label="Raport PDF" icon="pi pi-file-pdf" class="p-button-success" @click="generatePDF" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAIN CANVAS (Flex Grow) -->
|
||||
<div class="flex-1 flex flex-column p-0 bg-black-alpha-90 relative overflow-hidden" style="min-width: 0;">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex gap-2 p-2 surface-overlay border-bottom-1 surface-border align-items-center overflow-x-auto flex-shrink-0">
|
||||
<Button label="Kalibracja" icon="pi pi-arrows-h" size="small"
|
||||
:severity="interactionMode === 'calibration' ? 'warning' : 'secondary'"
|
||||
@click="startCalibration" />
|
||||
|
||||
<template v-if="selectedView.startsWith('side')">
|
||||
<!-- Buttons removed as per user request (auto-initialization) -->
|
||||
</template>
|
||||
|
||||
<div class="border-left-1 surface-border mx-2 h-2rem"></div>
|
||||
|
||||
<Button v-if="currentResult?.calibration.p1" label="Zmień dł." icon="pi pi-pencil" size="small" outlined @click="setCalibrationLength" />
|
||||
<span v-if="currentResult?.calibration" class="text-xs ml-2">
|
||||
{{ currentResult.calibration.realLengthMm }}mm
|
||||
({{ currentResult.calibration.ratio ? currentResult.calibration.ratio.toFixed(2) : '-' }} mm/px)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Canvas Container -->
|
||||
<div class="flex-1 relative overflow-hidden flex align-items-center justify-content-center bg-black-alpha-90" style="min-height: 0;">
|
||||
<PostureCanvas
|
||||
|
||||
:imageSrc="images[selectedView]"
|
||||
:analysisResult="results[selectedView]"
|
||||
:viewType="selectedView"
|
||||
:interactionMode="interactionMode"
|
||||
@update-points="onPointsUpdate"
|
||||
@update-calibration="onCalibrationUpdate"
|
||||
@point-click="onPointClick"
|
||||
@update-plumb-line="handlePlumbLineUpdate"
|
||||
class="w-full h-full"
|
||||
/>
|
||||
|
||||
<!-- Interaction Hint -->
|
||||
<div v-if="interactionMode !== 'none'" class="absolute bottom-0 left-0 w-full p-2 bg-primary text-center text-sm opacity-90 z-2">
|
||||
Tryb: {{ interactionMode.toUpperCase() }} - Kliknij na zdjęciu aby dodać punkt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RESULTS (Fixed Width) -->
|
||||
<div class="w-20rem flex flex-column overflow-hidden border-left-1 surface-border p-0 h-full flex-shrink-0 bg-surface-0">
|
||||
<div class="font-bold text-xl p-3 surface-ground border-bottom-1 surface-border flex-shrink-0">Wyniki Analizy</div>
|
||||
<div class="overflow-y-auto flex-grow-1 p-3">
|
||||
<div v-if="currentResult">
|
||||
<Card class="mb-3 surface-ground border-transparent shadow-none">
|
||||
<template #content>
|
||||
<div class="flex flex-column gap-2">
|
||||
<div v-for="(metric, key) in currentResult.angles" :key="key"
|
||||
class="flex justify-content-between align-items-center border-bottom-1 surface-border pb-1">
|
||||
<span class="text-sm font-medium">{{ key }}</span>
|
||||
<span class="font-bold param-value white-space-nowrap" :class="{
|
||||
'text-green-500': metric.status === 'norm',
|
||||
'text-red-500': metric.status === 'error',
|
||||
'text-orange-500': metric.status === 'warning'
|
||||
}">{{ metric.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
<div v-else class="text-center text-color-secondary mt-5">
|
||||
Brak analizy dla tego widoku.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIALOGS -->
|
||||
<Dialog v-model:visible="showSaveDialog" header="Zapisz Sesję" :modal="true" class="w-30rem">
|
||||
<div class="field">
|
||||
<label class="block mb-2">Nazwa Sesji</label>
|
||||
<InputText v-model="saveSessionName" class="w-full" autofocus />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Anuluj" icon="pi pi-times" class="p-button-text" @click="showSaveDialog = false" />
|
||||
<Button label="Zapisz" icon="pi pi-check" @click="saveSession" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="showLoadDialog" header="Zapisane Sesje" :modal="true" class="w-30rem">
|
||||
<div v-if="savedSessions.length === 0" class="text-center p-4">Brak zapisanych sesji.</div>
|
||||
<div v-else class="flex flex-column gap-2 max-h-20rem overflow-y-auto">
|
||||
<div v-for="s in savedSessions" :key="s.id" class="flex justify-content-between align-items-center p-2 surface-card border-round">
|
||||
<div>
|
||||
<div class="font-bold">{{ s.name }}</div>
|
||||
<div class="text-xs text-color-secondary">
|
||||
{{ s.date }} • {{ (s.data && s.data.view && views.find(v => v.id === s.data.view)?.label) || 'Nieznany widok' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-folder-open" class="p-button-rounded p-button-text" @click="loadSession(s.data)" />
|
||||
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger" @click="deleteSession(s.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<!-- Hidden PDF Table Generator -->
|
||||
<!-- Use absolute off-screen positioning instead of opacity to ensure html2canvas sees it as 'visible' -->
|
||||
<div ref="pdfTableRef" class="fixed bg-white p-4 text-gray-900" style="left: -9999px; top: 0; width: 600px; z-index: -1000;">
|
||||
<div v-if="pdfTableData" class="flex flex-column gap-2 text-gray-900">
|
||||
<div class="font-bold text-xl mb-2 text-gray-900">Wyniki Pomiarów</div>
|
||||
<div class="grid grid-nogutter border-bottom-2 surface-border pb-1 font-bold text-sm bg-gray-100 p-1 text-gray-900">
|
||||
<div class="col-6">Parametr</div>
|
||||
<div class="col-3 text-right">Wartość</div>
|
||||
<div class="col-3 text-center">Status</div>
|
||||
</div>
|
||||
<div v-for="(metric, key) in pdfTableData" :key="key"
|
||||
class="grid grid-nogutter border-bottom-1 surface-border py-1 align-items-center text-sm text-gray-900">
|
||||
<div class="col-6 font-medium">{{ key }}</div>
|
||||
<div class="col-3 text-right font-bold">{{ metric.value }}</div>
|
||||
<div class="col-3 flex justify-content-center">
|
||||
<span v-if="metric.status === 'norm'" class="bg-green-500 text-white border-round px-2 py-0 text-xs font-bold">OK</span>
|
||||
<span v-else class="bg-red-500 text-white border-round px-2 py-0 text-xs font-bold">!</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="Object.keys(pdfTableData).length === 0" class="text-center text-gray-500 italic py-2">
|
||||
Brak danych pomiarowych
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aspect-ratio-16-9 { aspect-ratio: 16/9; }
|
||||
</style>
|
||||
368
src/app/components/posture/PostureCanvas.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import type { PostureAnalysisResult, PosturePoints, Point } from '../../composables/usePostureAnalysis';
|
||||
import { drawGrid, drawSkeleton, drawPoints, drawCalibration, drawMedicalLines, drawAngleVisuals, drawSagittalAnalysis } from '../../utils/postureDrawing';
|
||||
|
||||
const props = defineProps<{
|
||||
imageSrc: string | null;
|
||||
analysisResult: PostureAnalysisResult | null;
|
||||
viewType: string;
|
||||
interactionMode: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update-points', 'update-calibration', 'point-click', 'update-plumb-line']);
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
// Help with interaction
|
||||
const localPoints = ref<PosturePoints | null>(null);
|
||||
|
||||
watch(() => props.analysisResult?.points, (newVal) => {
|
||||
if (newVal) {
|
||||
// Filter nulls? Logic already nulls them out in usePostureAnalysis.
|
||||
// But we copy them.
|
||||
localPoints.value = JSON.parse(JSON.stringify(newVal));
|
||||
}
|
||||
}, { deep: true, immediate: true });
|
||||
|
||||
// Zoom & Pan State
|
||||
const scale = ref(1);
|
||||
const offset = ref({ x: 0, y: 0 });
|
||||
const isPanning = ref(false);
|
||||
const startPan = ref({ x: 0, y: 0 });
|
||||
|
||||
// Interaction State
|
||||
const draggingPoint = ref<string | null>(null);
|
||||
const mousePos = ref<Point>({ x: 0, y: 0 });
|
||||
const showLoupe = ref(false);
|
||||
|
||||
// Cached Image to prevent flickering
|
||||
const loadedImage = ref<HTMLImageElement | null>(null);
|
||||
|
||||
const loadSourceImage = () => {
|
||||
if (!props.imageSrc) {
|
||||
loadedImage.value = null;
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = props.imageSrc;
|
||||
img.onload = () => {
|
||||
loadedImage.value = img;
|
||||
draw();
|
||||
};
|
||||
};
|
||||
|
||||
watch(() => props.imageSrc, loadSourceImage, { immediate: true });
|
||||
|
||||
const getBaseSize = () => {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return 20;
|
||||
return Math.max(10, canvas.width * 0.015);
|
||||
};
|
||||
|
||||
// Utils (Hoisted)
|
||||
const dist = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
|
||||
const getCanvasCoords = (e: MouseEvent) => {
|
||||
if (!canvasRef.value) return { x: 0, y: 0 };
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
|
||||
// 1. Mouse in DOM CSS pixels
|
||||
const clientX = e.clientX - rect.left;
|
||||
const clientY = e.clientY - rect.top;
|
||||
|
||||
// 2. Map to Internal Canvas Resolution (un-zoomed)
|
||||
const domScaleX = canvasRef.value.width / rect.width;
|
||||
const domScaleY = canvasRef.value.height / rect.height;
|
||||
|
||||
const xRaw = clientX * domScaleX;
|
||||
const yRaw = clientY * domScaleY;
|
||||
|
||||
// 3. Apply Inverse Transform (Zoom/Pan)
|
||||
const x = (xRaw - offset.value.x) / scale.value;
|
||||
const y = (yRaw - offset.value.y) / scale.value;
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const zoomIntensity = 0.1;
|
||||
const delta = e.deltaY > 0 ? -zoomIntensity : zoomIntensity;
|
||||
const newScale = Math.max(0.1, Math.min(scale.value + delta, 5));
|
||||
scale.value = newScale;
|
||||
draw();
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas || !loadedImage.value) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = loadedImage.value;
|
||||
|
||||
// Optimize: Only resize if dimensions differ to prevent flicker/layout thrashing
|
||||
if (canvas.width !== img.width || canvas.height !== img.height) {
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
}
|
||||
|
||||
// Clear handled by resize usually, but if not resizing:
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const unit = Math.max(10, canvas.width * 0.015);
|
||||
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(offset.value.x, offset.value.y);
|
||||
ctx.scale(scale.value, scale.value);
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
drawGrid(ctx, canvas.width, canvas.height);
|
||||
|
||||
// Determine Plumb X to draw (Local override during drag for smoothness, else Prop)
|
||||
const activePlumbX = (draggingPoint.value === 'plumbLine' && mousePos.value)
|
||||
? mousePos.value.x
|
||||
: props.analysisResult?.manualPlumbLineX;
|
||||
|
||||
if (localPoints.value) {
|
||||
drawSkeleton(ctx, localPoints.value, unit);
|
||||
drawPoints(ctx, localPoints.value, unit, scale.value);
|
||||
drawMedicalLines(ctx, localPoints.value, unit, scale.value, props.viewType);
|
||||
drawAngleVisuals(ctx, localPoints.value, unit, scale.value, props.viewType);
|
||||
// Pass activePlumbX
|
||||
drawSagittalAnalysis(ctx, localPoints.value, unit, scale.value, props.viewType, props.analysisResult?.calibration, activePlumbX);
|
||||
}
|
||||
|
||||
drawCalibration(ctx, props.analysisResult?.calibration, unit, scale.value);
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// ... Utils ...
|
||||
|
||||
// Helper for Plumb Line Hit logic
|
||||
const getPlumbLineX = () => {
|
||||
// If manaul is set, us it.
|
||||
if (props.analysisResult?.manualPlumbLineX !== null && props.analysisResult?.manualPlumbLineX !== undefined) {
|
||||
return props.analysisResult.manualPlumbLineX;
|
||||
}
|
||||
// Fallback order matches drawSagittalAnalysis: Head Center -> Ear -> Ankle
|
||||
if (!localPoints.value) return undefined;
|
||||
const pts = localPoints.value;
|
||||
const isLeft = props.viewType === 'side_left';
|
||||
return pts.head_center?.x ?? (isLeft ? pts.ear_l?.x : pts.ear_r?.x) ?? (isLeft ? pts.ankle_l?.x : pts.ankle_r?.x);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||||
isPanning.value = true;
|
||||
startPan.value = { x: e.clientX, y: e.clientY };
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = getCanvasCoords(e);
|
||||
const unit = getBaseSize();
|
||||
const r_visual = (unit * 0.4) / scale.value + (unit * 0.1);
|
||||
const threshold = r_visual * 1.5;
|
||||
|
||||
// Calibration
|
||||
const calib = props.analysisResult?.calibration;
|
||||
if (calib?.p1 && dist({x,y}, calib.p1) < threshold) { draggingPoint.value = 'calib_p1'; return; }
|
||||
if (calib?.p2 && dist({x,y}, calib.p2) < threshold) { draggingPoint.value = 'calib_p2'; return; }
|
||||
|
||||
// Plumb Line (Sagittal Only)
|
||||
if (props.viewType.startsWith('side')) {
|
||||
const plumbX = getPlumbLineX();
|
||||
|
||||
// Widen Hit Threshold (Line is thin, give it clickable area)
|
||||
const hitWidth = (unit * 1.0) / scale.value;
|
||||
if (plumbX !== undefined && Math.abs(x - plumbX) < hitWidth) {
|
||||
draggingPoint.value = 'plumbLine';
|
||||
// Snap mousePos to x immediately?
|
||||
mousePos.value = { x, y };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Points
|
||||
if (localPoints.value) {
|
||||
for (const [key, pt] of Object.entries(localPoints.value)) {
|
||||
// @ts-ignore
|
||||
if (pt && dist({x,y}, pt) < threshold) {
|
||||
draggingPoint.value = key;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (props.interactionMode !== 'none') {
|
||||
emit('point-click', { mode: props.interactionMode, x, y });
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!canvasRef.value) return;
|
||||
const { x, y } = getCanvasCoords(e);
|
||||
const unit = getBaseSize();
|
||||
|
||||
// Hit Detection for Cursor
|
||||
let hoverType = 'default';
|
||||
|
||||
// Check Plumb Line Hover
|
||||
if (props.viewType.startsWith('side')) {
|
||||
const plumbX = getPlumbLineX();
|
||||
const hitWidth = (unit * 1.0) / scale.value;
|
||||
|
||||
if (plumbX !== undefined && Math.abs(x - plumbX) < hitWidth) {
|
||||
hoverType = 'col-resize';
|
||||
if (!draggingPoint.value) containerRef.value!.style.cursor = 'col-resize';
|
||||
}
|
||||
}
|
||||
|
||||
if (isPanning.value) {
|
||||
// ... (pan logic stays same) ...
|
||||
const dx = e.clientX - startPan.value.x;
|
||||
const dy = e.clientY - startPan.value.y;
|
||||
const rect = canvasRef.value!.getBoundingClientRect();
|
||||
const domScaleX = canvasRef.value!.width / rect.width;
|
||||
|
||||
offset.value.x += dx * domScaleX;
|
||||
offset.value.y += dy * domScaleX;
|
||||
|
||||
startPan.value = { x: e.clientX, y: e.clientY };
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
|
||||
const mouseP = { x, y };
|
||||
mousePos.value = mouseP;
|
||||
|
||||
if (draggingPoint.value) {
|
||||
showLoupe.value = true;
|
||||
|
||||
if (draggingPoint.value.includes('calib_')) {
|
||||
const isP1 = draggingPoint.value === 'calib_p1';
|
||||
const cal = props.analysisResult?.calibration;
|
||||
if (cal) emit('update-calibration', { p1: isP1 ? mouseP : cal.p1, p2: !isP1 ? mouseP : cal.p2 });
|
||||
draw(); // Immediate redraw
|
||||
} else if (draggingPoint.value === 'plumbLine') {
|
||||
containerRef.value!.style.cursor = 'col-resize';
|
||||
emit('update-plumb-line', x);
|
||||
draw(); // Immediate redraw with local override
|
||||
} else if (localPoints.value) {
|
||||
containerRef.value!.style.cursor = 'crosshair';
|
||||
const key = draggingPoint.value as keyof PosturePoints;
|
||||
if (localPoints.value[key]) {
|
||||
localPoints.value[key] = mouseP;
|
||||
emit('update-points', { ...localPoints.value });
|
||||
draw(); // Immediate redraw
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ... cursor logic ...
|
||||
showLoupe.value = false;
|
||||
if (hoverType === 'default') {
|
||||
// ... existing point hover logic ...
|
||||
let overPoint = false;
|
||||
// ...
|
||||
// (Keep existing hover logic shorter or assume it follows)
|
||||
// To match replacement block, I need to ensure I don't cut off logic.
|
||||
// Just copying the start of the else block
|
||||
const calib = props.analysisResult?.calibration;
|
||||
const thres = ((unit * 0.4) / scale.value + (unit * 0.1)) * 1.5;
|
||||
|
||||
if (calib?.p1 && dist(mouseP, calib.p1) < thres) overPoint = true;
|
||||
else if (calib?.p2 && dist(mouseP, calib.p2) < thres) overPoint = true;
|
||||
else if (localPoints.value) {
|
||||
for (const pt of Object.values(localPoints.value)) {
|
||||
// @ts-ignore
|
||||
if (pt && dist(mouseP, pt) < thres) { overPoint = true; break; }
|
||||
}
|
||||
}
|
||||
containerRef.value!.style.cursor = overPoint ? 'pointer' : (props.interactionMode !== 'none' ? 'crosshair' : 'default');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => { draggingPoint.value = null; isPanning.value = false; showLoupe.value = false; };
|
||||
|
||||
const resetZoom = () => {
|
||||
scale.value = 1;
|
||||
offset.value = { x: 0, y: 0 };
|
||||
draw();
|
||||
};
|
||||
|
||||
// Loupe Logic
|
||||
const loupeCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
watch(mousePos, () => {
|
||||
if (!showLoupe.value || !loupeCanvas.value || !loadedImage.value) return;
|
||||
const ctx = loupeCanvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Draw from Original Image (Cached)
|
||||
const img = loadedImage.value;
|
||||
|
||||
// Updated settings: Size 150px, Zoom 1.5x
|
||||
const settings = { zoom: 1.5, size: 150 };
|
||||
// We want to show 150px of content zoomed 1.5x.
|
||||
// So we show 100px of source content.
|
||||
const srcW = settings.size / settings.zoom;
|
||||
|
||||
ctx.clearRect(0,0,settings.size,settings.size);
|
||||
// Draw source patch
|
||||
ctx.drawImage(img, mousePos.value.x - srcW/2, mousePos.value.y - srcW/2, srcW, srcW, 0, 0, settings.size, settings.size);
|
||||
|
||||
ctx.strokeStyle = '#06b6d4'; ctx.lineWidth = 1; ctx.beginPath();
|
||||
ctx.moveTo(settings.size/2, 0); ctx.lineTo(settings.size/2, settings.size);
|
||||
ctx.moveTo(0, settings.size/2); ctx.lineTo(settings.size, settings.size/2);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
watch(() => props.analysisResult, draw, { deep: true });
|
||||
onMounted(draw);
|
||||
|
||||
defineExpose({ draw });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="w-full h-full relative overflow-hidden bg-black-alpha-90 select-none flex align-items-center justify-content-center"
|
||||
@mousedown="onMouseDown" @mousemove="onMouseMove" @mouseup="onMouseUp" @mouseleave="onMouseUp"
|
||||
@wheel="onWheel">
|
||||
|
||||
<!-- Ensure Aspect Ratio is preserved to prevent stretching -->
|
||||
<canvas ref="canvasRef" style="max-width: 100%; max-height: 100%; width: auto; height: auto; display: block; object-fit: contain;"></canvas>
|
||||
|
||||
<div v-if="!imageSrc" class="absolute inset-0 flex align-items-center justify-content-center text-white pointer-events-none">
|
||||
Brak obrazu
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="absolute top-0 left-0 m-2 flex gap-1 z-3">
|
||||
<button class="bg-black-alpha-60 text-white border-none border-round px-2 py-1 cursor-pointer hover:bg-black-alpha-80" @click="scale = Math.min(scale + 0.5, 5); draw()">+</button>
|
||||
<button class="bg-black-alpha-60 text-white border-none border-round px-2 py-1 cursor-pointer hover:bg-black-alpha-80" @click="scale = Math.max(scale - 0.5, 0.1); draw()">-</button>
|
||||
<button class="bg-black-alpha-60 text-white border-none border-round px-2 py-1 cursor-pointer text-xs hover:bg-black-alpha-80" @click="resetZoom">1:1</button>
|
||||
<div class="bg-black-alpha-60 text-white border-round px-2 py-1 text-xs select-none">Shift+Drag to Pan</div>
|
||||
</div>
|
||||
|
||||
<!-- Guidelines Overlay -->
|
||||
<div class="absolute top-0 right-0 p-3 m-3 bg-black-alpha-60 text-white text-xs border-round pointer-events-none" style="max-width: 200px;">
|
||||
<div class="font-bold mb-1">Wytyczne:</div>
|
||||
<div>• Kamera na wys. bioder (90-100cm)</div>
|
||||
<div>• Odległość 2-3 metry</div>
|
||||
<div>• Ustaw pionowo</div>
|
||||
</div>
|
||||
|
||||
<!-- Loupe: Reduced size and zoom -->
|
||||
<div class="absolute border-2 border-primary border-round overflow-hidden shadow-4 bg-black"
|
||||
style="width: 150px; height: 150px; pointer-events: none; z-index: 100;"
|
||||
:style="{ left: '20px', bottom: '20px', display: showLoupe ? 'block' : 'none' }">
|
||||
<canvas ref="loupeCanvas" width="150" height="150"></canvas>
|
||||
<div class="absolute top-0 left-0 bg-primary text-xs px-1">1.5x</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
160
src/app/composables/medicalMetrics.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { Point } from './usePostureAnalysis';
|
||||
|
||||
// Constants
|
||||
export const THRESHOLDS = {
|
||||
TILT_WARN: 1.5, // degrees
|
||||
CVA_NORM: 50, // degrees
|
||||
TRUNK_LEAN_WARN: 2.0 // degrees (estimate)
|
||||
};
|
||||
|
||||
// --- Helper Math ---
|
||||
const dist = (a: Point, b: Point) => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
|
||||
// vector removed
|
||||
|
||||
const degrees = (radians: number) => radians * (180 / Math.PI);
|
||||
|
||||
// Angle of vector relative to horizontal (0 degrees = Right, 90 = Down in image coords usually, -90 Up)
|
||||
// We want standard math: 0=Right, 90=Up?
|
||||
// Image coords: Y grows down.
|
||||
// Math.atan2(dy, dx).
|
||||
// If we want "Tilt" from horizontal: abs(atan2(dy, dx))
|
||||
export const calculateTilt = (p1: Point, p2: Point): number => {
|
||||
if (!p1 || !p2) return 0;
|
||||
const dy = p2.y - p1.y; // Y is down
|
||||
const dx = p2.x - p1.x;
|
||||
return degrees(Math.atan2(dy, dx));
|
||||
};
|
||||
|
||||
// CVA: Angle between Horizontal and C7->Tragus
|
||||
// C7 is pivot.
|
||||
// Vector V = Tragus - C7.
|
||||
// Angle = angle of V with Horizontal (-1, 0) (Posterior horizontal line)
|
||||
export const calculateCVA = (c7: Point, tragus: Point): number => {
|
||||
if (!c7 || !tragus) return 0;
|
||||
// In image coords (Y down), Horizontal Posterior line from C7 (assuming facing Right) is (-1, 0).
|
||||
// Wait, usually CVA is measured on Side view.
|
||||
// If facing Right: Ear is to right of C7? No, Ear is above and slightly forward/back.
|
||||
// CVA is formed by horizontal line at C7 and the line to the ear.
|
||||
// Ideally it's ~50 degrees upwards from horizontal.
|
||||
|
||||
// Vector C7 -> Tragus
|
||||
const dx = tragus.x - c7.x;
|
||||
const dy = tragus.y - c7.y; // negative if ear is above C7
|
||||
|
||||
// We want angle against Horizontal.
|
||||
// atan2(-dy, dx) gives angle in standard math coords (Y increasing up).
|
||||
// Let's use simple atan2 of vector.
|
||||
// If facing RIGHT: vector is (+x, -y). Angle ~ 45-60 deg.
|
||||
// If facing LEFT: vector is (-x, -y). Angle ~ 120-135 deg?
|
||||
// We want the inner angle relative to the horizontal line pointing BACKWARDS from the neck?
|
||||
// Standard definition: Angle formed by horizontal line at C7 and line extending to Tragus.
|
||||
// Usually measured from the horizontal looking forward? Or horizontal looking back?
|
||||
// CVA < 50 is bad (head forward).
|
||||
// If neck is perfectly vertical (impossible), angle is 90.
|
||||
// If neck is horizontal (turtle), angle is 0.
|
||||
// So it's angle from horizontal.
|
||||
// We assume we want absolute angle from horizontal plane.
|
||||
return Math.abs(degrees(Math.atan2(-dy, Math.abs(dx))));
|
||||
};
|
||||
|
||||
// --- Landmark Estimations ---
|
||||
|
||||
export const estimateC7 = (shoulderL: Point, shoulderR: Point, nose: Point): Point => {
|
||||
if (!shoulderL || !shoulderR || !nose) return { x: 0, y: 0 };
|
||||
|
||||
const midShoulder = {
|
||||
x: (shoulderL.x + shoulderR.x) / 2,
|
||||
y: (shoulderL.y + shoulderR.y) / 2
|
||||
};
|
||||
|
||||
// Distance Nose to MidShoulder
|
||||
const neckLen = dist(nose, midShoulder);
|
||||
|
||||
// Shift Up (negative Y) by 15%
|
||||
// "przesunąć w górę o ok. 15% długości szyi"
|
||||
// We assume C7 is roughly where the neck starts, MP shoulders are joint centers (lower).
|
||||
// Actually C7 is slightly *above* the line connecting shoulder joints in 2D projection?
|
||||
// Or is it? Acromion is high. C7 is often level or slightly above.
|
||||
// Heuristic: Move UP.
|
||||
return {
|
||||
x: midShoulder.x,
|
||||
y: midShoulder.y - (neckLen * 0.20) // 20% feels safer for C7 vs shoulder center line
|
||||
};
|
||||
};
|
||||
|
||||
export const correctASIS = (hipL: Point, hipR: Point, shoulderL: Point, shoulderR: Point): { l: Point, r: Point } => {
|
||||
if (!hipL || !hipR || !shoulderL || !shoulderR) return { l: hipL, r: hipR };
|
||||
|
||||
const shoulderWidth = dist(shoulderL, shoulderR);
|
||||
const offset = shoulderWidth * 0.15;
|
||||
|
||||
// Move Outwards
|
||||
// Left Hip (on screen left usually) -> Move Left (-x)
|
||||
// Right Hip -> Move Right (+x)
|
||||
// Assuming Front view where Left Hip is on right side of image?
|
||||
// MP: LEFT_HIP is person's left.
|
||||
// If person faces cam: LEFT_HIP is on Image Right.
|
||||
// So we need to check x coords to determine "Outwards".
|
||||
|
||||
// We assume MP Hips (23, 24) are internal.
|
||||
// ASIS is lateral.
|
||||
// Shift Outwards by 15% of Shoulder Width.
|
||||
// Direction depend on which hip is left/right on SCREEN.
|
||||
|
||||
// Determine center of hips
|
||||
const hipCenter = (hipL.x + hipR.x) / 2;
|
||||
|
||||
// HipL is usually on the Right side of the screen if Front View (Subject's Left).
|
||||
// But MP documentation says:
|
||||
// 23 - left hip
|
||||
// 24 - right hip
|
||||
// If user faces camera: 23 is on Screen Right (larger X), 24 is on Screen Left (smaller X).
|
||||
|
||||
// Let's just use the vector HipR -> HipL (Lateral direction).
|
||||
// If Front View: 24(R) -> 23(L) is vector pointing RIGHT (positive X).
|
||||
// If Back View: 23(L) -> 24(R) is vector pointing RIGHT.
|
||||
|
||||
// Safer: Move away from center.
|
||||
const moveFromCenter = (pt: Point, centerX: number, amount: number) => {
|
||||
if (pt.x < centerX) return { ...pt, x: pt.x - amount }; // Move Left
|
||||
else return { ...pt, x: pt.x + amount }; // Move Right
|
||||
};
|
||||
|
||||
const newL = moveFromCenter(hipL, hipCenter, offset);
|
||||
const newR = moveFromCenter(hipR, hipCenter, offset);
|
||||
|
||||
return { l: newL, r: newR };
|
||||
};
|
||||
|
||||
export const calculateTrunkLean = (midShoulder: Point, midHip: Point): number => {
|
||||
// Angle against Vertical
|
||||
if (!midShoulder || !midHip) return 0;
|
||||
const dx = midShoulder.x - midHip.x;
|
||||
const dy = midShoulder.y - midHip.y; // usually negative
|
||||
// Vertical is 90.
|
||||
const angle = degrees(Math.atan2(Math.abs(dy), Math.abs(dx))); // 90 is vertical
|
||||
return Math.abs(90 - angle);
|
||||
};
|
||||
|
||||
export const calculateATSI = (c7: Point, s1: Point, shoulderL: Point, shoulderR: Point, _hipL: Point, _hipR: Point): number => {
|
||||
/*
|
||||
ATSI (Anterior Trunk Symmetry Index) simplified:
|
||||
Asymmetry of areas/distances.
|
||||
Let's check lateral deviation of C7-S1 axis vs center of body blocks.
|
||||
Or simpler: (Dist(C7, AcromionL) - Dist(C7, AcromionR)) / Mean...
|
||||
User req: "różnice odległościowe między lewą a prawą stroną tułowia względem linii pośrodkowej"
|
||||
*/
|
||||
if (!c7 || !s1) return 0;
|
||||
|
||||
// Only feasible if we have solid Axis.
|
||||
// Let's implement simple "Trunk Asymmetry":
|
||||
// |( ShoulderL.x - Axis )| - |( ShoulderR.x - Axis )|
|
||||
// Axis defined by S1 vertical? Or C7-S1 line.
|
||||
|
||||
// Let's approximate by Waist Triangle diff provided earlier, that's standard POTSI component.
|
||||
// Here: return Asymmetry of Shoulders relative to C7 X-coord.
|
||||
const distL = Math.abs(shoulderL.x - c7.x);
|
||||
const distR = Math.abs(shoulderR.x - c7.x);
|
||||
return Math.abs(distL - distR);
|
||||
};
|
||||
42
src/app/composables/useActiveWorkout.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
// Duplicate types for now (ideally share from TrainingsView or a types file)
|
||||
export interface TrainingExercise {
|
||||
instanceId: string;
|
||||
exerciseId: string;
|
||||
name: string;
|
||||
sets: number;
|
||||
value: number; // reps or time
|
||||
isTime: boolean;
|
||||
rest: number; // seconds
|
||||
}
|
||||
|
||||
export interface TrainingSection {
|
||||
id: string;
|
||||
name: string;
|
||||
exercises: TrainingExercise[];
|
||||
}
|
||||
|
||||
export interface TrainingPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
sections: TrainingSection[];
|
||||
}
|
||||
|
||||
const activePlan = ref<TrainingPlan | null>(null);
|
||||
|
||||
export function useActiveWorkout() {
|
||||
const startWorkout = (plan: TrainingPlan) => {
|
||||
activePlan.value = JSON.parse(JSON.stringify(plan));
|
||||
};
|
||||
|
||||
const clearWorkout = () => {
|
||||
activePlan.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
activePlan,
|
||||
startWorkout,
|
||||
clearWorkout
|
||||
};
|
||||
}
|
||||
53
src/app/composables/useAnalysis.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ref } from 'vue';
|
||||
import { api } from '../api';
|
||||
import type { AnalysisSession, Annotation } from '../types';
|
||||
|
||||
// Global State
|
||||
const sessions = ref<AnalysisSession[]>([]);
|
||||
const isLoaded = ref(false);
|
||||
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
sessions.value = await api.getAnalysisSessions();
|
||||
isLoaded.value = true;
|
||||
} catch (e) {
|
||||
console.error("Failed to load analysis sessions", e);
|
||||
}
|
||||
};
|
||||
|
||||
export function useAnalysis() {
|
||||
// Initial fetch
|
||||
if (!isLoaded.value) {
|
||||
// Don't mark true immediately to avoid race condition if logic changes,
|
||||
// but currently safe as we await in separate flow or just trigger.
|
||||
loadSessions();
|
||||
// Note: We don't block component mounting on this.
|
||||
}
|
||||
|
||||
const refreshSessions = async () => {
|
||||
await loadSessions();
|
||||
};
|
||||
|
||||
const getAnnotations = async (sessionId: string): Promise<Annotation[]> => {
|
||||
return await api.getAnnotations(sessionId);
|
||||
};
|
||||
|
||||
const deleteSession = async (id: string) => {
|
||||
await api.deleteAnalysisSession(id);
|
||||
sessions.value = sessions.value.filter(s => s.id !== id);
|
||||
};
|
||||
|
||||
const createSession = async (name: string, videoPath: string | null) => {
|
||||
const newSession = await api.createAnalysisSession(name, videoPath);
|
||||
sessions.value.unshift(newSession);
|
||||
return newSession;
|
||||
}
|
||||
|
||||
return {
|
||||
sessions,
|
||||
refreshSessions,
|
||||
getAnnotations,
|
||||
deleteSession,
|
||||
createSession
|
||||
};
|
||||
}
|
||||
289
src/app/composables/useChatAI.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { ref } from 'vue';
|
||||
import { api } from '../api';
|
||||
import { VectorStore } from '../services/vectorStore';
|
||||
import type { ChatSession } from '../types';
|
||||
|
||||
// Constants
|
||||
const SERVER_URL = 'http://127.0.0.1:8080/v1/chat/completions';
|
||||
const BASE_SYSTEM_PROMPT = `Jesteś profesjonalnym trenerem personalnym i dietetykiem.
|
||||
Twoim celem jest pomaganie użytkownikowi w osiągnięciu celów sylwetkowych i zdrowotnych.
|
||||
Analizuj dostarczony kontekst (np. pliki z bazy wiedzy) i odpowiadaj precyzyjnie.
|
||||
Formatuj odpowiedzi używając Markdown. Bądź konkretny i pomocny.
|
||||
|
||||
WAŻNE: NIE używaj żadnych narzędzi (functions/tools). Odpowiadaj TYLKO tekstem naturalnym.
|
||||
NIE zwracaj formatu JSON.`;
|
||||
|
||||
// Types
|
||||
export interface ThinkingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface UiChatMessage {
|
||||
id?: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
created_at?: string;
|
||||
steps?: ThinkingStep[];
|
||||
}
|
||||
|
||||
// Global State
|
||||
const messages = ref<UiChatMessage[]>([]);
|
||||
const sessions = ref<ChatSession[]>([]);
|
||||
const currentSessionId = ref<string | null>(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
export function useChatAI() {
|
||||
|
||||
const initEngine = async () => {
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const data = await api.getChatSessions();
|
||||
sessions.value = data;
|
||||
} catch (e) {
|
||||
console.error("Failed to load sessions", e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSession = async (sessionId: string) => {
|
||||
try {
|
||||
const history = await api.getChatHistory(sessionId);
|
||||
currentSessionId.value = sessionId;
|
||||
|
||||
const uiHistory: UiChatMessage[] = history.map(h => ({
|
||||
id: h.id,
|
||||
role: h.role,
|
||||
content: h.content,
|
||||
created_at: h.createdAt
|
||||
}));
|
||||
|
||||
if (uiHistory.length === 0) {
|
||||
messages.value = [{ role: 'system', content: BASE_SYSTEM_PROMPT }];
|
||||
} else {
|
||||
messages.value = uiHistory;
|
||||
if (messages.value.length > 0 && messages.value[0].role !== 'system') {
|
||||
messages.value.unshift({ role: 'system', content: BASE_SYSTEM_PROMPT });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load session history", e);
|
||||
}
|
||||
};
|
||||
|
||||
const createNewChat = () => {
|
||||
currentSessionId.value = null;
|
||||
messages.value = [{ role: 'system', content: BASE_SYSTEM_PROMPT }];
|
||||
};
|
||||
|
||||
const deleteSession = async (id: string) => {
|
||||
try {
|
||||
await api.deleteChatSession(id);
|
||||
if (currentSessionId.value === id) {
|
||||
createNewChat();
|
||||
}
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
console.error("Failed to delete session", e);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSessionTitle = async (id: string, title: string) => {
|
||||
try {
|
||||
await api.updateChatTitle(id, title);
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
console.error("Failed to update title", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to manage Thinking Steps (On Reactive Objects)
|
||||
const addStep = (msg: UiChatMessage, title: string): ThinkingStep => {
|
||||
const step: ThinkingStep = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
title,
|
||||
status: 'running'
|
||||
};
|
||||
if (!msg.steps) msg.steps = [];
|
||||
msg.steps.push(step);
|
||||
// Return the reactive proxy from within the array
|
||||
return msg.steps[msg.steps.length - 1];
|
||||
};
|
||||
|
||||
const updateStep = (msg: UiChatMessage, stepId: string, updates: Partial<ThinkingStep>) => {
|
||||
// msg must be the reactive object
|
||||
const step = msg.steps?.find(s => s.id === stepId);
|
||||
if (step) Object.assign(step, updates);
|
||||
};
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim()) return;
|
||||
|
||||
// 1. Create session if needed
|
||||
if (!currentSessionId.value) {
|
||||
try {
|
||||
const title = text.slice(0, 30) + (text.length > 30 ? "..." : "");
|
||||
const newSession = await api.createChatSession(title);
|
||||
currentSessionId.value = newSession.id;
|
||||
sessions.value.unshift(newSession);
|
||||
} catch (e) {
|
||||
console.error("Failed to create session", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = currentSessionId.value!;
|
||||
|
||||
// 2. Add User Message (Push then retrieve reactive proxy to ensure reactivity)
|
||||
messages.value.push({ role: 'user', content: text });
|
||||
const userMsg = messages.value[messages.value.length - 1];
|
||||
|
||||
// Save to DB
|
||||
api.saveChatMessage(sessionId, 'user', text).then(saved => {
|
||||
userMsg.id = saved.id;
|
||||
userMsg.created_at = saved.createdAt;
|
||||
}).catch(console.error);
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
// 3. Create Assistant Message Placeholder (Reactive)
|
||||
messages.value.push({ role: 'assistant', content: '', steps: [] });
|
||||
const replyMessage = messages.value[messages.value.length - 1]; // Important: Get the reactive proxy
|
||||
|
||||
try {
|
||||
// --- RAG PROCESS ---
|
||||
let contextText = "";
|
||||
|
||||
const searchStep = addStep(replyMessage, "Przeszukiwanie bazy wiedzy...");
|
||||
|
||||
try {
|
||||
// Ensure text is passed correctly
|
||||
const results = await VectorStore.search(text, 3);
|
||||
|
||||
if (results.length > 0) {
|
||||
updateStep(replyMessage, searchStep.id, {
|
||||
status: 'completed',
|
||||
title: `Znaleziono ${results.length} dokumentów`,
|
||||
details: results.map(r => `${r.document.path} (${(r.score * 100).toFixed(0)}%)`).join('\n')
|
||||
});
|
||||
|
||||
contextText = results.map(r => r.document.content).join("\n\n---\n\n");
|
||||
} else {
|
||||
updateStep(replyMessage, searchStep.id, {
|
||||
status: 'completed',
|
||||
title: "Brak pasujących dokumentów w bazie wiedzy",
|
||||
details: "Odpowiadam na podstawie wiedzy ogólnej."
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Vector search failed", e);
|
||||
updateStep(replyMessage, searchStep.id, {
|
||||
status: 'error',
|
||||
title: "Błąd przeszukiwania bazy",
|
||||
details: String(e)
|
||||
});
|
||||
}
|
||||
|
||||
// --- LLM GENERATION ---
|
||||
const genStep = addStep(replyMessage, "Generowanie odpowiedzi...");
|
||||
|
||||
// Prepare messages payload (Clone to avoid mutating UI state with context)
|
||||
const messagesPayload = messages.value
|
||||
.filter(m => m !== replyMessage) // Exclude current empty
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
|
||||
// Inject Context
|
||||
if (contextText) {
|
||||
// Find system prompt
|
||||
const systemMsgIndex = messagesPayload.findIndex(m => m.role === 'system');
|
||||
if (systemMsgIndex !== -1) {
|
||||
messagesPayload[systemMsgIndex] = {
|
||||
...messagesPayload[systemMsgIndex],
|
||||
content: messagesPayload[systemMsgIndex].content + `\n\n### KONTEKST BAZY WIEDZY:\n${contextText}`
|
||||
};
|
||||
} else {
|
||||
messagesPayload.unshift({
|
||||
role: 'system',
|
||||
content: `${BASE_SYSTEM_PROMPT}\n\n### KONTEKST BAZY WIEDZY:\n${contextText}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(SERVER_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: messagesPayload,
|
||||
stream: true,
|
||||
temperature: 0.7,
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Server error: " + response.statusText);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
updateStep(replyMessage, genStep.id, { status: 'running', title: "Pisanie..." });
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const dataStr = line.replace('data: ', '').trim();
|
||||
if (dataStr === '[DONE]') break;
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
const delta = data.choices[0]?.delta?.content || "";
|
||||
replyMessage.content += delta; // Reactive update
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateStep(replyMessage, genStep.id, { status: 'completed', title: "Wygenerowano odpowiedź" });
|
||||
|
||||
// Save to DB
|
||||
api.saveChatMessage(sessionId, 'assistant', replyMessage.content).then(saved => {
|
||||
replyMessage.id = saved.id;
|
||||
replyMessage.created_at = saved.createdAt;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error);
|
||||
replyMessage.content += "\n\n[Błąd komunikacji z modelem AI]";
|
||||
if (replyMessage.steps) {
|
||||
const lastStep = replyMessage.steps[replyMessage.steps.length - 1];
|
||||
if (lastStep) lastStep.status = 'error';
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
messages,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
isLoading,
|
||||
initEngine,
|
||||
loadSessions,
|
||||
loadSession,
|
||||
createNewChat,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
sendMessage,
|
||||
};
|
||||
}
|
||||
82
src/app/composables/useExercises.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ref } from 'vue';
|
||||
import { api } from '../api';
|
||||
import type { Exercise as ApiExercise } from '../types';
|
||||
|
||||
export interface Exercise {
|
||||
id: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
enrichment: string;
|
||||
tags: string[];
|
||||
videoUrl: string;
|
||||
}
|
||||
|
||||
// Global state
|
||||
const exercises = ref<Exercise[]>([]);
|
||||
const isLoaded = ref(false);
|
||||
|
||||
const mapToFrontend = (e: ApiExercise): Exercise => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
instructions: e.instructions,
|
||||
enrichment: e.enrichment,
|
||||
tags: e.tags ? JSON.parse(e.tags) : [],
|
||||
videoUrl: e.videoUrl
|
||||
});
|
||||
|
||||
const mapToBackend = (e: Omit<Exercise, 'id'>) => ({
|
||||
name: e.name,
|
||||
instructions: e.instructions,
|
||||
enrichment: e.enrichment,
|
||||
tags: JSON.stringify(e.tags),
|
||||
videoUrl: e.videoUrl || ''
|
||||
});
|
||||
|
||||
const loadExercises = async () => {
|
||||
// if (isLoaded.value) return; // Removed to fix race condition
|
||||
try {
|
||||
const data = await api.getExercises();
|
||||
exercises.value = data.map(mapToFrontend);
|
||||
isLoaded.value = true;
|
||||
} catch (e) {
|
||||
console.error("Failed to load exercises", e);
|
||||
}
|
||||
};
|
||||
|
||||
export function useExercises() {
|
||||
if (!isLoaded.value) {
|
||||
isLoaded.value = true; // Prevent double load
|
||||
loadExercises();
|
||||
}
|
||||
|
||||
const addExercise = async (exercise: Omit<Exercise, 'id'>) => {
|
||||
const apiExercise = mapToBackend(exercise);
|
||||
const newEx = await api.addExercise(apiExercise);
|
||||
const newUiEx = mapToFrontend(newEx);
|
||||
exercises.value.push(newUiEx);
|
||||
return newUiEx;
|
||||
};
|
||||
|
||||
const updateExercise = async (exercise: Exercise) => {
|
||||
// We need to construct the full API object including ID
|
||||
const backendPayload = { ...mapToBackend(exercise), id: exercise.id };
|
||||
await api.updateExercise(backendPayload);
|
||||
const index = exercises.value.findIndex(e => e.id === exercise.id);
|
||||
if (index !== -1) {
|
||||
exercises.value[index] = exercise;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteExercise = async (id: string) => {
|
||||
await api.deleteExercise(id);
|
||||
exercises.value = exercises.value.filter(e => e.id !== id);
|
||||
};
|
||||
|
||||
return {
|
||||
exercises, // Expose compatible exercises
|
||||
addExercise,
|
||||
updateExercise,
|
||||
deleteExercise,
|
||||
refreshExercises: loadExercises
|
||||
};
|
||||
}
|
||||
131
src/app/composables/useLocalModel.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { ref } from 'vue';
|
||||
import { BaseDirectory, exists, mkdir, writeFile, readFile } from '@tauri-apps/plugin-fs';
|
||||
import { join, appLocalDataDir } from '@tauri-apps/api/path';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
export const MODEL_ID = "Llama-3.2-3B-Instruct-q4f32_1-MLC";
|
||||
const REPO_URL = "https://huggingface.co/mlc-ai/Llama-3.2-3B-Instruct-q4f32_1-MLC/resolve/main/";
|
||||
|
||||
export const useLocalModel = () => {
|
||||
const downloadProgress = ref<string>("");
|
||||
const isDownloading = ref(false);
|
||||
const downloadPercent = ref(0);
|
||||
|
||||
const getModelPath = async () => {
|
||||
return await join("models", MODEL_ID, "resolve", "main");
|
||||
};
|
||||
|
||||
const checkModelExists = async () => {
|
||||
try {
|
||||
const modelPath = await getModelPath();
|
||||
// Check for key config files
|
||||
const configExists = await exists(await join(modelPath, "mlc-chat-config.json"), { baseDir: BaseDirectory.AppLocalData });
|
||||
const paramsExists = await exists(await join(modelPath, "ndarray-cache.json"), { baseDir: BaseDirectory.AppLocalData });
|
||||
return configExists && paramsExists;
|
||||
} catch (e) {
|
||||
console.error("Error checking model existence:", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = async (filename: string, modelDir: string) => {
|
||||
const url = `${REPO_URL}${filename}`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
|
||||
|
||||
const blob = await response.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
await writeFile(await join(modelDir, filename), uint8Array, { baseDir: BaseDirectory.AppLocalData });
|
||||
} catch (e) {
|
||||
console.error(`Failed to download ${filename}`, e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadModel = async () => {
|
||||
if (isDownloading.value) return;
|
||||
isDownloading.value = true;
|
||||
downloadProgress.value = "Starting download...";
|
||||
downloadPercent.value = 0;
|
||||
|
||||
try {
|
||||
const modelDir = await getModelPath();
|
||||
// Create directory
|
||||
if (!(await exists(modelDir, { baseDir: BaseDirectory.AppLocalData }))) {
|
||||
await mkdir(modelDir, { baseDir: BaseDirectory.AppLocalData, recursive: true });
|
||||
}
|
||||
|
||||
// 1. Download basic configs
|
||||
const configFiles = ["mlc-chat-config.json", "ndarray-cache.json", "tokenizer.json", "tokenizer_config.json"];
|
||||
|
||||
for (let i = 0; i < configFiles.length; i++) {
|
||||
downloadProgress.value = `Downloading config: ${configFiles[i]}`;
|
||||
await downloadFile(configFiles[i], modelDir);
|
||||
}
|
||||
|
||||
// 2. Read ndarray-cache.json to find shards
|
||||
const cacheContent = await readFile(await join(modelDir, "ndarray-cache.json"), { baseDir: BaseDirectory.AppLocalData });
|
||||
const textDecoder = new TextDecoder();
|
||||
const cacheJson = JSON.parse(textDecoder.decode(cacheContent));
|
||||
|
||||
const records = cacheJson.records || [];
|
||||
// Filter for weight files (usually params_shard_*.bin)
|
||||
const shards = records.map((r: any) => r.dataPath);
|
||||
|
||||
const totalFiles = shards.length;
|
||||
for (let i = 0; i < totalFiles; i++) {
|
||||
const shard = shards[i];
|
||||
downloadProgress.value = `Downloading shard ${i + 1}/${totalFiles}: ${shard}`;
|
||||
await downloadFile(shard, modelDir);
|
||||
downloadPercent.value = Math.round(((i + 1) / totalFiles) * 100);
|
||||
}
|
||||
|
||||
downloadProgress.value = "Download complete!";
|
||||
isDownloading.value = false;
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
downloadProgress.value = `Download failed: ${error}`;
|
||||
isDownloading.value = false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getModelConfig = async () => {
|
||||
// AppLocalData path resolution
|
||||
// Note: convertFileSrc needs absolute path.
|
||||
const appDataPath = await appLocalDataDir();
|
||||
// Path matches model root
|
||||
const fullModelPath = await join(appDataPath, "models", MODEL_ID);
|
||||
|
||||
// We need to convert this to an asset URL
|
||||
// On Windows it will be http://asset.localhost/C:/Users/...
|
||||
const modelUrl = convertFileSrc(fullModelPath);
|
||||
|
||||
// Ensure trailing slash if needed? convertFileSrc usually doesn't add it.
|
||||
// WebLLM expects a directory URL usually.
|
||||
const modelUrlWithSlash = modelUrl.endsWith('/') ? modelUrl : `${modelUrl}/`;
|
||||
|
||||
return {
|
||||
model: modelUrlWithSlash,
|
||||
model_id: MODEL_ID, // Use original ID as we are now compliant with folder structure
|
||||
model_lib: "https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/main/web-llm-models/v0.2.46/Llama-3-8B-Instruct-q4f32_1-ctx4k_cs1k-webgpu.wasm",
|
||||
vram_required_MB: 6148,
|
||||
low_resource_required: false,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
checkModelExists,
|
||||
downloadModel,
|
||||
getModelConfig,
|
||||
downloadProgress,
|
||||
isDownloading,
|
||||
downloadPercent
|
||||
};
|
||||
};
|
||||
395
src/app/composables/usePostureAnalysis.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { ref } from 'vue';
|
||||
import { Pose, type Results } from '@mediapipe/pose';
|
||||
import * as Medical from './medicalMetrics';
|
||||
|
||||
// Landmark indices
|
||||
const LANDMARKS = {
|
||||
NOSE: 0,
|
||||
LEFT_EYE_INNER: 1, LEFT_EYE: 2, LEFT_EYE_OUTER: 3,
|
||||
RIGHT_EYE_INNER: 4, RIGHT_EYE: 5, RIGHT_EYE_OUTER: 6,
|
||||
LEFT_EAR: 7, RIGHT_EAR: 8,
|
||||
MOUTH_LEFT: 9, MOUTH_RIGHT: 10,
|
||||
LEFT_SHOULDER: 11, RIGHT_SHOULDER: 12,
|
||||
LEFT_ELBOW: 13, RIGHT_ELBOW: 14,
|
||||
LEFT_WRIST: 15, RIGHT_WRIST: 16,
|
||||
LEFT_HIP: 23, RIGHT_HIP: 24,
|
||||
LEFT_KNEE: 25, RIGHT_KNEE: 26,
|
||||
LEFT_ANKLE: 27, RIGHT_ANKLE: 28,
|
||||
LEFT_HEEL: 29, RIGHT_HEEL: 30,
|
||||
LEFT_FOOT_INDEX: 31, RIGHT_FOOT_INDEX: 32
|
||||
};
|
||||
|
||||
export interface Point { x: number; y: number; }
|
||||
|
||||
// Unified Posture Points (Manual + Auto combined)
|
||||
// Unified Posture Points (15-Point Sagittal Model + Front/Back support)
|
||||
export interface PosturePoints {
|
||||
// Standard Body (MediaPipe)
|
||||
nose: Point | null;
|
||||
eye_l: Point | null; eye_r: Point | null; // Added eyes for heuristics (Head Center etc)
|
||||
ear_l: Point | null; ear_r: Point | null;
|
||||
shoulder_l: Point | null; shoulder_r: Point | null;
|
||||
elbow_l: Point | null; elbow_r: Point | null;
|
||||
wrist_l: Point | null; wrist_r: Point | null;
|
||||
hip_l: Point | null; hip_r: Point | null;
|
||||
knee_l: Point | null; knee_r: Point | null;
|
||||
ankle_l: Point | null; ankle_r: Point | null;
|
||||
heel_l: Point | null; heel_r: Point | null;
|
||||
toe_l: Point | null; toe_r: Point | null;
|
||||
|
||||
// Derived / Specific anatomical points
|
||||
c7: Point | null;
|
||||
head_center: Point | null; // Pkt 1
|
||||
tmj: Point | null; // Pkt 3
|
||||
chin: Point | null; // Pkt 4
|
||||
neck: Point | null; // Pkt 5 (Cervical Lordosis Peak)
|
||||
kyphosis: Point | null; // Pkt 7 (Thoracic)
|
||||
lordosis: Point | null; // Pkt 8 (Lumbar)
|
||||
glute: Point | null; // Pkt 11
|
||||
s1: Point | null;
|
||||
}
|
||||
|
||||
export interface CalibrationData {
|
||||
p1: Point | null;
|
||||
p2: Point | null;
|
||||
realLengthMm: number;
|
||||
ratio: number | null; // mm per pixel
|
||||
}
|
||||
|
||||
export interface PostureAnalysisResult {
|
||||
points: PosturePoints;
|
||||
angles: Record<string, { value: number | string, status: 'norm' | 'warning' | 'error' }>;
|
||||
viewType: 'front' | 'back' | 'side_left' | 'side_right';
|
||||
calibration: CalibrationData;
|
||||
manualPlumbLineX: number | null; // User-defined vertical reference line (X-coordinate)
|
||||
}
|
||||
|
||||
const initialCalibration: CalibrationData = {
|
||||
p1: null, p2: null, realLengthMm: 1000, ratio: null
|
||||
};
|
||||
|
||||
const degrees = (radians: number) => radians * (180 / Math.PI);
|
||||
|
||||
export function usePostureAnalysis() {
|
||||
const pose = new Pose({
|
||||
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`
|
||||
});
|
||||
|
||||
pose.setOptions({
|
||||
modelComplexity: 1,
|
||||
smoothLandmarks: true,
|
||||
enableSegmentation: false,
|
||||
minDetectionConfidence: 0.5,
|
||||
minTrackingConfidence: 0.5
|
||||
});
|
||||
|
||||
const isReady = ref(false);
|
||||
pose.onResults(() => { isReady.value = true; });
|
||||
|
||||
// --- Helper to convert raw MP landmark to Pixel Point ---
|
||||
const toPoint = (lm: any, w: number, h: number): Point | null => {
|
||||
if (!lm || lm.visibility < 0.5) return null;
|
||||
return { x: lm.x * w, y: lm.y * h };
|
||||
};
|
||||
|
||||
const detectPoints = (landmarks: any[], w: number, h: number): PosturePoints => {
|
||||
const p = (idx: number) => toPoint(landmarks[idx], w, h);
|
||||
|
||||
const rawPoints: PosturePoints = {
|
||||
nose: p(LANDMARKS.NOSE),
|
||||
eye_l: p(LANDMARKS.LEFT_EYE), eye_r: p(LANDMARKS.RIGHT_EYE),
|
||||
ear_l: p(LANDMARKS.LEFT_EAR), ear_r: p(LANDMARKS.RIGHT_EAR),
|
||||
shoulder_l: p(LANDMARKS.LEFT_SHOULDER), shoulder_r: p(LANDMARKS.RIGHT_SHOULDER),
|
||||
elbow_l: p(LANDMARKS.LEFT_ELBOW), elbow_r: p(LANDMARKS.RIGHT_ELBOW),
|
||||
wrist_l: p(LANDMARKS.LEFT_WRIST), wrist_r: p(LANDMARKS.RIGHT_WRIST),
|
||||
hip_l: p(LANDMARKS.LEFT_HIP), hip_r: p(LANDMARKS.RIGHT_HIP),
|
||||
knee_l: p(LANDMARKS.LEFT_KNEE), knee_r: p(LANDMARKS.RIGHT_KNEE),
|
||||
ankle_l: p(LANDMARKS.LEFT_ANKLE), ankle_r: p(LANDMARKS.RIGHT_ANKLE),
|
||||
heel_l: p(LANDMARKS.LEFT_HEEL), heel_r: p(LANDMARKS.RIGHT_HEEL),
|
||||
toe_l: p(LANDMARKS.LEFT_FOOT_INDEX), toe_r: p(LANDMARKS.RIGHT_FOOT_INDEX),
|
||||
|
||||
c7: null, kyphosis: null, lordosis: null, s1: null, neck: null,
|
||||
head_center: null, tmj: null, chin: null, glute: null
|
||||
};
|
||||
|
||||
// --- derived points initialization happens in analyzeImage for view-specific logic ---
|
||||
return rawPoints;
|
||||
};
|
||||
|
||||
// Helper: Initialize derived points based on view
|
||||
const initializeDerivedPoints = (pts: PosturePoints, view: string) => {
|
||||
if (view.startsWith('side')) {
|
||||
const isLeft = view === 'side_left';
|
||||
const ear = isLeft ? pts.ear_l : pts.ear_r;
|
||||
const shoulder = isLeft ? pts.shoulder_l : pts.shoulder_r;
|
||||
const hip = isLeft ? pts.hip_l : pts.hip_r;
|
||||
const nose = pts.nose;
|
||||
|
||||
// Direction multiplier (Facing Right = 1, Left = -1)
|
||||
// If nose X > ear X => Facing Right.
|
||||
const facingRight = (nose && ear) ? (nose.x > ear.x) : true;
|
||||
const dir = facingRight ? 1 : -1;
|
||||
|
||||
if (ear) {
|
||||
// 1. Head Center (referencyjny w linii ucha, wyżej)
|
||||
if (!pts.head_center) pts.head_center = { x: ear.x, y: ear.y - 40 };
|
||||
|
||||
// 3. TMJ (Staw skroniowo-żuchwowy) - Forward & Down from ear
|
||||
if (!pts.tmj) pts.tmj = { x: ear.x + (15 * dir), y: ear.y + 10 };
|
||||
}
|
||||
|
||||
// 4. Chin (Podbródek)
|
||||
if (nose && ear) {
|
||||
if (!pts.chin) pts.chin = { x: nose.x, y: ear.y + 40 };
|
||||
} else if (ear) {
|
||||
if (!pts.chin) pts.chin = { x: ear.x + (40 * dir), y: ear.y + 40 };
|
||||
}
|
||||
|
||||
// 5. Neck (Szczyt lordozy szyjnej)
|
||||
if (ear && shoulder) {
|
||||
const midX = (ear.x + shoulder.x) / 2;
|
||||
const midY = (ear.y + shoulder.y) / 2;
|
||||
if (!pts.neck) pts.neck = { x: midX - (15 * dir), y: midY };
|
||||
}
|
||||
|
||||
// 6. C7 (Shoulder level approx)
|
||||
if (shoulder) {
|
||||
if (!pts.c7) pts.c7 = { x: shoulder.x - (10 * dir), y: shoulder.y - 15 };
|
||||
}
|
||||
|
||||
// 7. Kyphosis (Thoracic) & 8. Lordosis (Lumbar)
|
||||
// Heuristic between Shoulder and Hip
|
||||
if (shoulder && hip) {
|
||||
const dy = hip.y - shoulder.y;
|
||||
const tY = shoulder.y + dy * 0.35; // Thoracic height
|
||||
const lY = shoulder.y + dy * 0.70; // Lumbar height
|
||||
|
||||
// Kyphosis (Backwards), Lordosis (Forwards)
|
||||
if (!pts.kyphosis) pts.kyphosis = { x: shoulder.x - (25 * dir), y: tY };
|
||||
if (!pts.lordosis) pts.lordosis = { x: shoulder.x + (10 * dir), y: lY };
|
||||
}
|
||||
|
||||
// 11. Glute (Wyniosłość pośladków)
|
||||
if (hip) {
|
||||
if (!pts.glute) pts.glute = { x: hip.x - (40 * dir), y: hip.y + 20 };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const recalculateMetrics = (
|
||||
viewType: string,
|
||||
points: PosturePoints,
|
||||
calib: CalibrationData,
|
||||
manualPlumbX?: number | null
|
||||
) => {
|
||||
const angles: Record<string, { value: number | string, status: 'norm' | 'warning' | 'error' }> = {};
|
||||
|
||||
const toVal = (val: number, isMm: boolean = false): string => {
|
||||
if (isMm && calib.ratio) return (val * calib.ratio).toFixed(0) + ' mm';
|
||||
return val.toFixed(1) + (isMm ? ' px' : '°');
|
||||
};
|
||||
|
||||
const check = (name: string, val: number, normMin: number, normMax: number, isMm: boolean = false) => {
|
||||
let status: 'norm' | 'warning' | 'error' = 'norm';
|
||||
// ... existing check logic ...
|
||||
if (val < normMin || val > normMax) status = 'error';
|
||||
angles[name] = { value: toVal(val, isMm), status };
|
||||
};
|
||||
|
||||
// ...
|
||||
|
||||
// --- Analysis ---
|
||||
if (viewType === 'front' || viewType === 'back') {
|
||||
// ... existing front logic ...
|
||||
// 1. Shoulder Tilt
|
||||
if (points.shoulder_l && points.shoulder_r) {
|
||||
const tilt = Medical.calculateTilt(points.shoulder_l, points.shoulder_r);
|
||||
check("Nachylenie Barków", Math.abs(tilt), 0, Medical.THRESHOLDS.TILT_WARN);
|
||||
}
|
||||
|
||||
// 2. Pelvic Tilt
|
||||
if (points.hip_l && points.hip_r) {
|
||||
const tilt = Medical.calculateTilt(points.hip_l, points.hip_r);
|
||||
check("Nachylenie Miednicy", Math.abs(tilt), 0, Medical.THRESHOLDS.TILT_WARN);
|
||||
}
|
||||
|
||||
// 3. Trunk Lean
|
||||
if (points.shoulder_l && points.shoulder_r && points.hip_l && points.hip_r) {
|
||||
const midS = { x: (points.shoulder_l.x + points.shoulder_r.x) / 2, y: (points.shoulder_l.y + points.shoulder_r.y) / 2 };
|
||||
const midH = { x: (points.hip_l.x + points.hip_r.x) / 2, y: (points.hip_l.y + points.hip_r.y) / 2 };
|
||||
const lean = Medical.calculateTrunkLean(midS, midH);
|
||||
check("Przechylenie Tułowia", lean, 0, Medical.THRESHOLDS.TRUNK_LEAN_WARN);
|
||||
}
|
||||
|
||||
// 4. ATSI
|
||||
if (points.c7 && points.s1 && points.shoulder_l && points.shoulder_r && points.hip_l && points.hip_r) {
|
||||
const asym = Medical.calculateATSI(points.c7, points.s1, points.shoulder_l, points.shoulder_r, points.hip_l, points.hip_r);
|
||||
const valMm = calib.ratio ? asym * calib.ratio : asym;
|
||||
const limit = calib.ratio ? 15 : 30;
|
||||
if (valMm > limit) angles["Asymetria Tułowia"] = { value: toVal(asym, true), status: 'error' };
|
||||
else angles["Asymetria Tułowia"] = { value: toVal(asym, true), status: 'norm' };
|
||||
}
|
||||
|
||||
} else if (viewType.startsWith('side')) {
|
||||
const isLeft = viewType === 'side_left';
|
||||
const ear = isLeft ? points.ear_l : points.ear_r;
|
||||
const shoulder = isLeft ? points.shoulder_l : points.shoulder_r;
|
||||
const hip = isLeft ? points.hip_l : points.hip_r;
|
||||
const knee = isLeft ? points.knee_l : points.knee_r;
|
||||
const ankle = isLeft ? points.ankle_l : points.ankle_r;
|
||||
|
||||
// 1. CVA
|
||||
if (points.c7 && ear) {
|
||||
const cva = Medical.calculateCVA(points.c7, ear);
|
||||
if (cva < Medical.THRESHOLDS.CVA_NORM) angles["Kąt CVA"] = { value: cva.toFixed(1) + '°', status: 'error' };
|
||||
else angles["Kąt CVA"] = { value: cva.toFixed(1) + '°', status: 'norm' };
|
||||
}
|
||||
|
||||
// 2. FHP (Forward Head Posture)
|
||||
if (ear && shoulder) {
|
||||
const dist = Math.abs(ear.x - shoulder.x);
|
||||
const isForward = isLeft ? (ear.x < shoulder.x) : (ear.x > shoulder.x);
|
||||
if (isForward) {
|
||||
const valMm = calib.ratio ? dist * calib.ratio : dist;
|
||||
const limit = calib.ratio ? 25 : 40;
|
||||
if (valMm > limit) angles["FHP (Wysunięcie)"] = { value: toVal(dist, true), status: 'error' };
|
||||
else angles["FHP"] = { value: toVal(dist, true), status: 'norm' };
|
||||
} else {
|
||||
angles["FHP"] = { value: "Norma", status: 'norm' };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Knee Extension
|
||||
if (hip && knee && ankle) {
|
||||
const ang = Math.abs(degrees(Math.atan2(ankle.y - knee.y, ankle.x - knee.x) - Math.atan2(hip.y - knee.y, hip.x - knee.x)));
|
||||
// const kneeAngle = ang > 180 ? 360 - ang : ang; (Old deviation logic)
|
||||
const kneeAngle = ang; // Raw angle ~180
|
||||
|
||||
// Warn if significant deviation (e.g. <170 or >190)
|
||||
if (kneeAngle < 170 || kneeAngle > 190) {
|
||||
check("Kąt Kolanowy", kneeAngle, 170, 190);
|
||||
} else {
|
||||
angles["Kąt Kolanowy"] = { value: kneeAngle.toFixed(0) + '°', status: 'norm' };
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Plumb Line Analysis (Linear Deviations) ---
|
||||
// Use manual if available, else Head Center (Red Line)
|
||||
const refRedX = points.head_center ? points.head_center.x : (isLeft ? points.ear_l?.x : points.ear_r?.x);
|
||||
const refX = (manualPlumbX !== undefined && manualPlumbX !== null) ? manualPlumbX : refRedX;
|
||||
|
||||
if (refX !== undefined && refX !== null) {
|
||||
|
||||
// Determine Facing Direction (Nose vs Ear)
|
||||
// If Nose X < Ear X => Facing Left. Forward is Negative X direction (smaller X).
|
||||
// If Nose X > Ear X => Facing Right. Forward is Positive X direction.
|
||||
const nose = points.nose;
|
||||
let facingRight = true;
|
||||
if (nose && ear) facingRight = nose.x > ear.x;
|
||||
|
||||
const getDeviation = (pt: Point, name: string) => {
|
||||
const rawDiff = pt.x - refX;
|
||||
// Normalize: Positive = Forward, Negative = Backward
|
||||
const val = facingRight ? rawDiff : -rawDiff;
|
||||
|
||||
// Value in mm (if calibrated) or px
|
||||
const valStr = toVal(val, true);
|
||||
|
||||
// Thresholds (Medical Norms roughly: alignment +/- 2cm)
|
||||
const limit = calib.ratio ? 20 : 30; // 20mm
|
||||
const status = Math.abs(val * (calib.ratio || 1)) > limit ? 'warning' : 'norm';
|
||||
|
||||
angles[`Odstęp ${name}`] = { value: (val > 0 ? '+' : '') + valStr, status };
|
||||
};
|
||||
|
||||
// Add requested points
|
||||
// Add requested points ONLY
|
||||
if (points.neck) getDeviation(points.neck, "Szczyt lordozy szyjnej");
|
||||
if (points.kyphosis) getDeviation(points.kyphosis, "Szczyt kifozy piersiowej");
|
||||
if (points.lordosis) getDeviation(points.lordosis, "Szczyt lordozy lędźwiowej");
|
||||
if (points.glute) getDeviation(points.glute, "Wyniosłość pośladków");
|
||||
}
|
||||
}
|
||||
|
||||
return angles;
|
||||
};
|
||||
|
||||
const filterPointsByView = (pts: PosturePoints, view: string): PosturePoints => {
|
||||
const filtered = { ...pts };
|
||||
if (view === 'side_left') {
|
||||
// Keep Left, Remove Right
|
||||
filtered.ear_r = null; filtered.shoulder_r = null; filtered.elbow_r = null; filtered.wrist_r = null;
|
||||
filtered.hip_r = null; filtered.knee_r = null; filtered.ankle_r = null;
|
||||
// Strict: Remove Opposite Foot, Nose, Eyes
|
||||
filtered.heel_r = null; filtered.toe_r = null;
|
||||
filtered.nose = null; filtered.eye_l = null; filtered.eye_r = null;
|
||||
} else if (view === 'side_right') {
|
||||
// Keep Right, Remove Left
|
||||
filtered.ear_l = null; filtered.shoulder_l = null; filtered.elbow_l = null; filtered.wrist_l = null;
|
||||
filtered.hip_l = null; filtered.knee_l = null; filtered.ankle_l = null;
|
||||
// Strict: Remove Opposite Foot, Nose, Eyes
|
||||
filtered.heel_l = null; filtered.toe_l = null;
|
||||
filtered.nose = null; filtered.eye_l = null; filtered.eye_r = null;
|
||||
} else if (view === 'back') {
|
||||
filtered.nose = null; // No nose in back view
|
||||
}
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const analyzeImage = async (imageElement: HTMLImageElement, viewType: any): Promise<PostureAnalysisResult> => {
|
||||
return new Promise((resolve) => {
|
||||
pose.onResults((results: Results) => {
|
||||
// Initialize clean structure
|
||||
let generatedPoints: PosturePoints = {
|
||||
nose: null, eye_l: null, eye_r: null, ear_l: null, ear_r: null,
|
||||
shoulder_l: null, shoulder_r: null, elbow_l: null, elbow_r: null,
|
||||
wrist_l: null, wrist_r: null, hip_l: null, hip_r: null,
|
||||
knee_l: null, knee_r: null, ankle_l: null, ankle_r: null,
|
||||
heel_l: null, heel_r: null, toe_l: null, toe_r: null,
|
||||
c7: null, kyphosis: null, lordosis: null, s1: null, neck: null,
|
||||
head_center: null, tmj: null, chin: null, glute: null
|
||||
};
|
||||
|
||||
if (results.poseLandmarks) {
|
||||
const raw = detectPoints(results.poseLandmarks, imageElement.width, imageElement.height);
|
||||
generatedPoints = filterPointsByView(raw, viewType);
|
||||
|
||||
// Apply Heuristics for 15-point model
|
||||
initializeDerivedPoints(generatedPoints, viewType);
|
||||
|
||||
// Legacy C7/ASIS corrections for front view
|
||||
if (viewType === 'front') {
|
||||
if (raw.shoulder_l && raw.shoulder_r && raw.nose) {
|
||||
generatedPoints.c7 = Medical.estimateC7(raw.shoulder_l, raw.shoulder_r, raw.nose);
|
||||
}
|
||||
// ASIS
|
||||
if (raw.hip_l && raw.hip_r && raw.shoulder_l && raw.shoulder_r) {
|
||||
const corrected = Medical.correctASIS(raw.hip_l, raw.hip_r, raw.shoulder_l, raw.shoulder_r);
|
||||
generatedPoints.hip_l = corrected.l;
|
||||
generatedPoints.hip_r = corrected.r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial Calc
|
||||
const calib = { ...initialCalibration };
|
||||
const angles = recalculateMetrics(viewType, generatedPoints, calib);
|
||||
|
||||
resolve({
|
||||
points: generatedPoints,
|
||||
angles,
|
||||
viewType,
|
||||
calibration: calib,
|
||||
manualPlumbLineX: null // Blue line starts hidden or at default
|
||||
});
|
||||
});
|
||||
pose.send({ image: imageElement });
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
analyzeImage,
|
||||
recalculateMetrics,
|
||||
toPoint
|
||||
};
|
||||
}
|
||||
108
src/app/composables/useTrainingPlans.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ref } from 'vue';
|
||||
import { api } from '../api';
|
||||
import type { TrainingPlan as ApiTrainingPlan } from '../types';
|
||||
|
||||
// --- Types ---
|
||||
export interface TrainingExercise {
|
||||
instanceId: string;
|
||||
exerciseId: string;
|
||||
name: string;
|
||||
sets: number;
|
||||
value: number; // reps or time
|
||||
isTime: boolean;
|
||||
rest: number; // seconds
|
||||
}
|
||||
|
||||
export interface TrainingSection {
|
||||
id: string;
|
||||
name: string;
|
||||
exercises: TrainingExercise[];
|
||||
}
|
||||
|
||||
// Local wrapper for UI (parsed sections)
|
||||
export interface TrainingPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
sections: TrainingSection[];
|
||||
}
|
||||
|
||||
// Global State
|
||||
const plans = ref<TrainingPlan[]>([]);
|
||||
const isLoaded = ref(false);
|
||||
|
||||
const mapToFrontend = (p: ApiTrainingPlan): TrainingPlan => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
sections: p.sections ? JSON.parse(p.sections) : []
|
||||
});
|
||||
|
||||
const mapToBackend = (p: TrainingPlan): ApiTrainingPlan => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
sections: JSON.stringify(p.sections)
|
||||
});
|
||||
|
||||
const loadPlans = async () => {
|
||||
// if (isLoaded.value) return; // Removed to fix race condition with caller
|
||||
try {
|
||||
const data = await api.getPlans();
|
||||
plans.value = data.map(mapToFrontend);
|
||||
isLoaded.value = true;
|
||||
} catch (e) {
|
||||
console.error("Failed to load plans", e);
|
||||
}
|
||||
};
|
||||
|
||||
export function useTrainingPlans() {
|
||||
if (!isLoaded.value) {
|
||||
isLoaded.value = true;
|
||||
loadPlans();
|
||||
}
|
||||
|
||||
const createPlan = async () => {
|
||||
const defaultName = 'Nowy Plan';
|
||||
const defaultSections: TrainingSection[] = [
|
||||
{ id: Math.random().toString(36).substr(2, 9), name: 'Sekcja 1', exercises: [] }
|
||||
];
|
||||
|
||||
try {
|
||||
const apiPlan = await api.createPlan(defaultName, JSON.stringify(defaultSections));
|
||||
const newPlan = mapToFrontend(apiPlan);
|
||||
plans.value.push(newPlan);
|
||||
return newPlan;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const deletePlan = async (id: string) => {
|
||||
await api.deletePlan(id);
|
||||
plans.value = plans.value.filter(p => p.id !== id);
|
||||
};
|
||||
|
||||
const updatePlan = async (updatedPlan: TrainingPlan) => {
|
||||
// Optimistic update
|
||||
const index = plans.value.findIndex(p => p.id === updatedPlan.id);
|
||||
if (index !== -1) {
|
||||
plans.value[index] = updatedPlan;
|
||||
}
|
||||
|
||||
// Sync to backend
|
||||
const apiPlan = mapToBackend(updatedPlan);
|
||||
await api.updatePlan(apiPlan);
|
||||
};
|
||||
|
||||
const getPlanById = (id: string) => {
|
||||
return plans.value.find(p => p.id === id);
|
||||
};
|
||||
|
||||
return {
|
||||
plans,
|
||||
createPlan,
|
||||
deletePlan,
|
||||
updatePlan,
|
||||
getPlanById,
|
||||
refreshPlans: loadPlans
|
||||
};
|
||||
}
|
||||
264
src/app/composables/useTrainingPrograms.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { api } from '../api';
|
||||
import type { Workout as ApiWorkout } from '../types';
|
||||
|
||||
// Types
|
||||
export type WeekDay = 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday';
|
||||
|
||||
export interface Workout {
|
||||
id: string;
|
||||
weekId: string;
|
||||
programId: string;
|
||||
day: WeekDay;
|
||||
type: 'exercise' | 'plan';
|
||||
refId?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
// UI Helpers
|
||||
exerciseId?: string;
|
||||
planId?: string;
|
||||
}
|
||||
|
||||
export interface Week {
|
||||
id: string;
|
||||
position: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface Program {
|
||||
id: string;
|
||||
name: string;
|
||||
weeks: Week[];
|
||||
workouts: Workout[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Global State (Singleton)
|
||||
const programs = ref<Program[]>([]);
|
||||
const activeProgramId = ref<string | null>(null);
|
||||
const isLoaded = ref(false);
|
||||
|
||||
const loadPrograms = async () => {
|
||||
if (isLoaded.value) return;
|
||||
try {
|
||||
const apiPrograms = await api.getPrograms();
|
||||
programs.value = apiPrograms.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
createdAt: new Date(p.createdAt),
|
||||
weeks: [],
|
||||
workouts: []
|
||||
}));
|
||||
|
||||
// Pick first as default if none selected
|
||||
if (!activeProgramId.value && programs.value.length > 0) {
|
||||
activeProgramId.value = programs.value[0].id;
|
||||
}
|
||||
|
||||
isLoaded.value = true;
|
||||
} catch (e) {
|
||||
console.error("Failed to load programs", e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProgramDetails = async (programId: string) => {
|
||||
const program = programs.value.find(p => p.id === programId);
|
||||
if (!program) return;
|
||||
|
||||
try {
|
||||
const wk = await api.getWeeks(programId);
|
||||
const wo = await api.getWorkouts(programId);
|
||||
|
||||
program.weeks = wk.map(w => ({
|
||||
id: w.id,
|
||||
position: w.position,
|
||||
notes: w.notes
|
||||
}));
|
||||
|
||||
program.workouts = wo.map(w => ({
|
||||
id: w.id,
|
||||
weekId: w.weekId,
|
||||
programId: w.programId,
|
||||
day: w.day as WeekDay,
|
||||
type: w.type as 'exercise' | 'plan',
|
||||
refId: w.refId,
|
||||
name: w.name,
|
||||
description: w.description,
|
||||
completed: w.completed,
|
||||
exerciseId: w.type === 'exercise' ? w.refId : undefined,
|
||||
planId: w.type === 'plan' ? w.refId : undefined
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("Failed to details for program " + programId, e);
|
||||
}
|
||||
};
|
||||
|
||||
export function useTrainingPrograms() {
|
||||
|
||||
if (!isLoaded.value) {
|
||||
isLoaded.value = true;
|
||||
loadPrograms().then(() => {
|
||||
if (activeProgramId.value) loadProgramDetails(activeProgramId.value);
|
||||
});
|
||||
}
|
||||
|
||||
watch(activeProgramId, (newId) => {
|
||||
if (newId) loadProgramDetails(newId);
|
||||
});
|
||||
|
||||
const activeProgram = computed(() => programs.value.find(p => p.id === activeProgramId.value) || null);
|
||||
|
||||
const createProgram = async (name: string) => {
|
||||
try {
|
||||
const apiProg = await api.createProgram(name);
|
||||
const newProgram: Program = {
|
||||
id: apiProg.id,
|
||||
name: apiProg.name,
|
||||
createdAt: new Date(apiProg.createdAt),
|
||||
weeks: [],
|
||||
workouts: []
|
||||
};
|
||||
programs.value.push(newProgram);
|
||||
activeProgramId.value = newProgram.id;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProgram = async (id: string) => {
|
||||
try {
|
||||
await api.deleteProgram(id);
|
||||
programs.value = programs.value.filter(p => p.id !== id);
|
||||
if (activeProgramId.value === id) {
|
||||
activeProgramId.value = programs.value[0]?.id || null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const duplicateProgram = async (id: string) => {
|
||||
try {
|
||||
const apiProg = await api.duplicateProgram(id);
|
||||
const newProgram: Program = {
|
||||
id: apiProg.id,
|
||||
name: apiProg.name,
|
||||
createdAt: new Date(apiProg.createdAt),
|
||||
weeks: [],
|
||||
workouts: []
|
||||
};
|
||||
programs.value.push(newProgram);
|
||||
activeProgramId.value = newProgram.id;
|
||||
await loadProgramDetails(newProgram.id);
|
||||
} catch (e) {
|
||||
console.error("Failed to duplicate program", e);
|
||||
}
|
||||
}
|
||||
|
||||
const addWeek = async (programId: string) => {
|
||||
const prog = programs.value.find(p => p.id === programId);
|
||||
if (!prog) return;
|
||||
const position = prog.weeks.length + 1;
|
||||
|
||||
const w = await api.addWeek(programId, position);
|
||||
prog.weeks.push({ id: w.id, position: w.position, notes: w.notes });
|
||||
};
|
||||
|
||||
const deleteWeek = async (weekId: string) => {
|
||||
await api.deleteWeek(weekId);
|
||||
if (activeProgram.value) {
|
||||
activeProgram.value.weeks = activeProgram.value.weeks.filter(w => w.id !== weekId);
|
||||
activeProgram.value.workouts = activeProgram.value.workouts.filter(wo => wo.weekId !== weekId);
|
||||
}
|
||||
};
|
||||
|
||||
const addWorkout = async (data: Omit<Workout, 'id' | 'completed'>) => {
|
||||
// Construct API object (camelCase, matched with `types`)
|
||||
// The API wrapper handles conversion if needed, but our `types` are aligned.
|
||||
const apiData: Omit<ApiWorkout, 'id' | 'completed'> = {
|
||||
weekId: data.weekId,
|
||||
programId: data.programId,
|
||||
day: data.day,
|
||||
type: data.type,
|
||||
refId: data.type === 'exercise' ? data.exerciseId : data.planId,
|
||||
name: data.name,
|
||||
description: data.description
|
||||
};
|
||||
|
||||
const w = await api.addWorkout(apiData);
|
||||
|
||||
const mapped: Workout = {
|
||||
id: w.id,
|
||||
weekId: w.weekId,
|
||||
programId: w.programId,
|
||||
day: w.day as WeekDay,
|
||||
type: w.type as 'exercise' | 'plan',
|
||||
refId: w.refId,
|
||||
name: w.name,
|
||||
description: w.description,
|
||||
completed: w.completed,
|
||||
exerciseId: w.type === 'exercise' ? w.refId : undefined,
|
||||
planId: w.type === 'plan' ? w.refId : undefined
|
||||
};
|
||||
if (activeProgram.value) {
|
||||
activeProgram.value.workouts.push(mapped);
|
||||
}
|
||||
return mapped;
|
||||
};
|
||||
|
||||
const updateWorkout = async (workout: Workout) => {
|
||||
const apiData: ApiWorkout = {
|
||||
id: workout.id,
|
||||
weekId: workout.weekId,
|
||||
programId: workout.programId,
|
||||
day: workout.day,
|
||||
type: workout.type,
|
||||
refId: workout.type === 'exercise' ? workout.exerciseId : workout.planId,
|
||||
name: workout.name,
|
||||
description: workout.description,
|
||||
completed: workout.completed
|
||||
};
|
||||
|
||||
await api.updateWorkout(apiData);
|
||||
|
||||
if (activeProgram.value) {
|
||||
const index = activeProgram.value.workouts.findIndex(w => w.id === workout.id);
|
||||
if (index !== -1) {
|
||||
activeProgram.value.workouts[index] = workout;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteWorkout = async (id: string) => {
|
||||
await api.deleteWorkout(id);
|
||||
if (activeProgram.value) {
|
||||
activeProgram.value.workouts = activeProgram.value.workouts.filter(w => w.id !== id);
|
||||
}
|
||||
};
|
||||
|
||||
const updateWeekNote = async (weekId: string, notes: string) => {
|
||||
await api.updateWeekNote(weekId, notes);
|
||||
if (activeProgram.value) {
|
||||
const w = activeProgram.value.weeks.find(x => x.id === weekId);
|
||||
if (w) w.notes = notes;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
programs,
|
||||
activeProgramId,
|
||||
activeProgram,
|
||||
createProgram,
|
||||
deleteProgram,
|
||||
duplicateProgram,
|
||||
refreshPrograms: loadPrograms,
|
||||
addWeek,
|
||||
deleteWeek,
|
||||
addWorkout,
|
||||
updateWorkout,
|
||||
deleteWorkout,
|
||||
updateWeekNote,
|
||||
reloadActiveProgram: () => activeProgramId.value && loadProgramDetails(activeProgramId.value)
|
||||
};
|
||||
}
|
||||
27
src/app/config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
// Centralized configuration for TrainHubApp
|
||||
// Follows the pattern from OpenWork to keep magic strings out of logic
|
||||
|
||||
export const CONFIG = {
|
||||
MODELS: {
|
||||
DIR: 'models',
|
||||
CHAT: {
|
||||
URL: 'https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf',
|
||||
FILENAME: 'Llama-3.2-3B-Instruct-Q4_K_M.gguf',
|
||||
CONTEXT_SIZE: 4096,
|
||||
GPU_LAYERS: 100
|
||||
},
|
||||
EMBEDDING: {
|
||||
URL: 'https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf',
|
||||
FILENAME: 'nomic-embed-text-v1.5.Q4_K_M.gguf',
|
||||
CONTEXT_SIZE: 2048,
|
||||
GPU_LAYERS: 100
|
||||
}
|
||||
},
|
||||
AI_SERVER: {
|
||||
HOST: '127.0.0.1',
|
||||
PORT: '8080',
|
||||
EMBEDDING_PORT: '8081',
|
||||
HEALTH_ENDPOINT: '/health'
|
||||
}
|
||||
} as const;
|
||||
27
src/app/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrapper flex min-h-screen relative overflow-hidden">
|
||||
<!-- Ambient Background -->
|
||||
<div class="absolute inset-0 bg-no-repeat bg-cover z-0 pointer-events-none"
|
||||
style="background: radial-gradient(circle at 15% 50%, rgba(76, 29, 149, 0.08), transparent 25%), radial-gradient(circle at 85% 30%, rgba(56, 189, 248, 0.08), transparent 25%);">
|
||||
</div>
|
||||
|
||||
<Sidebar />
|
||||
<div class="layout-main-container flex-1 flex flex-column overflow-hidden relative z-1">
|
||||
<main class="flex-1 overflow-y-auto p-4 md:p-6 lg:p-7">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-wrapper {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
409
src/app/pages/AnalysisView.vue
Normal file
@@ -0,0 +1,409 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Card from 'primevue/card';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import ListBox from 'primevue/listbox';
|
||||
import { api } from '../api';
|
||||
import type { AnalysisSession, Annotation } from '../types';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import PostureAnalysis from '../components/posture/PostureAnalysis.vue';
|
||||
import { useAnalysis } from '../composables/useAnalysis';
|
||||
|
||||
// State
|
||||
const analysisMode = ref<'video' | 'posture'>('video');
|
||||
const { sessions: savedSessions, createSession, deleteSession: removeSession, getAnnotations } = useAnalysis();
|
||||
|
||||
const currentSessionName = ref('');
|
||||
const saveSessionDialog = ref(false);
|
||||
const activeSessionId = ref<string | null>(null);
|
||||
|
||||
const videoSrc = ref<string | null>(null);
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(0);
|
||||
|
||||
const inPoint = ref<number | null>(null);
|
||||
const outPoint = ref<number | null>(null);
|
||||
|
||||
const annotationName = ref('');
|
||||
const annotationDesc = ref('');
|
||||
const editingAnnotationId = ref<string | null>(null);
|
||||
const annotations = ref<Annotation[]>([]);
|
||||
|
||||
// Loading handled by composable
|
||||
|
||||
const pickVideo = async () => {
|
||||
try {
|
||||
const path = await api.pickVideoFile();
|
||||
if (path) {
|
||||
videoSrc.value = convertFileSrc(path);
|
||||
resetAnalysis();
|
||||
// Temporarily store path in a way we can access when saving session
|
||||
currentVideoPathCache.value = path;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error picking video", e);
|
||||
}
|
||||
};
|
||||
const currentVideoPathCache = ref<string | null>(null);
|
||||
|
||||
|
||||
const resetAnalysis = () => {
|
||||
inPoint.value = null;
|
||||
outPoint.value = null;
|
||||
currentTime.value = 0;
|
||||
annotations.value = [];
|
||||
editingAnnotationId.value = null;
|
||||
currentSessionName.value = '';
|
||||
activeSessionId.value = null;
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (videoRef.value) {
|
||||
currentTime.value = videoRef.value.currentTime;
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (videoRef.value) {
|
||||
duration.value = videoRef.value.duration;
|
||||
}
|
||||
};
|
||||
|
||||
const setInPoint = () => {
|
||||
inPoint.value = currentTime.value;
|
||||
};
|
||||
|
||||
const setOutPoint = () => {
|
||||
outPoint.value = currentTime.value;
|
||||
};
|
||||
|
||||
const clearPoints = () => {
|
||||
inPoint.value = null;
|
||||
outPoint.value = null;
|
||||
editingAnnotationId.value = null;
|
||||
annotationName.value = '';
|
||||
annotationDesc.value = '';
|
||||
};
|
||||
|
||||
const playSelection = () => {
|
||||
if (videoRef.value && inPoint.value !== null && outPoint.value !== null) {
|
||||
videoRef.value.currentTime = inPoint.value;
|
||||
videoRef.value.play();
|
||||
}
|
||||
};
|
||||
|
||||
const saveAnnotation = async () => {
|
||||
if (!activeSessionId.value) {
|
||||
alert("Musisz najpierw zapisać sesję, aby dodawać adnotacje.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (inPoint.value !== null && outPoint.value !== null && annotationName.value) {
|
||||
try {
|
||||
if (editingAnnotationId.value) {
|
||||
// Update
|
||||
const ann = annotations.value.find(a => a.id === editingAnnotationId.value);
|
||||
if (ann) {
|
||||
|
||||
const updated = { ...ann, name: annotationName.value, description: annotationDesc.value, startTime: inPoint.value, endTime: outPoint.value };
|
||||
await api.updateAnnotation(updated);
|
||||
const idx = annotations.value.findIndex(a => a.id === ann.id);
|
||||
if (idx !== -1) annotations.value[idx] = updated;
|
||||
}
|
||||
} else {
|
||||
// Create
|
||||
const newAnn = await api.addAnnotation({
|
||||
sessionId: activeSessionId.value,
|
||||
startTime: inPoint.value,
|
||||
endTime: outPoint.value,
|
||||
name: annotationName.value,
|
||||
description: annotationDesc.value,
|
||||
color: getRandomColor()
|
||||
});
|
||||
annotations.value.push(newAnn);
|
||||
}
|
||||
clearPoints();
|
||||
} catch (e) {
|
||||
console.error("Error saving annotation", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const editAnnotation = (ann: Annotation) => {
|
||||
editingAnnotationId.value = ann.id;
|
||||
inPoint.value = ann.startTime;
|
||||
outPoint.value = ann.endTime;
|
||||
annotationName.value = ann.name;
|
||||
annotationDesc.value = ann.description;
|
||||
|
||||
if (videoRef.value) {
|
||||
videoRef.value.currentTime = ann.startTime;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAnnotation = async (id: string) => {
|
||||
try {
|
||||
await api.deleteAnnotation(id);
|
||||
annotations.value = annotations.value.filter(a => a.id !== id);
|
||||
if (editingAnnotationId.value === id) {
|
||||
clearPoints();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error deleting annotation", e);
|
||||
}
|
||||
};
|
||||
|
||||
const openSaveSessionDialog = () => {
|
||||
saveSessionDialog.value = true;
|
||||
};
|
||||
|
||||
const saveSession = async () => {
|
||||
if (currentSessionName.value.trim()) {
|
||||
try {
|
||||
const session = await createSession(currentSessionName.value, currentVideoPathCache.value);
|
||||
activeSessionId.value = session.id;
|
||||
saveSessionDialog.value = false;
|
||||
alert('Sesja zapisana! Możesz teraz dodawać adnotacje.');
|
||||
} catch (e) {
|
||||
console.error("Error saving session", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadSession = async (session: AnalysisSession) => {
|
||||
activeSessionId.value = session.id;
|
||||
currentSessionName.value = session.name;
|
||||
|
||||
// Load annotations
|
||||
try {
|
||||
annotations.value = await getAnnotations(session.id);
|
||||
} catch (e) {
|
||||
console.error("Error loading annotations", e);
|
||||
}
|
||||
|
||||
if (session.videoPath) {
|
||||
videoSrc.value = convertFileSrc(session.videoPath);
|
||||
} else {
|
||||
if (!videoSrc.value) {
|
||||
alert('Ta sesja nie ma zapisanego wideo. Załaduj wideo ręcznie.');
|
||||
}
|
||||
}
|
||||
clearPoints();
|
||||
};
|
||||
|
||||
const deleteSessionHelper = async (id: string) => {
|
||||
try {
|
||||
await removeSession(id);
|
||||
if (activeSessionId.value === id) {
|
||||
resetAnalysis();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error deleting session", e);
|
||||
}
|
||||
};
|
||||
|
||||
const getRandomColor = () => {
|
||||
const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6'];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Timeline helpers
|
||||
const getLeftPosition = (time: number) => {
|
||||
if (!duration.value) return '0%';
|
||||
return `${(time / duration.value) * 100}%`;
|
||||
};
|
||||
|
||||
const getWidth = (start: number, end: number) => {
|
||||
if (!duration.value) return '0%';
|
||||
return `${((end - start) / duration.value) * 100}%`;
|
||||
};
|
||||
|
||||
const seek = (event: MouseEvent) => {
|
||||
if (!duration.value || !videoRef.value) return;
|
||||
const timeline = event.currentTarget as HTMLElement;
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const time = percentage * duration.value;
|
||||
videoRef.value.currentTime = time;
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
if (videoSrc.value && videoSrc.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(videoSrc.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-column h-full gap-2">
|
||||
<!-- Mode Switcher -->
|
||||
<div class="flex justify-content-center gap-2 pb-2">
|
||||
<Button label="Analiza Wideo" :severity="analysisMode === 'video' ? 'primary' : 'secondary'" @click="analysisMode = 'video'" />
|
||||
<Button label="Analiza Postawy" :severity="analysisMode === 'posture' ? 'primary' : 'secondary'" @click="analysisMode = 'posture'" />
|
||||
</div>
|
||||
|
||||
<div v-if="analysisMode === 'posture'" class="flex-grow-1 overflow-hidden relative" style="min-height: 0;">
|
||||
<PostureAnalysis class="absolute inset-0" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid flex-grow-1 h-full">
|
||||
<!-- Main Area: Video & Timeline -->
|
||||
<div class="col-12 md:col-8 flex flex-column gap-3">
|
||||
<Card class="flex-grow-1 flex flex-column">
|
||||
<template #title>
|
||||
<div class="flex justify-content-between align-items-center">
|
||||
<span>Podgląd Wideo</span>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Zapisz sesję" icon="pi pi-save" size="small" @click="openSaveSessionDialog" :disabled="!videoSrc" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="!videoSrc" class="flex align-items-center justify-content-center h-20rem border-2 border-dashed surface-border border-round bg-surface-ground">
|
||||
<span class="text-color-secondary">Wybierz wideo z panelu po prawej</span>
|
||||
</div>
|
||||
<div v-else class="relative w-full bg-black flex align-items-center justify-content-center" style="min-height: 400px;">
|
||||
<video ref="videoRef" :src="videoSrc" class="w-full h-full" controls @timeupdate="onTimeUpdate" @loadedmetadata="onLoadedMetadata"></video>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="mt-4 relative h-3rem bg-surface-200 border-round cursor-pointer select-none" @click="seek">
|
||||
<!-- Progress Bar -->
|
||||
<div class="absolute top-0 left-0 h-full bg-primary-200 opacity-50 pointer-events-none" :style="{ width: getLeftPosition(currentTime) }"></div>
|
||||
|
||||
<!-- Current Time Marker -->
|
||||
<div class="absolute top-0 w-2px h-full bg-red-500 z-5 pointer-events-none" :style="{ left: getLeftPosition(currentTime) }"></div>
|
||||
|
||||
<!-- Saved Annotations -->
|
||||
<div v-for="ann in annotations" :key="ann.id"
|
||||
class="absolute top-0 h-full opacity-70 border-left-1 border-right-1 border-white"
|
||||
:style="{ left: getLeftPosition(ann.startTime), width: getWidth(ann.startTime, ann.endTime), backgroundColor: ann.color }"
|
||||
:title="ann.name">
|
||||
</div>
|
||||
|
||||
<!-- Current Selection (Pending) -->
|
||||
<div v-if="inPoint !== null" class="absolute top-0 h-full border-left-2 border-primary z-4"
|
||||
:style="{ left: getLeftPosition(inPoint) }">
|
||||
<div class="absolute -top-100 left-50" style="transform: translateX(-50%)"><i class="pi pi-arrow-down text-primary"></i></div>
|
||||
</div>
|
||||
<div v-if="outPoint !== null" class="absolute top-0 h-full border-left-2 border-primary z-4"
|
||||
:style="{ left: getLeftPosition(outPoint) }">
|
||||
<div class="absolute -top-100 left-50" style="transform: translateX(-50%)"><i class="pi pi-arrow-down text-primary"></i></div>
|
||||
</div>
|
||||
<div v-if="inPoint !== null && outPoint !== null"
|
||||
class="absolute top-0 h-full bg-primary opacity-30 pointer-events-none z-3"
|
||||
:style="{ left: getLeftPosition(inPoint), width: getWidth(inPoint, outPoint) }">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-content-between mt-1 text-sm text-color-secondary">
|
||||
<span>{{ formatTime(currentTime) }}</span>
|
||||
<span>{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: Controls & Annotations -->
|
||||
<div class="col-12 md:col-4 flex flex-column gap-3 h-full overflow-y-auto">
|
||||
<Card>
|
||||
<template #title>Narzędzia</template>
|
||||
<template #content>
|
||||
<Button label="Wybierz wideo" icon="pi pi-video" @click="pickVideo" class="w-full mb-3" />
|
||||
|
||||
<div class="flex gap-2 mb-3">
|
||||
<Button label="IN" icon="pi pi-step-backward" @click="setInPoint" class="flex-1" :disabled="!videoSrc" />
|
||||
<Button label="OUT" icon="pi pi-step-forward" @click="setOutPoint" class="flex-1" :disabled="!videoSrc" />
|
||||
<Button icon="pi pi-times" severity="secondary" @click="clearPoints" :disabled="!inPoint && !outPoint" />
|
||||
</div>
|
||||
|
||||
<div v-if="inPoint !== null && outPoint !== null" class="surface-ground p-3 border-round mb-3">
|
||||
<div class="text-sm text-color-secondary mb-2">Zaznaczenie: {{ formatTime(inPoint) }} - {{ formatTime(outPoint) }}</div>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<Button icon="pi pi-play" size="small" rounded outlined @click="playSelection" />
|
||||
</div>
|
||||
|
||||
<div class="field mb-2">
|
||||
<InputText v-model="annotationName" placeholder="Nazwa ćwiczenia/błędu" class="w-full p-inputtext-sm" />
|
||||
</div>
|
||||
<div class="field mb-2">
|
||||
<Textarea v-model="annotationDesc" placeholder="Opis..." rows="2" class="w-full text-sm" />
|
||||
</div>
|
||||
<Button :label="editingAnnotationId ? 'Zaktualizuj' : 'Zapisz adnotację'" icon="pi pi-check" class="w-full text-sm" @click="saveAnnotation" :disabled="!annotationName" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="flex-grow-1">
|
||||
<template #title>Sesje i Adnotacje</template>
|
||||
<template #content>
|
||||
<!-- Saved Sessions List -->
|
||||
<div class="mb-4">
|
||||
<div class="text-sm font-bold mb-2">Zapisane Sesje</div>
|
||||
<ListBox :options="savedSessions" optionLabel="name" class="w-full" listStyle="max-height: 150px">
|
||||
<template #option="slotProps">
|
||||
<div class="flex align-items-center justify-content-between w-full" @click="loadSession(slotProps.option)">
|
||||
<div class="flex flex-column">
|
||||
<span>{{ slotProps.option.name }}</span>
|
||||
<span class="text-xs text-color-secondary">{{ new Date(slotProps.option.date).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
<Button icon="pi pi-trash" text rounded severity="danger" class="w-2rem h-2rem" @click.stop="deleteSessionHelper(slotProps.option.id)" />
|
||||
</div>
|
||||
</template>
|
||||
</ListBox>
|
||||
</div>
|
||||
|
||||
<div class="text-sm font-bold mb-2">Adnotacje w tej sesji</div>
|
||||
<div v-if="annotations.length === 0" class="text-center text-color-secondary py-2 text-sm">Brak adnotacji</div>
|
||||
<div class="flex flex-column gap-2">
|
||||
<div v-for="ann in annotations" :key="ann.id"
|
||||
class="flex flex-column p-2 border-1 surface-border border-round cursor-pointer hover:surface-hover transition-colors"
|
||||
:class="{'surface-hover': editingAnnotationId === ann.id}"
|
||||
:style="{ borderLeft: `4px solid ${ann.color}` }"
|
||||
@click="editAnnotation(ann)">
|
||||
<div class="font-bold flex justify-content-between">
|
||||
<span>{{ ann.name }}</span>
|
||||
<span class="text-xs text-color-secondary">{{ formatTime(ann.startTime) }} - {{ formatTime(ann.endTime) }}</span>
|
||||
</div>
|
||||
<div class="text-sm mt-1 text-overflow-ellipsis overflow-hidden white-space-nowrap">{{ ann.description }}</div>
|
||||
<div class="flex gap-2 mt-2" @click.stop>
|
||||
<Button icon="pi pi-play" size="small" text rounded @click="() => { if(videoRef) { videoRef.currentTime = ann.startTime; videoRef.play(); } }" />
|
||||
<Button icon="pi pi-trash" size="small" text rounded severity="danger" @click="deleteAnnotation(ann.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="saveSessionDialog" header="Zapisz Sesję" :style="{ width: '300px' }" modal>
|
||||
<div class="field">
|
||||
<label for="sessionName" class="block mb-2">Nazwa sesji</label>
|
||||
<InputText id="sessionName" v-model="currentSessionName" class="w-full" autofocus />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Anuluj" text @click="saveSessionDialog = false" />
|
||||
<Button label="Zapisz" @click="saveSession" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles if needed */
|
||||
</style>
|
||||
400
src/app/pages/CalendarView.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Select from 'primevue/select';
|
||||
import RadioButton from 'primevue/radiobutton';
|
||||
import Toolbar from 'primevue/toolbar';
|
||||
import { useConfirm } from "primevue/useconfirm";
|
||||
import { useExercises } from '../composables/useExercises';
|
||||
import { useTrainingPlans } from '../composables/useTrainingPlans';
|
||||
import { useTrainingPrograms, type WeekDay, type Workout, type Week } from '../composables/useTrainingPrograms';
|
||||
|
||||
// --- Shared Data ---
|
||||
const { exercises } = useExercises();
|
||||
const { plans } = useTrainingPlans();
|
||||
const {
|
||||
programs,
|
||||
activeProgram,
|
||||
activeProgramId,
|
||||
createProgram,
|
||||
deleteProgram: deleteProgramAction,
|
||||
duplicateProgram: duplicateProgramAction,
|
||||
addWeek: addWeekAction,
|
||||
deleteWeek: deleteWeekAction,
|
||||
addWorkout: addWorkoutAction,
|
||||
updateWorkout: updateWorkoutAction,
|
||||
deleteWorkout: deleteWorkoutAction,
|
||||
updateWeekNote: updateWeekNoteAction
|
||||
} = useTrainingPrograms();
|
||||
|
||||
const confirm = useConfirm();
|
||||
|
||||
// --- Computed Accessors ---
|
||||
const weeks = computed(() => activeProgram.value?.weeks || []);
|
||||
const workouts = computed(() => activeProgram.value?.workouts || []);
|
||||
|
||||
const weekDays: WeekDay[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
// --- State ---
|
||||
const showWorkoutDialog = ref(false);
|
||||
const showNotesDialog = ref(false);
|
||||
const showProgramDialog = ref(false);
|
||||
const newProgramName = ref('');
|
||||
|
||||
const editingWorkout = ref<Workout>({} as Workout);
|
||||
const currentWeekIdForNote = ref<string | null>(null);
|
||||
const currentNote = ref('');
|
||||
|
||||
// --- Actions (Programs) ---
|
||||
const openNewProgram = () => {
|
||||
newProgramName.value = '';
|
||||
showProgramDialog.value = true;
|
||||
};
|
||||
|
||||
const saveNewProgram = async () => {
|
||||
if (newProgramName.value.trim()) {
|
||||
await createProgram(newProgramName.value.trim());
|
||||
showProgramDialog.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteProgram = (event: Event) => {
|
||||
confirm.require({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
message: 'Czy na pewno chcesz usunąć ten kalendarz?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
accept: async () => {
|
||||
if (activeProgramId.value) await deleteProgramAction(activeProgramId.value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onDuplicateProgram = async () => {
|
||||
if (activeProgramId.value) {
|
||||
await duplicateProgramAction(activeProgramId.value);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Actions (Weeks) ---
|
||||
const addWeek = async () => {
|
||||
if (!activeProgram.value || !activeProgramId.value) return;
|
||||
await addWeekAction(activeProgramId.value);
|
||||
};
|
||||
|
||||
const deleteWeek = async (id: string) => {
|
||||
if (!activeProgram.value) return;
|
||||
await deleteWeekAction(id);
|
||||
};
|
||||
|
||||
// --- Actions (Workouts) ---
|
||||
const openAddWorkout = (weekId: string, day: WeekDay) => {
|
||||
editingWorkout.value = {
|
||||
id: '', // Empty for new
|
||||
weekId,
|
||||
programId: activeProgramId.value || '',
|
||||
day,
|
||||
type: 'exercise', // default
|
||||
name: '',
|
||||
description: '',
|
||||
completed: false
|
||||
};
|
||||
showWorkoutDialog.value = true;
|
||||
};
|
||||
|
||||
const editWorkout = (workout: Workout) => {
|
||||
editingWorkout.value = { ...workout };
|
||||
if (!editingWorkout.value.type) editingWorkout.value.type = 'exercise';
|
||||
showWorkoutDialog.value = true;
|
||||
};
|
||||
|
||||
const saveWorkout = async () => {
|
||||
if (!activeProgram.value || !activeProgramId.value) return;
|
||||
|
||||
// Auto-fill name if empty
|
||||
if (!editingWorkout.value.name) {
|
||||
if (editingWorkout.value.type === 'exercise' && editingWorkout.value.exerciseId) {
|
||||
const ex = exercises.value.find(e => e.id === editingWorkout.value.exerciseId);
|
||||
if (ex) editingWorkout.value.name = ex.name;
|
||||
} else if (editingWorkout.value.type === 'plan' && editingWorkout.value.planId) {
|
||||
const p = plans.value.find(pl => pl.id === editingWorkout.value.planId);
|
||||
if (p) editingWorkout.value.name = p.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (editingWorkout.value.name?.trim()) {
|
||||
const payload = { ...editingWorkout.value, programId: activeProgramId.value };
|
||||
|
||||
if (editingWorkout.value.id) {
|
||||
await updateWorkoutAction(payload);
|
||||
} else {
|
||||
// New
|
||||
await addWorkoutAction(payload);
|
||||
}
|
||||
showWorkoutDialog.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteWorkout = async (id: string) => {
|
||||
if (!activeProgram.value) return;
|
||||
await deleteWorkoutAction(id);
|
||||
showWorkoutDialog.value = false;
|
||||
};
|
||||
|
||||
const onExerciseSelect = () => {
|
||||
if (editingWorkout.value.type === 'exercise') {
|
||||
const ex = exercises.value.find(e => e.id === editingWorkout.value.exerciseId);
|
||||
if (ex) editingWorkout.value.name = ex.name;
|
||||
}
|
||||
}
|
||||
|
||||
const onPlanSelect = () => {
|
||||
if (editingWorkout.value.type === 'plan') {
|
||||
const p = plans.value.find(pl => pl.id === editingWorkout.value.planId);
|
||||
if (p) {
|
||||
editingWorkout.value.name = p.name;
|
||||
editingWorkout.value.description = `${p.sections.length} Sekcji`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notes ---
|
||||
const openNotes = (week: Week) => {
|
||||
currentWeekIdForNote.value = week.id;
|
||||
currentNote.value = week.notes;
|
||||
showNotesDialog.value = true;
|
||||
};
|
||||
|
||||
const saveNotes = async () => {
|
||||
if (!activeProgram.value) return;
|
||||
if (currentWeekIdForNote.value) {
|
||||
await updateWeekNoteAction(currentWeekIdForNote.value, currentNote.value);
|
||||
}
|
||||
showNotesDialog.value = false;
|
||||
};
|
||||
|
||||
const toggleComplete = async (workout: Workout) => {
|
||||
const updated = { ...workout, completed: !workout.completed };
|
||||
await updateWorkoutAction(updated);
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
// Use memoization/computed if performance issues, but filter is fine for small lists
|
||||
const getWorkoutsFor = (weekId: string, day: WeekDay) => {
|
||||
return workouts.value.filter((w: Workout) => w.weekId === weekId && w.day === day);
|
||||
};
|
||||
|
||||
const dayLabels: Record<WeekDay, string> = {
|
||||
'Monday': 'Poniedziałek',
|
||||
'Tuesday': 'Wtorek',
|
||||
'Wednesday': 'Środa',
|
||||
'Thursday': 'Czwartek',
|
||||
'Friday': 'Piątek',
|
||||
'Saturday': 'Sobota',
|
||||
'Sunday': 'Niedziela'
|
||||
};
|
||||
|
||||
// --- D&D ---
|
||||
const draggedWorkout = ref<Workout | null>(null);
|
||||
|
||||
const onDragStart = (event: DragEvent, workout: Workout) => {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
draggedWorkout.value = workout;
|
||||
};
|
||||
|
||||
const onDrop = async (_event: DragEvent, weekId: string, day: WeekDay) => {
|
||||
if (draggedWorkout.value) {
|
||||
if (draggedWorkout.value.weekId !== weekId || draggedWorkout.value.day !== day) {
|
||||
draggedWorkout.value.weekId = weekId;
|
||||
draggedWorkout.value.day = day;
|
||||
await updateWorkoutAction(draggedWorkout.value);
|
||||
}
|
||||
draggedWorkout.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-column">
|
||||
<!-- New Header with Program Selection -->
|
||||
<Toolbar class="mb-4 p-2 border-round-xl">
|
||||
<template #start>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-calendar text-2xl text-primary mr-2"></i>
|
||||
<Select v-model="activeProgramId" :options="programs" optionLabel="name" optionValue="id"
|
||||
class="w-15rem" placeholder="Wybierz Kalendarz" />
|
||||
<Button icon="pi pi-plus" text rounded v-tooltip="'Nowy Kalendarz'" @click="openNewProgram" />
|
||||
<Button icon="pi pi-clone" text rounded v-tooltip="'Duplikuj Kalendarz'" @click="onDuplicateProgram" :disabled="!activeProgramId"/>
|
||||
<Button icon="pi pi-trash" text rounded severity="danger" v-tooltip="'Usuń Kalendarz'" @click="confirmDeleteProgram" :disabled="!activeProgramId"/>
|
||||
</div>
|
||||
</template>
|
||||
<template #end>
|
||||
<Button label="Dodaj Tydzień" icon="pi pi-plus" @click="addWeek" :disabled="!activeProgramId" />
|
||||
</template>
|
||||
</Toolbar>
|
||||
|
||||
<div v-if="!activeProgramId" class="flex flex-column align-items-center justify-content-center flex-grow-1 text-color-secondary">
|
||||
<i class="pi pi-calendar-times text-6xl mb-3"></i>
|
||||
<div class="text-xl">Brak wybranego kalendarza</div>
|
||||
<Button label="Stwórz nowy" class="mt-3" @click="openNewProgram" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-grow-1 overflow-y-auto">
|
||||
<!-- Header Row -->
|
||||
<div class="grid m-0 mb-2 hidden md:flex text-center font-bold text-color-secondary border-bottom-1 surface-border pb-2">
|
||||
<div class="col-1 text-left">Tydzień</div>
|
||||
<div v-for="day in weekDays" :key="day" class="col">{{ dayLabels[day] }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Weeks List -->
|
||||
<div v-for="week in weeks" :key="week.id" class="mb-5">
|
||||
<div class="grid m-0 surface-card border-round shadow-1 p-2">
|
||||
<!-- Week Number & Controls -->
|
||||
<div class="col-12 md:col-1 flex md:flex-column align-items-center justify-content-center border-bottom-1 md:border-bottom-none md:border-right-1 surface-border p-3 gap-2">
|
||||
<div class="text-2xl font-bold text-primary">{{ week.position }}</div>
|
||||
<div class="text-xs text-color-secondary">TYDZIEŃ</div>
|
||||
|
||||
<Button icon="pi pi-file-edit" text rounded severity="secondary" v-tooltip="'Notatki'" @click="openNotes(week)" :badge="week.notes ? '!' : undefined" />
|
||||
<Button icon="pi pi-trash" text rounded severity="danger" v-tooltip="'Usuń tydzień'" @click="deleteWeek(week.id)" />
|
||||
</div>
|
||||
|
||||
<!-- Days -->
|
||||
<div v-for="day in weekDays" :key="day"
|
||||
class="col-12 md:col flex flex-column border-bottom-1 md:border-bottom-none md:border-right-1 surface-border p-2 min-h-10rem transition-colors hover:surface-ground group relative"
|
||||
@dragover.prevent @dragenter.prevent @drop="onDrop($event, week.id, day)">
|
||||
|
||||
<!-- Header: Label + Add Button -->
|
||||
<div class="flex justify-content-between align-items-center mb-2">
|
||||
<span class="font-bold text-sm md:hidden">{{ dayLabels[day] }}</span> <!-- Mobile -->
|
||||
<span class="hidden md:inline"></span> <!-- Spacer for Desktop to keep button right -->
|
||||
<!-- Add Button: Always visible on mobile, opacity on desktop -->
|
||||
<Button icon="pi pi-plus" size="small" rounded text severity="primary" class="md:opacity-0 group-hover:opacity-100 transition-opacity" @click="openAddWorkout(week.id, day)" v-tooltip="'Dodaj Trening'" />
|
||||
</div>
|
||||
|
||||
<!-- Workouts List -->
|
||||
<div class="flex flex-column gap-2 flex-grow-1 h-full cursor-pointer" @click.self="openAddWorkout(week.id, day)">
|
||||
<div v-for="workout in getWorkoutsFor(week.id, day)" :key="workout.id"
|
||||
class="surface-overlay p-2 border-round shadow-1 cursor-move hover:shadow-2 border-left-3 select-none"
|
||||
:class="{
|
||||
'border-green-500': workout.completed,
|
||||
'border-primary': !workout.completed && workout.type !== 'plan',
|
||||
'border-purple-500': !workout.completed && workout.type === 'plan',
|
||||
'opacity-50': workout.completed
|
||||
}"
|
||||
draggable="true" @dragstart="onDragStart($event, workout)"
|
||||
@click.stop="editWorkout(workout)">
|
||||
|
||||
<div class="font-semibold text-sm mb-1 flex justify-content-between align-items-start">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i v-if="workout.type === 'plan'" class="pi pi-book text-purple-500 text-xs"></i>
|
||||
<span>{{ workout.name }}</span>
|
||||
</div>
|
||||
<i v-if="workout.completed" class="pi pi-check-circle text-green-500 text-xs"></i>
|
||||
</div>
|
||||
<div class="text-xs text-color-secondary text-overflow-ellipsis overflow-hidden white-space-nowrap">{{ workout.description }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State Helper (Click to add) -->
|
||||
<div v-if="getWorkoutsFor(week.id, day).length === 0" class="h-full flex align-items-center justify-content-center opacity-0 group-hover:opacity-50 text-xs text-color-secondary cursor-pointer" @click="openAddWorkout(week.id, day)">
|
||||
<i class="pi pi-plus mr-1"></i> Dodaj
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Preview -->
|
||||
<div v-if="week.notes" class="mt-2 text-sm text-color-secondary px-3">
|
||||
<i class="pi pi-info-circle mr-1"></i> {{ week.notes }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<Dialog v-model:visible="showWorkoutDialog" header="Dodaj Aktywność" :style="{ width: '450px' }" modal>
|
||||
<div class="field mb-4">
|
||||
<label class="block mb-2 font-bold">Rodzaj</label>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex align-items-center">
|
||||
<RadioButton v-model="editingWorkout.type" inputId="tEx" name="type" value="exercise" />
|
||||
<label for="tEx" class="ml-2 cursor-pointer">Ćwiczenie</label>
|
||||
</div>
|
||||
<div class="flex align-items-center">
|
||||
<RadioButton v-model="editingWorkout.type" inputId="tPlan" name="type" value="plan" />
|
||||
<label for="tPlan" class="ml-2 cursor-pointer">Plan Treningowy</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single Exercise Mode -->
|
||||
<div v-if="editingWorkout.type === 'exercise'" class="field mb-3 animate-fade-in">
|
||||
<label for="wEx" class="block mb-2 font-bold">Wybierz z bazy (opcjonalne)</label>
|
||||
<Select id="wEx" v-model="editingWorkout.exerciseId" :options="exercises" optionLabel="name" optionValue="id"
|
||||
placeholder="Wybierz ćwiczenie" class="w-full" showClear filter @change="onExerciseSelect" />
|
||||
</div>
|
||||
|
||||
<!-- Plan Mode -->
|
||||
<div v-if="editingWorkout.type === 'plan'" class="field mb-3 animate-fade-in">
|
||||
<label for="wPlan" class="block mb-2 font-bold">Wybierz Plan</label>
|
||||
<Select id="wPlan" v-model="editingWorkout.planId" :options="plans" optionLabel="name" optionValue="id"
|
||||
placeholder="Wybierz plan" class="w-full" showClear @change="onPlanSelect" />
|
||||
</div>
|
||||
|
||||
<div class="field mb-3">
|
||||
<label for="wName" class="block mb-2 font-bold">Nazwa</label>
|
||||
<InputText id="wName" v-model="editingWorkout.name" class="w-full" />
|
||||
</div>
|
||||
<div class="field mb-3">
|
||||
<label for="wDesc" class="block mb-2 font-bold">Opis / Notatki</label>
|
||||
<Textarea id="wDesc" v-model="editingWorkout.description" class="w-full" rows="3" />
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2 mb-3">
|
||||
<Button :icon="editingWorkout.completed ? 'pi pi-check-circle' : 'pi pi-circle'"
|
||||
:label="editingWorkout.completed ? 'Ukończono' : 'Oznacz jako ukończone'"
|
||||
:severity="editingWorkout.completed ? 'success' : 'secondary'"
|
||||
text
|
||||
@click="toggleComplete(editingWorkout)" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button v-if="editingWorkout.id && workouts.find(w => w.id === editingWorkout.id)" label="Usuń" icon="pi pi-trash" severity="danger" text @click="deleteWorkout(editingWorkout.id)" />
|
||||
<Button label="Anuluj" text @click="showWorkoutDialog = false" />
|
||||
<Button label="Zapisz" @click="saveWorkout" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="showNotesDialog" header="Notatki Tygodniowe" :style="{ width: '400px' }" modal>
|
||||
<div class="field">
|
||||
<Textarea v-model="currentNote" class="w-full" rows="5" placeholder="Cele na ten tydzień..." />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Anuluj" text @click="showNotesDialog = false" />
|
||||
<Button label="Zapisz" @click="saveNotes" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="showProgramDialog" header="Nowy Kalendarz" :style="{ width: '400px' }" modal>
|
||||
<div class="field">
|
||||
<label for="pName" class="block mb-2 font-bold">Nazwa Kalendarza</label>
|
||||
<InputText id="pName" v-model="newProgramName" class="w-full" autofocus @keyup.enter="saveNewProgram" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Anuluj" text @click="showProgramDialog = false" />
|
||||
<Button label="Stwórz" @click="saveNewProgram" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmPopup />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom grid overrides if PrimeFlex grid class issues occur */
|
||||
.min-h-10rem {
|
||||
min-height: 10rem;
|
||||
}
|
||||
</style>
|
||||
223
src/app/pages/ChatAIView.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useChatAI } from "../composables/useChatAI";
|
||||
import { checkModelsExist, downloadModels } from "../services/modelManager";
|
||||
import { startServer, stopServer } from "../services/aiService";
|
||||
import { VectorStore } from "../services/vectorStore";
|
||||
|
||||
// Components
|
||||
import Button from 'primevue/button';
|
||||
import ProgressBar from 'primevue/progressbar';
|
||||
import ScrollPanel from 'primevue/scrollpanel';
|
||||
import ConfirmPopup from 'primevue/confirmpopup';
|
||||
import { useConfirm } from "primevue/useconfirm";
|
||||
|
||||
import ChatMessageList from "../components/chat/ChatMessageList.vue";
|
||||
import ChatInput from "../components/chat/ChatInput.vue";
|
||||
|
||||
const {
|
||||
messages,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
isLoading,
|
||||
initEngine,
|
||||
sendMessage,
|
||||
loadSessions,
|
||||
loadSession,
|
||||
createNewChat,
|
||||
deleteSession,
|
||||
} = useChatAI();
|
||||
|
||||
// Local State
|
||||
const isModelReady = ref(false);
|
||||
const modelNeedsDownload = ref(false);
|
||||
const isDownloadingLocal = ref(false);
|
||||
const downloadPercent = ref(0);
|
||||
const localDownloadProgress = ref("");
|
||||
const serverLoading = ref(false);
|
||||
|
||||
// Local State (Indexing)
|
||||
const isIndexing = ref(false);
|
||||
const indexingProgress = ref("");
|
||||
|
||||
const confirm = useConfirm();
|
||||
|
||||
const startIndexing = async () => {
|
||||
try {
|
||||
isIndexing.value = true;
|
||||
const count = await VectorStore.buildIndex((status) => {
|
||||
indexingProgress.value = status;
|
||||
});
|
||||
indexingProgress.value = `Zakończono! Zindeksowano ${count} dokumentów.`;
|
||||
setTimeout(() => { isIndexing.value = false; }, 2000);
|
||||
} catch (e) {
|
||||
console.error("Indexing failed", e);
|
||||
indexingProgress.value = "Błąd indeksowania: " + e;
|
||||
setTimeout(() => { isIndexing.value = false; }, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const checkAndStart = async () => {
|
||||
try {
|
||||
serverLoading.value = true;
|
||||
const exists = await checkModelsExist();
|
||||
if (exists) {
|
||||
modelNeedsDownload.value = false;
|
||||
localDownloadProgress.value = "Uruchamianie serwera AI...";
|
||||
await startServer();
|
||||
isModelReady.value = true;
|
||||
await initEngine();
|
||||
} else {
|
||||
modelNeedsDownload.value = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to start:", e);
|
||||
localDownloadProgress.value = "Błąd: " + e;
|
||||
} finally {
|
||||
serverLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startDownload = async () => {
|
||||
try {
|
||||
isDownloadingLocal.value = true;
|
||||
await downloadModels((pct, status) => {
|
||||
downloadPercent.value = pct;
|
||||
localDownloadProgress.value = status;
|
||||
});
|
||||
isDownloadingLocal.value = false;
|
||||
await checkAndStart();
|
||||
} catch (e) {
|
||||
console.error("Download failed", e);
|
||||
isDownloadingLocal.value = false;
|
||||
localDownloadProgress.value = "Błąd pobierania: " + e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = (text: string) => {
|
||||
sendMessage(text);
|
||||
};
|
||||
|
||||
// Auto-refresh sessions
|
||||
watch(currentSessionId, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) loadSessions();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSessions();
|
||||
if (sessions.value.length === 0) {
|
||||
createNewChat();
|
||||
} else {
|
||||
await loadSession(sessions.value[0].id);
|
||||
}
|
||||
await checkAndStart();
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
await stopServer();
|
||||
});
|
||||
|
||||
const confirmDelete = (event: Event, id: string) => {
|
||||
confirm.require({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
message: 'Czy na pewno chcesz usunąć tę rozmowę?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
accept: () => { deleteSession(id); }
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full overflow-hidden bg-surface-ground">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="hidden md:flex flex-column w-20rem bg-surface-card border-right-1 surface-border h-full transition-all">
|
||||
<div class="p-3 border-bottom-1 surface-border">
|
||||
<Button label="Nowy czat" icon="pi pi-plus" class="w-full" @click="createNewChat" outlined />
|
||||
</div>
|
||||
<ScrollPanel class="flex-grow-1 w-full">
|
||||
<div class="flex flex-column p-2 gap-1">
|
||||
<div v-for="session in sessions" :key="session.id"
|
||||
:class="['flex align-items-center justify-content-between p-3 border-round cursor-pointer transition-colors',
|
||||
currentSessionId === session.id ? 'bg-primary-50 text-primary' : 'hover:surface-hover text-color']"
|
||||
@click="loadSession(session.id)">
|
||||
|
||||
<div class="flex align-items-center gap-2 overflow-hidden">
|
||||
<i class="pi pi-comments"></i>
|
||||
<span class="white-space-nowrap overflow-hidden text-overflow-ellipsis font-medium text-sm text-color-black">{{ session.title }}</span>
|
||||
</div>
|
||||
|
||||
<Button icon="pi pi-trash" text rounded severity="danger" class="w-2rem h-2rem flex-shrink-0" @click.stop="confirmDelete($event, session.id)" />
|
||||
</div>
|
||||
|
||||
<div v-if="sessions.length === 0" class="text-center p-4 text-500 text-sm">
|
||||
Brak historii rozmów
|
||||
</div>
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div class="flex flex-column flex-grow-1 h-full relative min-w-0">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex align-items-center justify-content-between p-3 surface-card border-bottom-1 surface-border z-1 shadow-1">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-bolt text-2xl text-primary"></i>
|
||||
<div class="flex flex-column">
|
||||
<span class="font-bold text-color">Trener AI</span>
|
||||
<span class="text-xs text-green-500 flex align-items-center gap-1">
|
||||
<i class="pi pi-circle-fill text-[8px]"></i> Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button label="Indeksuj wiedzę" icon="pi pi-database" size="small" outlined @click="startIndexing" :disabled="isIndexing || !isModelReady" />
|
||||
</div>
|
||||
|
||||
<!-- Chat Content -->
|
||||
<div class="flex-grow-1 relative flex flex-column overflow-hidden">
|
||||
|
||||
<!-- Loading / Status Overlays -->
|
||||
<div v-if="serverLoading || modelNeedsDownload || isIndexing" class="absolute inset-0 z-5 flex align-items-center justify-content-center bg-surface-ground-alpha backdrop-blur-sm">
|
||||
<!-- Re-using existing loading UI logic -->
|
||||
<div v-if="isIndexing" class="surface-card p-5 border-round-xl shadow-4 text-center">
|
||||
<i class="pi pi-database pi-spin text-4xl text-primary mb-3"></i>
|
||||
<div class="font-bold mb-2">Indeksowanie Wiedzy</div>
|
||||
<ProgressBar mode="indeterminate" style="height: 6px; width: 200px" class="mb-2"></ProgressBar>
|
||||
<small class="text-color-secondary">{{ indexingProgress }}</small>
|
||||
</div>
|
||||
<!-- ... other states ... -->
|
||||
<div v-else-if="modelNeedsDownload" class="surface-card p-5 border-round-xl shadow-4 text-center max-w-30rem">
|
||||
<h3>Wymagane pobranie modelu</h3>
|
||||
<p class="mb-4">Model zostanie zapisany lokalnie.</p>
|
||||
<div v-if="isDownloadingLocal">
|
||||
<ProgressBar :value="downloadPercent"></ProgressBar>
|
||||
<small>{{ localDownloadProgress }}</small>
|
||||
</div>
|
||||
<Button v-else label="Pobierz" @click="startDownload" />
|
||||
</div>
|
||||
<div v-else class="surface-card p-5 border-round-xl shadow-4 text-center">
|
||||
<i class="pi pi-cog pi-spin text-4xl text-primary mb-3"></i>
|
||||
<div class="font-bold">Uruchamianie serwera AI...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages List -->
|
||||
<ChatMessageList :messages="messages" />
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<ChatInput :is-loading="isLoading" @send="handleSend" />
|
||||
|
||||
</div>
|
||||
|
||||
<ConfirmPopup />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bg-surface-ground-alpha {
|
||||
background-color: rgba(var(--surface-ground-rgb), 0.8);
|
||||
}
|
||||
</style>
|
||||
150
src/app/pages/ExercisesView.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Tag from 'primevue/tag';
|
||||
import Toolbar from 'primevue/toolbar';
|
||||
// import IconField from 'primevue/iconfield';
|
||||
// import InputIcon from 'primevue/inputicon';
|
||||
import { useExercises, type Exercise } from '../composables/useExercises';
|
||||
|
||||
const { exercises, addExercise, updateExercise, deleteExercise: removeExercise } = useExercises();
|
||||
|
||||
const exerciseDialog = ref(false);
|
||||
const deleteExerciseDialog = ref(false);
|
||||
const exercise = ref<Exercise>({} as Exercise);
|
||||
const submitted = ref(false);
|
||||
|
||||
const openNew = () => {
|
||||
exercise.value = {} as Exercise;
|
||||
submitted.value = false;
|
||||
exerciseDialog.value = true;
|
||||
};
|
||||
|
||||
const hideDialog = () => {
|
||||
exerciseDialog.value = false;
|
||||
submitted.value = false;
|
||||
};
|
||||
|
||||
const saveExercise = async () => {
|
||||
submitted.value = true;
|
||||
|
||||
if (exercise.value.name?.trim()) {
|
||||
if (exercise.value.id) {
|
||||
await updateExercise(exercise.value);
|
||||
} else {
|
||||
// New exercise
|
||||
// Note: addExercise in composable expects Omit<Exercise, 'id'>, but our local ref type is Exercise (possibly partial id)
|
||||
// In a real app we'd adhere strictly to types, here we can just cast or pass.
|
||||
const { id, ...rest } = exercise.value;
|
||||
await addExercise(rest as any);
|
||||
}
|
||||
|
||||
exerciseDialog.value = false;
|
||||
exercise.value = {} as Exercise;
|
||||
}
|
||||
};
|
||||
|
||||
const editExercise = (prod: Exercise) => {
|
||||
exercise.value = { ...prod };
|
||||
exerciseDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmDeleteExercise = (prod: Exercise) => {
|
||||
exercise.value = prod;
|
||||
deleteExerciseDialog.value = true;
|
||||
};
|
||||
|
||||
const deleteExercise = async () => {
|
||||
await removeExercise(exercise.value.id);
|
||||
deleteExerciseDialog.value = false;
|
||||
exercise.value = {} as Exercise;
|
||||
};
|
||||
|
||||
// Helper for tags input (comma separated for simplicity in MVP)
|
||||
const tagsInput = ref('');
|
||||
const updateTags = () => {
|
||||
exercise.value.tags = tagsInput.value.split(',').map(t => t.trim()).filter(t => t);
|
||||
}
|
||||
// Sync tags into input when editing
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<Toolbar class="mb-4">
|
||||
<template #start>
|
||||
<Button label="Nowe ćwiczenie" icon="pi pi-plus" class="mr-2" @click="openNew" />
|
||||
</template>
|
||||
</Toolbar>
|
||||
|
||||
<DataTable :value="exercises" tableStyle="min-width: 50rem">
|
||||
<Column field="name" header="Nazwa" sortable></Column>
|
||||
<Column field="instructions" header="Instrukcje"></Column>
|
||||
<Column field="enrichment" header="Enrichment"></Column>
|
||||
<Column header="Tagi">
|
||||
<template #body="slotProps">
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<Tag v-for="tag in slotProps.data.tags" :key="tag" :value="tag" severity="info" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="videoUrl" header="Video">
|
||||
<template #body="slotProps">
|
||||
<a v-if="slotProps.data.videoUrl" :href="slotProps.data.videoUrl" target="_blank" class="text-primary hover:underline">Link</a>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column :exportable="false" style="min-width:8rem">
|
||||
<template #body="slotProps">
|
||||
<Button icon="pi pi-pencil" outlined rounded class="mr-2" @click="editExercise(slotProps.data); tagsInput = slotProps.data.tags.join(', ')" />
|
||||
<Button icon="pi pi-trash" outlined rounded severity="danger" @click="confirmDeleteExercise(slotProps.data)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<Dialog v-model:visible="exerciseDialog" :style="{width: '450px'}" header="Szczegóły ćwiczenia" :modal="true" class="p-fluid">
|
||||
<div class="field mb-3">
|
||||
<label for="name" class="font-bold mb-2 block">Nazwa</label>
|
||||
<InputText id="name" v-model.trim="exercise.name" required="true" autofocus :class="{'p-invalid': submitted && !exercise.name}" class="w-full" />
|
||||
<small class="p-error text-red-500" v-if="submitted && !exercise.name">Nazwa jest wymagana.</small>
|
||||
</div>
|
||||
<div class="field mb-3">
|
||||
<label for="instructions" class="font-bold mb-2 block">Instrukcje</label>
|
||||
<Textarea id="instructions" v-model="exercise.instructions" required="true" rows="3" cols="20" class="w-full" />
|
||||
</div>
|
||||
<div class="field mb-3">
|
||||
<label for="enrichment" class="font-bold mb-2 block">Enrichment (opcjonalne)</label>
|
||||
<Textarea id="enrichment" v-model="exercise.enrichment" rows="2" cols="20" class="w-full" />
|
||||
</div>
|
||||
<div class="field mb-3">
|
||||
<label for="tags" class="font-bold mb-2 block">Tagi (po przecinku)</label>
|
||||
<InputText id="tags" v-model="tagsInput" @input="updateTags" class="w-full" placeholder="np. nogi, siła" />
|
||||
</div>
|
||||
<div class="field mb-3">
|
||||
<label for="videoUrl" class="font-bold mb-2 block">Video URL (opcjonalne)</label>
|
||||
<InputText id="videoUrl" v-model="exercise.videoUrl" class="w-full" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Anuluj" icon="pi pi-times" text @click="hideDialog" />
|
||||
<Button label="Zapisz" icon="pi pi-check" text @click="saveExercise" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="deleteExerciseDialog" :style="{width: '450px'}" header="Potwierdzenie" :modal="true">
|
||||
<div class="confirmation-content flex align-items-center justify-content-center">
|
||||
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
|
||||
<span v-if="exercise">Czy na pewno chcesz usunąć <b>{{exercise.name}}</b>?</span>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Nie" icon="pi pi-times" text @click="deleteExerciseDialog = false" />
|
||||
<Button label="Tak" icon="pi pi-check" text severity="danger" @click="deleteExercise" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
262
src/app/pages/HomeView.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Button from 'primevue/button';
|
||||
import Timeline from 'primevue/timeline';
|
||||
import { useTrainingPrograms } from '../composables/useTrainingPrograms';
|
||||
import { useTrainingPlans } from '../composables/useTrainingPlans';
|
||||
import { useAnalysis } from '../composables/useAnalysis';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Composables
|
||||
const { activeProgram, programs } = useTrainingPrograms();
|
||||
const { plans } = useTrainingPlans();
|
||||
const { sessions } = useAnalysis();
|
||||
|
||||
// Computed Stats
|
||||
|
||||
// 1. Next Workout
|
||||
const nextWorkout = computed(() => {
|
||||
if (!activeProgram.value) return null;
|
||||
// Find first uncompleted workout
|
||||
return activeProgram.value.workouts.find(w => !w.completed);
|
||||
});
|
||||
|
||||
// 2. Stats Cards
|
||||
const stats = computed(() => {
|
||||
// A. Workouts Completed (Total in active program)
|
||||
const completedCount = activeProgram.value
|
||||
? activeProgram.value.workouts.filter(w => w.completed).length
|
||||
: 0;
|
||||
|
||||
// B. Total Plans
|
||||
const totalPlans = plans.value.length;
|
||||
|
||||
// C. Analysis Sessions
|
||||
const totalAnalysis = sessions.value.length;
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Ukończone Treningi',
|
||||
value: completedCount.toString(),
|
||||
icon: 'pi pi-check-circle',
|
||||
color: 'text-green-500',
|
||||
bg: 'bg-green-100'
|
||||
},
|
||||
{
|
||||
label: 'Dostępne Plany',
|
||||
value: totalPlans.toString(),
|
||||
icon: 'pi pi-list',
|
||||
color: 'text-blue-500',
|
||||
bg: 'bg-blue-100'
|
||||
},
|
||||
{
|
||||
label: 'Sesje Analizy',
|
||||
value: totalAnalysis.toString(),
|
||||
icon: 'pi pi-video',
|
||||
color: 'text-orange-500',
|
||||
bg: 'bg-orange-100'
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
interface ActivityItem {
|
||||
status: string;
|
||||
date: Date;
|
||||
icon: string;
|
||||
color: string;
|
||||
header: string;
|
||||
originalDate: string; // for sorting safety if distinct from Date obj
|
||||
}
|
||||
|
||||
// 3. Recent Activity (Merged Stream)
|
||||
const recentActivity = computed(() => {
|
||||
const activity: ActivityItem[] = [];
|
||||
|
||||
// Add recent analysis sessions
|
||||
sessions.value.forEach(s => {
|
||||
activity.push({
|
||||
status: 'Analiza Wideo',
|
||||
date: new Date(s.date),
|
||||
icon: 'pi pi-video',
|
||||
color: '#f59e0b',
|
||||
header: s.name,
|
||||
originalDate: s.date
|
||||
});
|
||||
});
|
||||
|
||||
// Add Programs creation (as a proxy for "Plan Added")
|
||||
programs.value.forEach(p => {
|
||||
activity.push({
|
||||
status: 'Nowy Program',
|
||||
date: p.createdAt,
|
||||
icon: 'pi pi-calendar',
|
||||
color: '#3b82f6',
|
||||
header: p.name,
|
||||
originalDate: p.createdAt.toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by date desc
|
||||
activity.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
|
||||
// Take top 5 and format date string
|
||||
return activity.slice(0, 5).map(a => ({
|
||||
...a,
|
||||
date: a.date.toLocaleDateString() + ' ' + a.date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}));
|
||||
});
|
||||
|
||||
const quickActions = [
|
||||
{ label: 'Nowy Trening', icon: 'pi pi-plus', command: () => router.push('/trainings'), severity: 'primary' },
|
||||
{ label: 'Analiza Wideo', icon: 'pi pi-video', command: () => router.push('/analysis'), severity: 'help' },
|
||||
{ label: 'Kalendarz', icon: 'pi pi-calendar', command: () => router.push('/calendar'), severity: 'info' },
|
||||
];
|
||||
|
||||
const startNextWorkout = () => {
|
||||
// Navigate to workout timer? Or just trainings view?
|
||||
// Usually to Timer if we can set it active, but logic for "Start Workout" is in useActiveWorkout.
|
||||
// For now, go to Calendar to see context or Trainings.
|
||||
// Let's go to Calendar as it shows the active program schedule.
|
||||
router.push('/calendar');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex flex-column gap-5 h-full overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-column md:flex-row justify-content-between align-items-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="m-0 text-5xl font-bold bg-gradient-to-r from-white to-gray-400 bg-clip-text text-transparent mb-2">Witaj, Kazi! 👋</h1>
|
||||
<p class="text-gray-400 text-lg" v-if="activeProgram">
|
||||
Aktywny program: <span class="font-semibold text-primary-400">{{ activeProgram.name }}</span>
|
||||
</p>
|
||||
<p class="text-gray-400 text-lg" v-else>
|
||||
Nie masz aktywnego programu. Utwórz nowy w Kalendarzu!
|
||||
</p>
|
||||
</div>
|
||||
<Button label="Mój Kalendarz" icon="pi pi-calendar" rounded size="large" class="px-5 py-3 font-semibold shadow-4 hover:shadow-6 transition-all" @click="router.push('/calendar')" />
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid">
|
||||
<div v-for="stat in stats" :key="stat.label" class="col-12 md:col-4">
|
||||
<div class="glass-card surface-card p-4 flex align-items-center gap-4 hover:surface-card transition-duration-300 transform hover:-translate-y-1 h-full relative overflow-hidden group">
|
||||
<!-- Background Glow -->
|
||||
<div class="absolute inset-0 opacity-10 transition-opacity duration-500 group-hover:opacity-20" :class="stat.bg"></div>
|
||||
|
||||
<div class="w-4rem h-4rem border-circle flex align-items-center justify-content-center text-xl relative z-1 shadow-2" :class="[stat.bg, stat.color]">
|
||||
<i :class="['text-2xl', stat.icon]"></i>
|
||||
</div>
|
||||
<div class="relative z-1">
|
||||
<div class="text-400 font-medium mb-1">{{ stat.label }}</div>
|
||||
<div class="text-3xl font-bold text-white">{{ stat.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid mt-2">
|
||||
<!-- Next Workout & Quick Actions -->
|
||||
<div class="col-12 md:col-8 flex flex-column gap-5">
|
||||
|
||||
<!-- Next Workout Card -->
|
||||
<div v-if="nextWorkout" class="relative overflow-hidden border-round-2xl p-5 shadow-6 cursor-pointer group transition-all duration-300 hover:shadow-8"
|
||||
style="background: linear-gradient(135deg, var(--primary-900) 0%, #1e1b4b 100%);"
|
||||
@click="startNextWorkout">
|
||||
|
||||
<div class="relative z-2">
|
||||
<div class="flex justify-content-between align-items-start mb-4">
|
||||
<div class="text-primary-300 font-semibold uppercase tracking-wider text-xs bg-primary-900 px-3 py-1 border-round-pill shadow-1">Następny Trening</div>
|
||||
<Button icon="pi pi-arrow-right" rounded text class="text-white hover:bg-white-alpha-10 transition-colors" />
|
||||
</div>
|
||||
|
||||
<h2 class="text-4xl font-bold text-white mb-2">{{ nextWorkout.name }}</h2>
|
||||
<p class="m-0 text-gray-300 text-lg line-height-3 max-w-30rem">{{ nextWorkout.description || 'Przygotuj się na solidny wycisk.' }}</p>
|
||||
|
||||
<div class="mt-4 flex gap-3">
|
||||
<span v-if="nextWorkout.day" class="glass-card px-3 py-1 text-sm bg-white-alpha-10 text-white flex align-items-center gap-2">
|
||||
<i class="pi pi-calendar text-xs"></i>
|
||||
{{ nextWorkout.day }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative Elements -->
|
||||
<div class="absolute w-20rem h-20rem bg-primary-500 border-circle blur-3xl opacity-20" style="top: -5rem; right: -5rem;"></div>
|
||||
<i class="pi pi-bolt absolute text-white opacity-5 text-9xl transition-transform duration-700 group-hover:rotate-12 group-hover:scale-110"
|
||||
style="right: -2rem; bottom: -3rem; font-size: 16rem !important;"></i>
|
||||
</div>
|
||||
|
||||
<div v-else class="glass-card p-5 border-dashed border-2 border-gray-700 text-center hover:border-primary-500 transition-colors duration-300">
|
||||
<div class="text-2xl font-bold text-white mb-2">Brak zaplanowanych treningów</div>
|
||||
<p class="text-gray-400 mb-4">Wszystkie treningi w tym programie zostały ukończone!</p>
|
||||
<Button label="Zarządzaj Programem" outlined class="font-semibold" @click="router.push('/calendar')" />
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="glass-card p-5">
|
||||
<h2 class="text-xl font-bold text-white mb-4 flex align-items-center gap-2">
|
||||
<i class="pi pi-bolt text-yellow-500"></i>
|
||||
Szybkie Akcje
|
||||
</h2>
|
||||
<div class="grid formgrid p-fluid">
|
||||
<div v-for="action in quickActions" :key="action.label" class="col-12 md:col-4 mb-3 md:mb-0">
|
||||
<Button :label="action.label" :icon="action.icon" :severity="action.severity"
|
||||
class="h-full py-4 text-left justify-content-start font-medium text-lg surface-card border-none hover:bg-white-alpha-10 transition-colors shadow-2"
|
||||
outlined
|
||||
@click="action.command" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Feed -->
|
||||
<div class="col-12 md:col-4">
|
||||
<div class="glass-card h-full p-4 flex flex-column">
|
||||
<div class="flex justify-content-between align-items-center mb-4 pb-3 border-bottom-1 border-white-alpha-10">
|
||||
<h2 class="text-xl font-bold text-white m-0">Ostatnia Aktywność</h2>
|
||||
<Button icon="pi pi-ellipsis-h" text rounded class="text-gray-400 hover:text-white" />
|
||||
</div>
|
||||
|
||||
<div v-if="recentActivity.length === 0" class="flex-1 flex align-items-center justify-content-center flex-column gap-3 text-center text-gray-500">
|
||||
<i class="pi pi-calendar-times text-4xl opacity-50"></i>
|
||||
<span>Brak ostatniej aktywności</span>
|
||||
</div>
|
||||
|
||||
<Timeline v-else :value="recentActivity" class="customized-timeline w-full">
|
||||
<template #marker="slotProps">
|
||||
<span class="flex w-2rem h-2rem align-items-center justify-content-center text-white border-circle z-1 shadow-2" :style="{backgroundColor: slotProps.item.color}">
|
||||
<i :class="[slotProps.item.icon, 'text-xs']"></i>
|
||||
</span>
|
||||
</template>
|
||||
<template #content="slotProps">
|
||||
<div class="mb-4 pl-3">
|
||||
<span class="text-gray-500 text-xs font-medium block mb-1">{{ slotProps.item.date }}</span>
|
||||
<div class="surface-card p-3 border-round-xl border-1 border-white-alpha-5 shadow-none hover:bg-white-alpha-5 transition-colors cursor-pointer">
|
||||
<span class="text-white font-semibold block mb-1">{{ slotProps.item.header }}</span>
|
||||
<span class="text-primary-400 text-xs font-uppercase font-bold tracking-wide">{{ slotProps.item.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Timeline>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.glass-card {
|
||||
/* Extending global glass-card if needed, or specific component overrides */
|
||||
}
|
||||
|
||||
/* Custom bg clip for gradient text support across browsers */
|
||||
.bg-clip-text {
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
</style>
|
||||
344
src/app/pages/TrainingsView.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Select from 'primevue/select';
|
||||
import ToggleButton from 'primevue/togglebutton';
|
||||
import { useExercises } from '../composables/useExercises';
|
||||
import { useActiveWorkout } from '../composables/useActiveWorkout';
|
||||
import { useTrainingPlans, type TrainingPlan, type TrainingExercise } from '../composables/useTrainingPlans';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const { startWorkout } = useActiveWorkout();
|
||||
const { plans, createPlan: createNewPlan, deletePlan: removePlan, updatePlan } = useTrainingPlans();
|
||||
|
||||
// --- Shared State ---
|
||||
const { exercises, addExercise } = useExercises();
|
||||
|
||||
// --- State ---
|
||||
const activePlanId = ref<string | null>(null);
|
||||
const currentPlan = ref<TrainingPlan | null>(null);
|
||||
|
||||
const showAddExerciseDialog = ref(false);
|
||||
const activeSectionId = ref<string | null>(null);
|
||||
|
||||
// New/Select Exercise Form
|
||||
const selectedExerciseId = ref<string | null>(null);
|
||||
const newExerciseName = ref('');
|
||||
const newExerciseInstructions = ref('');
|
||||
const newExerciseTags = ref('');
|
||||
const isCreatingNew = ref(false);
|
||||
|
||||
// --- Actions ---
|
||||
const selectPlan = (plan: TrainingPlan) => {
|
||||
// Clone deeply to avoid editing list directly before save (mock)
|
||||
currentPlan.value = JSON.parse(JSON.stringify(plan));
|
||||
activePlanId.value = plan.id;
|
||||
};
|
||||
|
||||
const startSession = () => {
|
||||
if (currentPlan.value) {
|
||||
startWorkout(currentPlan.value);
|
||||
router.push('/timer');
|
||||
}
|
||||
}
|
||||
|
||||
const createPlan = async () => {
|
||||
const newPlan = await createNewPlan();
|
||||
selectPlan(newPlan);
|
||||
};
|
||||
|
||||
const savePlan = async () => {
|
||||
if (currentPlan.value) {
|
||||
await updatePlan(JSON.parse(JSON.stringify(currentPlan.value)));
|
||||
// alert('Plan zapisany!');
|
||||
}
|
||||
};
|
||||
|
||||
const deletePlan = async (id: string) => {
|
||||
await removePlan(id);
|
||||
if (activePlanId.value === id) {
|
||||
currentPlan.value = null;
|
||||
activePlanId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const addSection = () => {
|
||||
if (currentPlan.value) {
|
||||
currentPlan.value.sections.push({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: 'Nowa Sekcja',
|
||||
exercises: []
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeSection = (sectionIndex: number) => {
|
||||
if (currentPlan.value) {
|
||||
currentPlan.value.sections.splice(sectionIndex, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const moveSection = (index: number, direction: -1 | 1) => {
|
||||
if (!currentPlan.value) return;
|
||||
const newIndex = index + direction;
|
||||
if (newIndex >= 0 && newIndex < currentPlan.value.sections.length) {
|
||||
const temp = currentPlan.value.sections[index];
|
||||
currentPlan.value.sections[index] = currentPlan.value.sections[newIndex];
|
||||
currentPlan.value.sections[newIndex] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
const openAddExercise = (sectionId: string) => {
|
||||
activeSectionId.value = sectionId;
|
||||
selectedExerciseId.value = null;
|
||||
newExerciseName.value = '';
|
||||
newExerciseInstructions.value = '';
|
||||
newExerciseTags.value = '';
|
||||
isCreatingNew.value = false;
|
||||
showAddExerciseDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmAddExercise = async () => {
|
||||
if (!currentPlan.value || !activeSectionId.value) return;
|
||||
|
||||
let exId = selectedExerciseId.value;
|
||||
let exName = '';
|
||||
|
||||
if (isCreatingNew.value) {
|
||||
if (!newExerciseName.value.trim()) return;
|
||||
// Create new in detailed DB
|
||||
const newEx = await addExercise({
|
||||
name: newExerciseName.value,
|
||||
instructions: newExerciseInstructions.value,
|
||||
enrichment: '',
|
||||
tags: newExerciseTags.value.split(',').map(t => t.trim()).filter(t => t),
|
||||
videoUrl: ''
|
||||
});
|
||||
exId = newEx.id;
|
||||
exName = newEx.name;
|
||||
} else {
|
||||
if (!exId) return;
|
||||
const existing = exercises.value.find(e => e.id === exId);
|
||||
if (existing) exName = existing.name;
|
||||
}
|
||||
|
||||
// Add to section
|
||||
const section = currentPlan.value.sections.find(s => s.id === activeSectionId.value);
|
||||
if (section) {
|
||||
section.exercises.push({
|
||||
instanceId: Math.random().toString(36).substr(2, 9),
|
||||
exerciseId: exId!,
|
||||
name: exName,
|
||||
sets: 3,
|
||||
value: 10,
|
||||
isTime: false,
|
||||
rest: 60
|
||||
});
|
||||
}
|
||||
|
||||
showAddExerciseDialog.value = false;
|
||||
};
|
||||
|
||||
const removeExerciseFromSection = (sectionId: string, exInstanceId: string) => {
|
||||
if (!currentPlan.value) return;
|
||||
const section = currentPlan.value.sections.find(s => s.id === sectionId);
|
||||
if (section) {
|
||||
section.exercises = section.exercises.filter(e => e.instanceId !== exInstanceId);
|
||||
}
|
||||
};
|
||||
|
||||
// Simple Drag and Drop for Exercises (Swapping/Reordering within section logic could be complex without library)
|
||||
// For MVP, implementing Move Up/Down arrows for exercises
|
||||
const moveExercise = (sectionId: string, index: number, direction: -1 | 1) => {
|
||||
const section = currentPlan.value?.sections.find(s => s.id === sectionId);
|
||||
if (section) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex >= 0 && newIndex < section.exercises.length) {
|
||||
const temp = section.exercises[index];
|
||||
section.exercises[index] = section.exercises[newIndex];
|
||||
section.exercises[newIndex] = temp;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Drag & Drop between sections (Mock implementation using Drag Events)
|
||||
const draggedExercise = ref<{ ex: TrainingExercise, sourceSectionId: string } | null>(null);
|
||||
|
||||
const onDragStart = (ex: TrainingExercise, sectionId: string) => {
|
||||
draggedExercise.value = { ex, sourceSectionId: sectionId };
|
||||
};
|
||||
|
||||
const onDrop = (targetSectionId: string) => {
|
||||
if (draggedExercise.value && currentPlan.value) {
|
||||
const { ex, sourceSectionId } = draggedExercise.value;
|
||||
if (sourceSectionId === targetSectionId) return; // Reordering handled by buttons/sortable for now
|
||||
|
||||
// Remove from source
|
||||
const sourceSec = currentPlan.value.sections.find(s => s.id === sourceSectionId);
|
||||
if (sourceSec) {
|
||||
sourceSec.exercises = sourceSec.exercises.filter(e => e.instanceId !== ex.instanceId);
|
||||
}
|
||||
|
||||
// Add to target
|
||||
const targetSec = currentPlan.value.sections.find(s => s.id === targetSectionId);
|
||||
if (targetSec) {
|
||||
targetSec.exercises.push(ex);
|
||||
}
|
||||
|
||||
draggedExercise.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex gap-4">
|
||||
<!-- Sidebar: List of Plans -->
|
||||
<div class="w-16rem flex-shrink-0 flex flex-column gap-2 border-right-1 surface-border pr-3">
|
||||
<div class="flex justify-content-between align-items-center mb-2">
|
||||
<span class="font-bold text-lg">Twoje Plany</span>
|
||||
<Button icon="pi pi-plus" size="small" rounded text @click="createPlan" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-2 overflow-y-auto flex-grow-1">
|
||||
<div v-for="plan in plans" :key="plan.id"
|
||||
class="p-3 surface-card border-round cursor-pointer hover:surface-hover transition-colors border-left-3"
|
||||
:class="activePlanId === plan.id ? 'border-primary surface-hover' : 'border-transparent'"
|
||||
@click="selectPlan(plan)">
|
||||
<div class="font-bold mb-1">{{ plan.name }}</div>
|
||||
<div class="text-xs text-color-secondary">{{ plan.sections.length }} sekcji</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Editor -->
|
||||
<div class="flex-grow-1 overflow-y-auto" v-if="currentPlan">
|
||||
<div class="flex justify-content-between align-items-center mb-4">
|
||||
<div class="flex align-items-center gap-3">
|
||||
<InputText v-model="currentPlan.name" class="text-xl font-bold border-none shadow-none p-0 w-20rem" placeholder="Nazwa Planu" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Rozpocznij" icon="pi pi-play" severity="success" @click="startSession" />
|
||||
<Button label="Zapisz" icon="pi pi-check" @click="savePlan" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined @click="deletePlan(currentPlan.id)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-4 pb-8">
|
||||
<div v-for="(section, sIndex) in currentPlan.sections" :key="section.id"
|
||||
class="surface-card p-4 border-round shadow-1"
|
||||
@dragover.prevent @drop="onDrop(section.id)">
|
||||
|
||||
<div class="flex justify-content-between align-items-center mb-3">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<InputText v-model="section.name" class="font-bold border-none shadow-none p-0" placeholder="Nazwa Sekcji" />
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Button icon="pi pi-arrow-up" text rounded size="small" :disabled="sIndex === 0" @click="moveSection(sIndex, -1)"/>
|
||||
<Button icon="pi pi-arrow-down" text rounded size="small" :disabled="sIndex === currentPlan.sections.length - 1" @click="moveSection(sIndex, 1)"/>
|
||||
<Button icon="pi pi-times" text rounded severity="danger" size="small" @click="removeSection(sIndex)"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-3 min-h-3rem"> <!-- min-height for drop zone -->
|
||||
<div v-for="(ex, exIndex) in section.exercises" :key="ex.instanceId"
|
||||
class="surface-ground p-3 border-round border-left-3 border-primary"
|
||||
draggable="true" @dragstart="onDragStart(ex, section.id)">
|
||||
|
||||
<!-- Header: Name & Controls -->
|
||||
<div class="flex justify-content-between align-items-start mb-3">
|
||||
<span class="font-bold">{{ ex.name }}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button icon="pi pi-arrow-up" text rounded size="small" class="p-0 w-2rem h-2rem" :disabled="exIndex === 0" @click="moveExercise(section.id, exIndex, -1)"/>
|
||||
<Button icon="pi pi-arrow-down" text rounded size="small" class="p-0 w-2rem h-2rem" :disabled="exIndex === section.exercises.length - 1" @click="moveExercise(section.id, exIndex, 1)"/>
|
||||
<Button icon="pi pi-trash" text rounded severity="danger" size="small" class="p-0 w-2rem h-2rem" @click="removeExerciseFromSection(section.id, ex.instanceId)"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Grid -->
|
||||
<div class="grid align-items-center gy-2"> <!-- gy-2 for vertical gap -->
|
||||
<div class="col-12 md:col-3 flex align-items-center gap-2">
|
||||
<label class="text-sm font-semibold">Sets</label>
|
||||
<InputNumber v-model="ex.sets" showButtons :min="1" buttonLayout="horizontal" inputClass="w-3rem text-center p-1" class="w-full" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-5 flex align-items-center gap-2">
|
||||
<ToggleButton v-model="ex.isTime" onLabel="Czas (s)" offLabel="Powt." onIcon="pi pi-clock" offIcon="pi pi-hashtag" class="w-6rem text-xs" />
|
||||
<InputNumber v-model="ex.value" showButtons :min="1" buttonLayout="horizontal" inputClass="w-4rem text-center p-1" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-4 flex align-items-center gap-2 justify-content-end">
|
||||
<label class="text-sm font-semibold">Rest (s)</label>
|
||||
<InputNumber v-model="ex.rest" showButtons :step="10" :min="0" buttonLayout="horizontal" inputClass="w-3rem text-center p-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="section.exercises.length === 0" class="text-center text-sm text-color-secondary py-3 border-1 border-dashed surface-border border-round">
|
||||
Przeciągnij tutaj lub dodaj ćwiczenie
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-center">
|
||||
<Button label="Dodaj ćwiczenie" icon="pi pi-plus" size="small" text @click="openAddExercise(section.id)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<Button label="Dodaj Sekcję" icon="pi pi-plus" outlined @click="addSection" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-grow-1 flex align-items-center justify-content-center text-color-secondary">
|
||||
Wybierz lub utwórz plan z listy po lewej
|
||||
</div>
|
||||
|
||||
<!-- Add Exercise Dialog -->
|
||||
<Dialog v-model:visible="showAddExerciseDialog" header="Dodaj ćwiczenie" :style="{ width: '400px' }" modal>
|
||||
<div class="flex flex-column gap-3">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<Button label="Wybierz z listy" :outlined="isCreatingNew" class="flex-1" @click="isCreatingNew = false" size="small" />
|
||||
<Button label="Utwórz nowe" :outlined="!isCreatingNew" class="flex-1" @click="isCreatingNew = true" size="small" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isCreatingNew">
|
||||
<label class="block mb-2 font-bold">Znajdź ćwiczenie</label>
|
||||
<Select v-model="selectedExerciseId" :options="exercises" optionLabel="name" optionValue="id" filter showClear placeholder="Wybierz..." class="w-full" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="field mb-3">
|
||||
<label class="block mb-2 font-bold">Nazwa nowego ćwiczenia</label>
|
||||
<InputText v-model="newExerciseName" class="w-full" placeholder="Np. Wyciskanie sztangi" autofocus />
|
||||
</div>
|
||||
<div class="field mb-3">
|
||||
<label class="block mb-2 font-bold">Opis / Instrukcje</label>
|
||||
<Textarea v-model="newExerciseInstructions" class="w-full" rows="2" placeholder="Krótki opis..." />
|
||||
</div>
|
||||
<div class="field mb-3">
|
||||
<label class="block mb-2 font-bold">Tagi</label>
|
||||
<InputText v-model="newExerciseTags" class="w-full" placeholder="np. klatka, siła (po przecinku)" />
|
||||
</div>
|
||||
<small class="block mt-2 text-color-secondary">Zostanie dodane do globalnej bazy ćwiczeń.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Anuluj" text @click="showAddExerciseDialog = false" />
|
||||
<Button label="Dodaj" icon="pi pi-check" @click="confirmAddExercise" :disabled="(!isCreatingNew && !selectedExerciseId) || (isCreatingNew && !newExerciseName)" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-inputnumber-button) {
|
||||
width: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
293
src/app/pages/WorkoutTimerView.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Button from 'primevue/button';
|
||||
import ProgressBar from 'primevue/progressbar';
|
||||
import Card from 'primevue/card';
|
||||
import Drawer from 'primevue/drawer'; // Replaced Sidebar component
|
||||
import { useActiveWorkout, type TrainingPlan } from '../composables/useActiveWorkout';
|
||||
|
||||
const router = useRouter();
|
||||
const { activePlan } = useActiveWorkout();
|
||||
|
||||
// Flattened Activity Structure
|
||||
interface Activity {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'work' | 'rest';
|
||||
duration: number; // seconds (0 if reps based, manual advance)
|
||||
originalExercise?: any;
|
||||
sectionName?: string;
|
||||
setIndex?: number;
|
||||
totalSets?: number;
|
||||
}
|
||||
|
||||
const activities = ref<Activity[]>([]);
|
||||
const currentActivityIndex = ref(0);
|
||||
const timeRemaining = ref(0);
|
||||
const totalTimeElapsed = ref(0);
|
||||
const isRunning = ref(false);
|
||||
const isFinished = ref(false);
|
||||
const timerInterval = ref<number | null>(null);
|
||||
const showActivityList = ref(false); // Sidebar state
|
||||
|
||||
// Audio
|
||||
const beepShort = new Audio('https://raw.githubusercontent.com/sound-effects/beep-sounds/main/mp3/beep-07.mp3');
|
||||
const beepLong = new Audio('https://raw.githubusercontent.com/sound-effects/beep-sounds/main/mp3/beep-09.mp3');
|
||||
|
||||
const BuildWorkoutSequence = (plan: TrainingPlan) => {
|
||||
let seq: Activity[] = [];
|
||||
|
||||
plan.sections.forEach(section => {
|
||||
section.exercises.forEach(ex => {
|
||||
for (let s = 1; s <= ex.sets; s++) {
|
||||
// WORK
|
||||
seq.push({
|
||||
id: `${ex.instanceId}-s${s}-work`,
|
||||
name: ex.name,
|
||||
type: 'work',
|
||||
duration: ex.isTime ? ex.value : 0, // 0 means manual completion
|
||||
originalExercise: ex,
|
||||
sectionName: section.name,
|
||||
setIndex: s,
|
||||
totalSets: ex.sets
|
||||
});
|
||||
|
||||
// REST
|
||||
if (ex.rest > 0) {
|
||||
seq.push({
|
||||
id: `${ex.instanceId}-s${s}-rest`,
|
||||
name: 'Przerwa',
|
||||
type: 'rest',
|
||||
duration: ex.rest,
|
||||
sectionName: section.name
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return seq;
|
||||
};
|
||||
|
||||
// Computed Properties
|
||||
const currentActivity = computed(() => activities.value[currentActivityIndex.value]);
|
||||
const nextActivity = computed(() => activities.value[currentActivityIndex.value + 1]);
|
||||
|
||||
const currentActivityProgress = computed(() => {
|
||||
if (!currentActivity.value || currentActivity.value.duration === 0) return 0;
|
||||
return ((currentActivity.value.duration - timeRemaining.value) / currentActivity.value.duration) * 100;
|
||||
});
|
||||
|
||||
// progressColor removed
|
||||
|
||||
// Timer Logic
|
||||
const tick = () => {
|
||||
if (currentActivity.value?.duration > 0) {
|
||||
if (timeRemaining.value > 0) {
|
||||
timeRemaining.value--;
|
||||
totalTimeElapsed.value++;
|
||||
|
||||
// Beeps
|
||||
if (timeRemaining.value <= 3 && timeRemaining.value > 0) beepShort.play().catch(() => {});
|
||||
if (timeRemaining.value === 0) beepLong.play().catch(() => {});
|
||||
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
totalTimeElapsed.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const startTimer = () => {
|
||||
if (timerInterval.value) return;
|
||||
isRunning.value = true;
|
||||
timerInterval.value = window.setInterval(tick, 1000);
|
||||
};
|
||||
|
||||
const pauseTimer = () => {
|
||||
if (timerInterval.value) {
|
||||
clearInterval(timerInterval.value);
|
||||
timerInterval.value = null;
|
||||
}
|
||||
isRunning.value = false;
|
||||
};
|
||||
|
||||
// resetTimer removed
|
||||
|
||||
const next = () => {
|
||||
if (currentActivityIndex.value < activities.value.length - 1) {
|
||||
currentActivityIndex.value++;
|
||||
loadActivity();
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
const prev = () => {
|
||||
if (currentActivityIndex.value > 0) {
|
||||
currentActivityIndex.value--;
|
||||
loadActivity();
|
||||
}
|
||||
};
|
||||
|
||||
const loadActivity = () => {
|
||||
const act = activities.value[currentActivityIndex.value];
|
||||
if (act.duration > 0) {
|
||||
timeRemaining.value = act.duration;
|
||||
// Auto-start only if it's Rest or previous was auto-completed?
|
||||
// For safe UX, let's auto-start REST, but require user start for WORK (unless running).
|
||||
// Actually simplest is: if was running, keep running.
|
||||
if (isRunning.value) {
|
||||
// ensure interval is active
|
||||
} else {
|
||||
// pauseTimer();
|
||||
// Maybe auto-start rest periods?
|
||||
if (act.type === 'rest') startTimer();
|
||||
}
|
||||
} else {
|
||||
timeRemaining.value = 0; // Manual
|
||||
pauseTimer(); // Stop timer for manual rep counting
|
||||
}
|
||||
};
|
||||
|
||||
const jumpToActivity = (index: number) => {
|
||||
currentActivityIndex.value = index;
|
||||
loadActivity();
|
||||
showActivityList.value = false;
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
pauseTimer();
|
||||
isFinished.value = true;
|
||||
beepLong.play().catch(() => {});
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (!activePlan.value) {
|
||||
// Don't redirect, let the template handle the empty state
|
||||
// router.push('/trainings');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build Sequence
|
||||
activities.value = BuildWorkoutSequence(activePlan.value);
|
||||
loadActivity();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
pauseTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-column align-items-center justify-content-center text-center p-4 relative overflow-hidden" :class="currentActivity?.type === 'rest' ? 'bg-green-50' : ''">
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<Drawer v-model:visible="showActivityList" header="Lista Ćwiczeń" position="right" class="w-full md:w-20rem">
|
||||
<div class="flex flex-column gap-2 mb-4">
|
||||
<div v-for="(act, index) in activities" :key="act.id"
|
||||
class="p-2 border-round cursor-pointer hover:surface-hover transition-colors flex align-items-center gap-2 text-left"
|
||||
:class="{'bg-primary-50 border-left-3 border-primary': index === currentActivityIndex, 'opacity-50': index < currentActivityIndex}"
|
||||
@click="jumpToActivity(index)">
|
||||
|
||||
<div class="w-2rem text-center font-bold text-xs">{{ index + 1 }}</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-bold text-sm">{{ act.name }}</div>
|
||||
<div class="text-xs text-color-secondary">{{ act.type === 'work' ? (act.duration > 0 ? `${act.duration}s` : 'Powt.') : 'Przerwa' }}</div>
|
||||
</div>
|
||||
<i v-if="index < currentActivityIndex" class="pi pi-check text-green-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<!-- Top Bar -->
|
||||
<div class="absolute top-0 left-0 w-full p-3 flex justify-content-between align-items-center z-3">
|
||||
<Button icon="pi pi-arrow-left" text rounded @click="router.push('/trainings')" />
|
||||
<Button icon="pi pi-list" text rounded @click="showActivityList = true" />
|
||||
</div>
|
||||
|
||||
<div v-if="activePlan && !isFinished" class="w-full max-w-30rem flex flex-column gap-5 z-2">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<div class="text-sm uppercase font-bold text-color-secondary mb-2">
|
||||
{{ currentActivity?.sectionName }}
|
||||
<span v-if="currentActivity?.totalSets">• Seria {{ currentActivity.setIndex }} / {{ currentActivity.totalSets }}</span>
|
||||
</div>
|
||||
<h1 class="text-6xl font-bold m-0" :class="currentActivity?.type === 'work' ? 'text-orange-500' : 'text-green-500'">
|
||||
{{ currentActivity?.name }}
|
||||
</h1>
|
||||
<div class="text-xl mt-2 text-color-secondary" v-if="nextActivity">
|
||||
Następnie: {{ nextActivity.name }}
|
||||
</div>
|
||||
<div class="text-xl mt-2 text-green-600 font-bold" v-else>
|
||||
Ostatnie ćwiczenie!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer / Reps Display -->
|
||||
<div class="py-6">
|
||||
<!-- TIME BASED -->
|
||||
<div v-if="currentActivity?.duration > 0" class="text-8xl font-mono font-bold">
|
||||
{{ formatTime(timeRemaining) }}
|
||||
</div>
|
||||
<!-- REPS BASED -->
|
||||
<div v-else class="text-6xl font-bold flex flex-column align-items-center">
|
||||
<span>{{ currentActivity?.originalExercise?.value }}</span>
|
||||
<span class="text-3xl text-color-secondary uppercase">Powtórzeń</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar (for timed activities) -->
|
||||
<ProgressBar v-if="currentActivity?.duration > 0" :value="currentActivityProgress" :showValue="false" style="height: 1.5rem"
|
||||
:pt="{ value: { style: { backgroundColor: currentActivity?.type === 'work' ? 'var(--orange-500)' : 'var(--green-500)' } } }" />
|
||||
<div v-else class="h-1-5rem"></div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-content-center gap-4 align-items-center relative">
|
||||
<Button icon="pi pi-step-backward" text rounded size="large" @click="prev" :disabled="currentActivityIndex === 0" />
|
||||
|
||||
<!-- Play/Pause for Time -->
|
||||
<template v-if="currentActivity?.duration > 0">
|
||||
<Button v-if="!isRunning" icon="pi pi-play" rounded raised size="large" class="w-5rem h-5rem p-0 text-3xl" @click="startTimer" />
|
||||
<Button v-else icon="pi pi-pause" rounded raised severity="secondary" size="large" class="w-5rem h-5rem p-0 text-3xl" @click="pauseTimer" />
|
||||
</template>
|
||||
|
||||
<!-- Check for Reps -->
|
||||
<template v-else>
|
||||
<Button icon="pi pi-check" rounded raised severity="success" size="large" class="w-6rem h-6rem p-0 text-4xl shadow-4" @click="next" />
|
||||
</template>
|
||||
|
||||
<Button icon="pi pi-step-forward" text rounded size="large" @click="next" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-content-center gap-3">
|
||||
<Button label="-10s" size="small" outlined severity="secondary" @click="timeRemaining = Math.max(0, timeRemaining - 10)" v-if="currentActivity?.duration > 0" />
|
||||
<Button label="+10s" size="small" outlined severity="secondary" @click="timeRemaining += 10" v-if="currentActivity?.duration > 0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Finished State -->
|
||||
<div v-if="isFinished" class="text-center">
|
||||
<i class="pi pi-trophy text-yellow-500" style="font-size: 5rem"></i>
|
||||
<h1 class="text-5xl font-bold mt-4">Trening Zakończony!</h1>
|
||||
<p class="text-xl">Całkowity czas: {{ formatTime(totalTimeElapsed) }}</p>
|
||||
<Button label="Wróć do planów" icon="pi pi-arrow-left" class="mt-4" @click="router.push('/trainings')" />
|
||||
</div>
|
||||
|
||||
<!-- No Plan State (Safety) -->
|
||||
<Card v-if="!activePlan" class="text-center">
|
||||
<template #content>
|
||||
Nie wybrano planu.
|
||||
<Button label="Wróć" link @click="router.push('/trainings')" />
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
28
src/app/router/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createWebHistory, createRouter } from 'vue-router';
|
||||
|
||||
// Lazy loading views
|
||||
// Lazy loading views
|
||||
const HomeView = () => import('@/app/pages/HomeView.vue');
|
||||
const ExercisesView = () => import('@/app/pages/ExercisesView.vue');
|
||||
const CalendarView = () => import('@/app/pages/CalendarView.vue');
|
||||
const TrainingsView = () => import('@/app/pages/TrainingsView.vue');
|
||||
const WorkoutTimerView = () => import('@/app/pages/WorkoutTimerView.vue');
|
||||
const AnalysisView = () => import('@/app/pages/AnalysisView.vue');
|
||||
const ChatAIView = () => import('@/app/pages/ChatAIView.vue');
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: HomeView },
|
||||
{ path: '/exercises', component: ExercisesView },
|
||||
{ path: '/calendar', component: CalendarView },
|
||||
{ path: '/trainings', component: TrainingsView },
|
||||
{ path: '/timer', component: WorkoutTimerView },
|
||||
{ path: '/analysis', component: AnalysisView },
|
||||
{ path: '/chat', component: ChatAIView },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
133
src/app/services/aiService.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Command } from '@tauri-apps/plugin-shell';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { checkModelsExist, getModelPath, getEmbeddingModelPath } from './modelManager';
|
||||
import { CONFIG } from '../config';
|
||||
|
||||
|
||||
|
||||
interface ServerProcess {
|
||||
process: any;
|
||||
port: string;
|
||||
}
|
||||
|
||||
let chatServer: ServerProcess | null = null;
|
||||
let embeddingServer: ServerProcess | null = null;
|
||||
|
||||
// Helper: Wait for server to be ready
|
||||
// Helper: Wait for server to be ready
|
||||
const waitForServer = async (port: string, retries = 60, delay = 1000): Promise<void> => {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
// Check health
|
||||
const response = await fetch(`http://${CONFIG.AI_SERVER.HOST}:${port}${CONFIG.AI_SERVER.HEALTH_ENDPOINT}`);
|
||||
if (response.ok) {
|
||||
console.log(`[AIService:${port}] Server is ready!`);
|
||||
return;
|
||||
} else {
|
||||
console.warn(`[AIService:${port}] Server returned status: ${response.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Server not listening yet, ignore and wait
|
||||
console.debug(`[AIService:${port}] Connection failed (attempt ${i + 1}):`, e);
|
||||
}
|
||||
console.log(`[AIService:${port}] Waiting for AI server... (${i + 1}/${retries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
throw new Error(`AI Server on port ${port} failed to start in time.`);
|
||||
};
|
||||
|
||||
// Internal start helper
|
||||
const startSidecar = async (type: 'chat' | 'embedding'): Promise<ServerProcess> => {
|
||||
const isChat = type === 'chat';
|
||||
const port = isChat ? CONFIG.AI_SERVER.PORT : CONFIG.AI_SERVER.EMBEDDING_PORT!;
|
||||
const existing = isChat ? chatServer : embeddingServer;
|
||||
|
||||
if (existing) {
|
||||
console.log(`[AIService] ${type} server already running on port ${port}.`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const modelsExist = await checkModelsExist();
|
||||
if (!modelsExist) {
|
||||
throw new Error("Models not found. Please download them first.");
|
||||
}
|
||||
|
||||
const modelPath = isChat ? await getModelPath() : await getEmbeddingModelPath();
|
||||
const args = [
|
||||
'--host', CONFIG.AI_SERVER.HOST,
|
||||
'--port', port,
|
||||
'--n-gpu-layers', isChat ? CONFIG.MODELS.CHAT.GPU_LAYERS.toString() : CONFIG.MODELS.EMBEDDING.GPU_LAYERS.toString(),
|
||||
'-m', modelPath,
|
||||
'-c', isChat ? CONFIG.MODELS.CHAT.CONTEXT_SIZE.toString() : CONFIG.MODELS.EMBEDDING.CONTEXT_SIZE.toString()
|
||||
];
|
||||
|
||||
if (!isChat) {
|
||||
args.push('--embedding');
|
||||
// Increase batch size for embeddings to handle larger inputs
|
||||
args.push('-b', '2048');
|
||||
args.push('-ub', '2048');
|
||||
console.log("[AIService] Starting Embedding Mode (Nomic)");
|
||||
} else {
|
||||
console.log("[AIService] Starting Chat Mode (Llama 3.2)");
|
||||
}
|
||||
|
||||
try {
|
||||
const command = Command.sidecar('llama-server', args);
|
||||
|
||||
command.on('close', (data: { code: number | null, signal: number | null }) => {
|
||||
console.log(`[AIService:${port}] Sidecar closed with code ${data.code}`);
|
||||
if (isChat) chatServer = null;
|
||||
else embeddingServer = null;
|
||||
});
|
||||
|
||||
command.on('error', (error: any) => {
|
||||
console.error(`[AIService:${port}] Sidecar error: "${error}"`);
|
||||
if (isChat) chatServer = null;
|
||||
else embeddingServer = null;
|
||||
});
|
||||
|
||||
command.stdout.on('data', (line: string) => console.log(`[AI:${port}] ${line}`));
|
||||
command.stderr.on('data', (line: string) => console.log(`[AI LOG:${port}] ${line}`));
|
||||
|
||||
console.log(`[AIService] Spawning sidecar (${type}) on port ${port}...`);
|
||||
const child = await command.spawn();
|
||||
|
||||
const serverProc = { process: child, port };
|
||||
if (isChat) chatServer = serverProc;
|
||||
else embeddingServer = serverProc;
|
||||
|
||||
console.log(`[AIService] Sidecar spawned, pid: ${child.pid}`);
|
||||
|
||||
// Wait for server to actually start
|
||||
await waitForServer(port);
|
||||
|
||||
return serverProc;
|
||||
|
||||
} catch (e) {
|
||||
console.error(`[AIService] Failed to start ${type} server:`, e);
|
||||
if (isChat && chatServer) { await chatServer.process.kill(); chatServer = null; }
|
||||
if (!isChat && embeddingServer) { await embeddingServer.process.kill(); embeddingServer = null; }
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const startServer = async (mode: 'chat' | 'embedding' = 'chat'): Promise<void> => {
|
||||
// This function is kept for backward compatibility but now starts the specific server
|
||||
// It can be called multiple times safely.
|
||||
await startSidecar(mode);
|
||||
};
|
||||
|
||||
export const stopServer = async (): Promise<void> => {
|
||||
// Stops ALL servers
|
||||
if (chatServer) {
|
||||
console.log("[AIService] Stopping chat server...");
|
||||
await chatServer.process.kill();
|
||||
chatServer = null;
|
||||
}
|
||||
if (embeddingServer) {
|
||||
console.log("[AIService] Stopping embedding server...");
|
||||
await embeddingServer.process.kill();
|
||||
embeddingServer = null;
|
||||
}
|
||||
};
|
||||
|
||||
98
src/app/services/modelManager.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { BaseDirectory, exists, mkdir, create } from '@tauri-apps/plugin-fs';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { appLocalDataDir, join } from '@tauri-apps/api/path';
|
||||
import { CONFIG } from '../config';
|
||||
|
||||
// Re-export constants for backward compatibility if needed,
|
||||
// but prefer using CONFIG directly in new code.
|
||||
|
||||
export const checkModelsExist = async (): Promise<boolean> => {
|
||||
try {
|
||||
const chatExists = await exists(
|
||||
`${CONFIG.MODELS.DIR}/${CONFIG.MODELS.CHAT.FILENAME}`,
|
||||
{ baseDir: BaseDirectory.AppLocalData }
|
||||
);
|
||||
const embedExists = await exists(
|
||||
`${CONFIG.MODELS.DIR}/${CONFIG.MODELS.EMBEDDING.FILENAME}`,
|
||||
{ baseDir: BaseDirectory.AppLocalData }
|
||||
);
|
||||
return chatExists && embedExists;
|
||||
} catch (e) {
|
||||
console.error("[ModelManager] Error checking models:", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadModels = async (onProgress: (pct: number, status: string) => void): Promise<void> => {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
const dirExists = await exists(CONFIG.MODELS.DIR, { baseDir: BaseDirectory.AppLocalData });
|
||||
if (!dirExists) {
|
||||
await mkdir(CONFIG.MODELS.DIR, { baseDir: BaseDirectory.AppLocalData, recursive: true });
|
||||
}
|
||||
|
||||
// Helper to download a single file
|
||||
const downloadFile = async (url: string, filename: string, weight: number, currentProgress: number) => {
|
||||
onProgress(currentProgress, `Downloading ${filename}...`);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
|
||||
if (!response.body) throw new Error("Response body is null");
|
||||
|
||||
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
|
||||
let downloaded = 0;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const file = await create(`${CONFIG.MODELS.DIR}/${filename}`, { baseDir: BaseDirectory.AppLocalData });
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
await file.write(value);
|
||||
downloaded += value.length;
|
||||
|
||||
if (contentLength > 0) {
|
||||
const filePct = (downloaded / contentLength);
|
||||
// Map file progress to overall weight
|
||||
onProgress(currentProgress + (filePct * weight), `Downloading ${filename}...`);
|
||||
}
|
||||
}
|
||||
|
||||
await file.close();
|
||||
};
|
||||
|
||||
// Download Chat Model (80% weight)
|
||||
await downloadFile(
|
||||
CONFIG.MODELS.CHAT.URL,
|
||||
CONFIG.MODELS.CHAT.FILENAME,
|
||||
80,
|
||||
0
|
||||
);
|
||||
|
||||
// Download Embedding Model (20% weight)
|
||||
await downloadFile(
|
||||
CONFIG.MODELS.EMBEDDING.URL,
|
||||
CONFIG.MODELS.EMBEDDING.FILENAME,
|
||||
20,
|
||||
80
|
||||
);
|
||||
|
||||
onProgress(100, "Download Complete!");
|
||||
|
||||
} catch (e) {
|
||||
console.error("[ModelManager] Download failed:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const getModelPath = async (): Promise<string> => {
|
||||
const appData = await appLocalDataDir();
|
||||
// Use join to ensure correct path separators
|
||||
return await join(appData, CONFIG.MODELS.DIR, CONFIG.MODELS.CHAT.FILENAME);
|
||||
};
|
||||
|
||||
export const getEmbeddingModelPath = async (): Promise<string> => {
|
||||
const appData = await appLocalDataDir();
|
||||
return await join(appData, CONFIG.MODELS.DIR, CONFIG.MODELS.EMBEDDING.FILENAME);
|
||||
};
|
||||
180
src/app/services/vectorStore.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { readDir, readTextFile, writeTextFile, exists, mkdir, BaseDirectory } from '@tauri-apps/plugin-fs';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { CONFIG } from '../config';
|
||||
|
||||
// Define the structure of our knowledge base
|
||||
export interface DocumentVector {
|
||||
path: string;
|
||||
content: string;
|
||||
embedding: number[];
|
||||
}
|
||||
|
||||
const KNOWLEDGE_FOLDER = 'knowledge';
|
||||
const INDEX_FILE = 'knowledge_base.json';
|
||||
|
||||
// Helper to interact with Llama Server for embeddings
|
||||
import { startServer } from './aiService';
|
||||
|
||||
async function fetchEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
// Ensure embedding server is running
|
||||
await startServer('embedding');
|
||||
|
||||
const response = await fetch(`http://${CONFIG.AI_SERVER.HOST}:${CONFIG.AI_SERVER.EMBEDDING_PORT}/v1/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
input: text,
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Embedding failed: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// OpenAI compatible response: data.data[0].embedding
|
||||
return data.data[0].embedding;
|
||||
} catch (e) {
|
||||
console.error("[VectorStore] Embedding error:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursive file scanner
|
||||
async function scanFiles(dirPath: string, fileList: string[] = []): Promise<string[]> {
|
||||
console.log(`[VectorStore] Scanning directory: ${dirPath}`);
|
||||
try {
|
||||
const entries = await readDir(dirPath, { baseDir: BaseDirectory.AppLocalData });
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = await join(dirPath, entry.name);
|
||||
console.log(`[VectorStore] Found entry: ${entry.name}, path: ${relativePath}, isDir: ${entry.isDirectory}`);
|
||||
|
||||
if (entry.isDirectory) {
|
||||
await scanFiles(relativePath, fileList);
|
||||
} else {
|
||||
if (/\.(txt|md|json|csv)$/i.test(entry.name)) {
|
||||
fileList.push(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[VectorStore] Failed to scan directory ${dirPath}:`, e);
|
||||
}
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
export const VectorStore = {
|
||||
// 1. Check if knowledge folder exists, create if not
|
||||
async ensureKnowledgeFolder(): Promise<void> {
|
||||
const folderExists = await exists(KNOWLEDGE_FOLDER, { baseDir: BaseDirectory.AppLocalData });
|
||||
if (!folderExists) {
|
||||
await mkdir(KNOWLEDGE_FOLDER, { baseDir: BaseDirectory.AppLocalData });
|
||||
// Create a dummy file to explain usage
|
||||
await writeTextFile(
|
||||
`${KNOWLEDGE_FOLDER}/Readme.txt`,
|
||||
"Place your text files (.txt, .md) here. The app will scan them to answer your questions.",
|
||||
{ baseDir: BaseDirectory.AppLocalData }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// 2. Build Index
|
||||
async buildIndex(onProgress: (msg: string) => void): Promise<number> {
|
||||
onProgress("Checking knowledge folder...");
|
||||
await this.ensureKnowledgeFolder();
|
||||
|
||||
onProgress("Scanning files...");
|
||||
// Scan recursively starting from KNOWLEDGE_FOLDER
|
||||
const allFiles = await scanFiles(KNOWLEDGE_FOLDER);
|
||||
|
||||
if (allFiles.length === 0) {
|
||||
onProgress("No files found in 'knowledge' folder.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const documents: DocumentVector[] = [];
|
||||
let processed = 0;
|
||||
|
||||
for (const relPath of allFiles) {
|
||||
onProgress(`Processing ${processed + 1}/${allFiles.length}: ${relPath}`);
|
||||
console.log(`[VectorStore] Reading file: ${relPath}`);
|
||||
|
||||
try {
|
||||
const content = await readTextFile(relPath, { baseDir: BaseDirectory.AppLocalData });
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
console.warn(`[VectorStore] Skipping empty file: ${relPath}`);
|
||||
processed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- CONTEXT AWARENESS LOGIC ---
|
||||
const contextualizedContent = `File: ${relPath}\nContent:\n${content}`;
|
||||
console.log(`[VectorStore] Generating embedding for ${relPath} (${content.length} chars)`);
|
||||
|
||||
// Generate Embedding
|
||||
const embedding = await fetchEmbedding(contextualizedContent);
|
||||
|
||||
documents.push({
|
||||
path: relPath, // Storing relative path within AppLocalData
|
||||
content: contextualizedContent,
|
||||
embedding
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error(`[VectorStore] Failed to process ${relPath}`, e);
|
||||
}
|
||||
processed++;
|
||||
}
|
||||
|
||||
onProgress("Saving index...");
|
||||
await writeTextFile(
|
||||
INDEX_FILE,
|
||||
JSON.stringify(documents, null, 2),
|
||||
{ baseDir: BaseDirectory.AppLocalData }
|
||||
);
|
||||
|
||||
onProgress(`Done! Buffered ${documents.length} documents.`);
|
||||
return documents.length;
|
||||
},
|
||||
|
||||
// 3. Load Index (for chatting)
|
||||
async loadIndex(): Promise<DocumentVector[]> {
|
||||
if (!await exists(INDEX_FILE, { baseDir: BaseDirectory.AppLocalData })) {
|
||||
return [];
|
||||
}
|
||||
const text = await readTextFile(INDEX_FILE, { baseDir: BaseDirectory.AppLocalData });
|
||||
return JSON.parse(text) as DocumentVector[];
|
||||
},
|
||||
|
||||
// 4. Search
|
||||
async search(query: string, limit: number = 3): Promise<{ document: DocumentVector, score: number }[]> {
|
||||
const index = await this.loadIndex();
|
||||
if (index.length === 0) return [];
|
||||
|
||||
const queryEmbedding = await fetchEmbedding(query);
|
||||
|
||||
// Calculate cosine similarity
|
||||
const results = index.map(doc => {
|
||||
const score = cosineSimilarity(queryEmbedding, doc.embedding);
|
||||
return { document: doc, score };
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
};
|
||||
|
||||
function cosineSimilarity(vecA: number[], vecB: number[]): number {
|
||||
const dotProduct = vecA.reduce((sum, a, i) => sum + a * vecB[i], 0);
|
||||
const magnitudeA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0));
|
||||
const magnitudeB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0));
|
||||
return dotProduct / (magnitudeA * magnitudeB);
|
||||
}
|
||||
88
src/app/types/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export interface Exercise {
|
||||
id: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
enrichment: string;
|
||||
tags: string; // JSON string
|
||||
videoUrl: string; // camelCase
|
||||
}
|
||||
|
||||
export interface TrainingPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
sections: string; // JSON string
|
||||
}
|
||||
|
||||
export interface Program {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string; // camelCase
|
||||
}
|
||||
|
||||
export interface Week {
|
||||
id: string;
|
||||
programId: string; // camelCase
|
||||
position: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface Workout {
|
||||
id: string;
|
||||
weekId: string; // camelCase
|
||||
programId: string; // camelCase
|
||||
day: string;
|
||||
type: 'exercise' | 'plan';
|
||||
refId?: string; // camelCase
|
||||
name: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
// Helper accessors not in DB:
|
||||
exerciseId?: string;
|
||||
planId?: string;
|
||||
}
|
||||
|
||||
export interface AnalysisSession {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
videoPath: string | null; // camelCase
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
sessionId: string; // camelCase
|
||||
startTime: number; // camelCase
|
||||
endTime: number; // camelCase
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface FlashcardSet {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string; // camelCase
|
||||
}
|
||||
|
||||
export interface Flashcard {
|
||||
id: string;
|
||||
setId: string; // camelCase
|
||||
front: string;
|
||||
back: string;
|
||||
createdAt: string; // camelCase
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string; // camelCase
|
||||
updatedAt: string; // camelCase
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
sessionId: string; // camelCase
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
createdAt: string; // camelCase
|
||||
}
|
||||
305
src/app/utils/postureDrawing.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import type { PosturePoints, Point, CalibrationData } from '../composables/usePostureAnalysis';
|
||||
|
||||
// Helper Type for drawing context
|
||||
export interface DrawingContext {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
scale: number;
|
||||
unit: number;
|
||||
viewType: string;
|
||||
}
|
||||
|
||||
export const pointLabels: Record<string, string> = {
|
||||
nose: 'Nos',
|
||||
eye_l: 'Oko L', eye_r: 'Oko P',
|
||||
ear_l: 'Ucho (wejście)', ear_r: 'Ucho (wejście)',
|
||||
shoulder_l: 'Bark', shoulder_r: 'Bark',
|
||||
elbow_l: 'Łokieć', elbow_r: 'Łokieć',
|
||||
wrist_l: 'Nadgarstek (wyr. rylcowaty)', wrist_r: 'Nadgarstek (wyr. rylcowaty)',
|
||||
hip_l: 'Krętarz', hip_r: 'Krętarz',
|
||||
knee_l: 'Środek kolana', knee_r: 'Środek kolana',
|
||||
ankle_l: 'Kostka', ankle_r: 'Kostka',
|
||||
heel_l: 'Pięta', heel_r: 'Pięta',
|
||||
toe_l: 'Paluch', toe_r: 'Paluch',
|
||||
|
||||
c7: 'C7', kyphosis: 'Szczyt kifozy piersiowej', lordosis: 'Szczyt lordozy lędźwiowej', s1: 'S1', neck: 'Szczyt lordozy szyjnej',
|
||||
head_center: 'Centrum głowy', tmj: 'Staw skroniowo-żuchwowy', chin: 'Podbródek', glute: 'Wyniosłość pośladków'
|
||||
};
|
||||
|
||||
export const drawGrid = (ctx: CanvasRenderingContext2D, w: number, h: number) => {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = Math.max(1, w * 0.001);
|
||||
|
||||
const step = w / 20;
|
||||
for (let x = 0; x < w; x += step) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||||
for (let y = 0; y < h; y += step) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||
};
|
||||
|
||||
export const drawSkeleton = (ctx: CanvasRenderingContext2D, pts: PosturePoints, unit: number) => {
|
||||
ctx.strokeStyle = '#10B981';
|
||||
ctx.lineWidth = unit * 0.15;
|
||||
|
||||
const line = (p1: Point | null, p2: Point | null) => {
|
||||
if (p1 && p2) { ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); }
|
||||
};
|
||||
|
||||
line(pts.shoulder_l, pts.shoulder_r);
|
||||
line(pts.shoulder_l, pts.elbow_l); line(pts.elbow_l, pts.wrist_l);
|
||||
line(pts.shoulder_r, pts.elbow_r); line(pts.elbow_r, pts.wrist_r);
|
||||
line(pts.shoulder_l, pts.hip_l); line(pts.shoulder_r, pts.hip_r);
|
||||
line(pts.hip_l, pts.hip_r);
|
||||
line(pts.hip_l, pts.knee_l); line(pts.knee_l, pts.ankle_l);
|
||||
line(pts.hip_r, pts.knee_r); line(pts.knee_r, pts.ankle_r);
|
||||
|
||||
// Foot
|
||||
line(pts.ankle_l, pts.heel_l); line(pts.heel_l, pts.toe_l); line(pts.ankle_l, pts.toe_l);
|
||||
line(pts.ankle_r, pts.heel_r); line(pts.heel_r, pts.toe_r); line(pts.ankle_r, pts.toe_r);
|
||||
|
||||
// Spine Chain (Visual) mainly for side
|
||||
line(pts.head_center, pts.neck);
|
||||
line(pts.neck, pts.kyphosis);
|
||||
line(pts.kyphosis, pts.lordosis);
|
||||
line(pts.lordosis, pts.s1); // or Glute?
|
||||
};
|
||||
|
||||
export const drawPoints = (ctx: CanvasRenderingContext2D, pts: PosturePoints, unit: number, scale: number) => {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
|
||||
for (const [key, pt] of Object.entries(pts)) {
|
||||
if (!pt) continue;
|
||||
if (key === 'c7') continue; // Hide C7 as requested
|
||||
|
||||
ctx.beginPath();
|
||||
const r = (unit * 0.4) / scale + (unit * 0.1);
|
||||
ctx.arc(pt.x, pt.y, Math.max(5, r), 0, 2 * Math.PI);
|
||||
|
||||
if (key.includes('_l')) ctx.fillStyle = '#3B82F6';
|
||||
else if (key.includes('_r')) ctx.fillStyle = '#EF4444';
|
||||
else if (['c7', 'kyphosis', 'lordosis', 's1', 'neck', 'head_center', 'tmj', 'chin', 'glute'].includes(key)) ctx.fillStyle = '#F59E0B';
|
||||
else ctx.fillStyle = '#10B981';
|
||||
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'white'; ctx.lineWidth = (unit * 0.1) / scale; ctx.stroke();
|
||||
|
||||
const label = pointLabels[key] || key;
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.shadowColor = 'black';
|
||||
ctx.shadowBlur = unit * 0.2;
|
||||
ctx.shadowOffsetX = unit * 0.05;
|
||||
ctx.shadowOffsetY = unit * 0.05;
|
||||
|
||||
// Font relative to Unit
|
||||
const fontSize = Math.max(12, (unit * 1.0) / scale);
|
||||
ctx.font = `bold ${fontSize}px Arial`;
|
||||
|
||||
ctx.strokeStyle = 'black';
|
||||
ctx.lineWidth = (unit * 0.15) / scale;
|
||||
const yOff = -(r + (unit * 0.2) / scale);
|
||||
ctx.strokeText(label, pt.x, pt.y + yOff);
|
||||
ctx.fillText(label, pt.x, pt.y + yOff);
|
||||
|
||||
ctx.shadowColor = 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
export const drawCalibration = (ctx: CanvasRenderingContext2D, calib: CalibrationData | undefined, unit: number, scale: number) => {
|
||||
if (calib && calib.p1 && calib.p2) {
|
||||
ctx.strokeStyle = '#FBBF24';
|
||||
ctx.lineWidth = unit * 0.2 / scale;
|
||||
ctx.beginPath(); ctx.moveTo(calib.p1.x, calib.p1.y); ctx.lineTo(calib.p2.x, calib.p2.y); ctx.stroke();
|
||||
|
||||
const r = unit * 0.4 / scale;
|
||||
ctx.fillStyle = '#FBBF24';
|
||||
ctx.beginPath(); ctx.arc(calib.p1.x, calib.p1.y, r, 0, 2 * Math.PI); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(calib.p2.x, calib.p2.y, r, 0, 2 * Math.PI); ctx.fill();
|
||||
|
||||
const midX = (calib.p1.x + calib.p2.x) / 2;
|
||||
const midY = (calib.p1.y + calib.p2.y) / 2;
|
||||
const fontSize = Math.max(12, unit * 1.0 / scale);
|
||||
ctx.font = `bold ${fontSize}px Arial`;
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.strokeStyle = 'black'; ctx.lineWidth = unit * 0.15 / scale;
|
||||
ctx.strokeText(`${calib.realLengthMm}mm`, midX + 10, midY);
|
||||
ctx.fillText(`${calib.realLengthMm}mm`, midX + 10, midY);
|
||||
}
|
||||
};
|
||||
|
||||
export const drawMedicalLines = (ctx: CanvasRenderingContext2D, pts: PosturePoints, unit: number, scale: number, viewType: string) => {
|
||||
// Prevent unused vars lint error since C7 is commented out
|
||||
if (!ctx || !pts || !unit || !scale || !viewType) return;
|
||||
// C7 Lines Removed as per user request
|
||||
/*
|
||||
if (pts.c7) {
|
||||
// ...
|
||||
}
|
||||
*/
|
||||
};
|
||||
|
||||
const drawAngleArc = (ctx: CanvasRenderingContext2D, center: Point, startAngle: number, endAngle: number, radius: number, color: string, valueStr: string, unit: number, scale: number, fill: boolean = false) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(center.x, center.y, radius, startAngle, endAngle);
|
||||
if (fill) {
|
||||
ctx.lineTo(center.x, center.y);
|
||||
ctx.lineTo(center.x, center.y);
|
||||
ctx.fillStyle = color; // Use color for fill
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = unit * 0.15 / scale;
|
||||
ctx.stroke();
|
||||
|
||||
// Correct Mid-Angle calculation handling wrap-around
|
||||
let span = endAngle - startAngle;
|
||||
while (span < 0) span += 2 * Math.PI;
|
||||
|
||||
const midAngle = startAngle + span / 2;
|
||||
const textR = radius * 1.4;
|
||||
const tx = center.x + Math.cos(midAngle) * textR;
|
||||
const ty = center.y + Math.sin(midAngle) * textR;
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.font = `bold ${Math.max(10, (unit * 0.8) / scale)}px Arial`;
|
||||
ctx.shadowColor = 'black'; ctx.shadowBlur = 3;
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(valueStr, tx, ty);
|
||||
ctx.shadowColor = 'transparent';
|
||||
};
|
||||
|
||||
export const drawAngleVisuals = (ctx: CanvasRenderingContext2D, pts: PosturePoints, unit: number, scale: number, viewType: string) => {
|
||||
// 1. CVA (Side) - Removed visual as per user request
|
||||
/*
|
||||
if (viewType.startsWith('side') && pts.c7) {
|
||||
const ear = viewType === 'side_left' ? pts.ear_l : pts.ear_r;
|
||||
if (ear) {
|
||||
const dx = ear.x - pts.c7.x;
|
||||
const dy = ear.y - pts.c7.y;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
// ...
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// 2. Knee Extension (Side)
|
||||
if (viewType.startsWith('side')) {
|
||||
const isLeft = viewType === 'side_left';
|
||||
const hip = isLeft ? pts.hip_l : pts.hip_r;
|
||||
const knee = isLeft ? pts.knee_l : pts.knee_r;
|
||||
const ankle = isLeft ? pts.ankle_l : pts.ankle_r;
|
||||
|
||||
if (hip && knee && ankle) {
|
||||
const angle1 = Math.atan2(hip.y - knee.y, hip.x - knee.x);
|
||||
const angle2 = Math.atan2(ankle.y - knee.y, ankle.x - knee.x);
|
||||
|
||||
// Calculate raw angle deg
|
||||
let angleRad = angle2 - angle1;
|
||||
if (angleRad < 0) angleRad += 2 * Math.PI;
|
||||
const deg = Math.abs(angleRad * 180 / Math.PI);
|
||||
|
||||
// Swap order to draw the "External" angle (Opposite sector)
|
||||
// Previously angle1 -> angle2. Now angle2 -> angle1.
|
||||
// const color = '#FBBF24'; // Yellow
|
||||
// Use angle2 as start, angle1 as end to invert the arc
|
||||
drawAngleArc(ctx, knee, angle2, angle1, unit * 1.5 / scale, 'rgba(251, 191, 36, 0.4)', `${deg.toFixed(0)}°`, unit, scale, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const drawSagittalAnalysis = (ctx: CanvasRenderingContext2D, pts: PosturePoints, unit: number, scale: number, viewType: string, calib?: CalibrationData, manualPlumbX?: number | null) => {
|
||||
if (!viewType.startsWith('side')) return;
|
||||
|
||||
const isLeft = viewType === 'side_left';
|
||||
|
||||
// --- 1. Red Dashed Line (Static Reference: Head Center) ---
|
||||
// User requested Reference Line to pass through Head Center.
|
||||
const refRedX = pts.head_center ? pts.head_center.x : (isLeft ? pts.ear_l?.x : pts.ear_r?.x);
|
||||
|
||||
if (refRedX) {
|
||||
ctx.strokeStyle = '#EF4444'; // Red
|
||||
ctx.lineWidth = unit * 0.15 / scale;
|
||||
ctx.setLineDash([unit * 0.2, unit * 0.2]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(refRedX, 0);
|
||||
ctx.lineTo(refRedX, ctx.canvas.height);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// --- 2. Blue Solid Line (Manual: Draggable) ---
|
||||
// Defaults to Head Center (Red Line) if not set manually
|
||||
const blueX = (manualPlumbX !== undefined && manualPlumbX !== null) ? manualPlumbX : refRedX;
|
||||
|
||||
if (blueX !== undefined && blueX !== null) {
|
||||
ctx.strokeStyle = '#3B82F6'; // Blue
|
||||
ctx.lineWidth = unit * 0.25 / scale;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(blueX, 0);
|
||||
ctx.lineTo(blueX, ctx.canvas.height);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw Handle
|
||||
ctx.fillStyle = '#3B82F6';
|
||||
ctx.beginPath(); ctx.moveTo(blueX - 10, 0); ctx.lineTo(blueX + 10, 0); ctx.lineTo(blueX, 20); ctx.fill();
|
||||
|
||||
// --- 3. Measurements (Blue Line to Kyphosis, Lordosis, Glute) ---
|
||||
// Measurement Targets: Neck, Kyphosis, Lordosis, Glute
|
||||
const targets = [
|
||||
{ pt: pts.neck, label: 'Szczyt lordozy szyjnej' },
|
||||
{ pt: pts.kyphosis, label: 'Szczyt kifozy piersiowej' },
|
||||
{ pt: pts.lordosis, label: 'Szczyt lordozy lędźwiowej' },
|
||||
{ pt: pts.glute, label: 'Wyniosłość pośladków' }
|
||||
];
|
||||
|
||||
const nose = pts.nose;
|
||||
const ear = isLeft ? pts.ear_l : pts.ear_r;
|
||||
const facingRight = (nose && ear) ? nose.x > ear.x : true;
|
||||
|
||||
ctx.font = `bold ${Math.max(10, (unit * 0.8) / scale)}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
|
||||
targets.forEach(({ pt, label: _label }) => {
|
||||
if (!pt) return;
|
||||
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
|
||||
ctx.lineWidth = unit * 0.1 / scale;
|
||||
ctx.setLineDash([unit * 0.1, unit * 0.1]);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pt.x, pt.y);
|
||||
ctx.lineTo(blueX, pt.y);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Label logic
|
||||
const rawDist = Math.abs(pt.x - blueX);
|
||||
const dx = pt.x - blueX;
|
||||
// Direction (+/-) relative to facing direction
|
||||
const isForward = facingRight ? (dx > 0) : (dx < 0);
|
||||
const sign = isForward ? '+' : '-';
|
||||
|
||||
let text = "";
|
||||
if (calib && calib.ratio) {
|
||||
const mm = (rawDist * calib.ratio).toFixed(0);
|
||||
text = `${sign}${mm} mm`;
|
||||
} else {
|
||||
text = `${sign}${rawDist.toFixed(0)} px`;
|
||||
}
|
||||
|
||||
// Draw Label bg and text
|
||||
const midX = (pt.x + blueX) / 2;
|
||||
const midY = pt.y - (unit * 0.1 / scale);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
||||
const width = ctx.measureText(text).width + 10;
|
||||
const height = (unit * 1.2 / scale);
|
||||
ctx.fillRect(midX - width / 2, midY - height + 2, width, height);
|
||||
|
||||
if (Math.abs(dx) > (calib && calib.ratio ? (25 / calib.ratio) : 35)) ctx.fillStyle = '#ff6b6b';
|
||||
else ctx.fillStyle = '#4ade80';
|
||||
|
||||
const fontSize = Math.max(10, (unit * 0.8) / scale);
|
||||
ctx.font = `bold ${fontSize}px Arial`;
|
||||
ctx.fillStyle = 'white'; // Text Color
|
||||
ctx.fillText(text, midX, midY + (height / 2));
|
||||
});
|
||||
}
|
||||
};
|
||||
82
src/main.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createApp } from "vue";
|
||||
import "@/app/assets/global.css";
|
||||
import App from "@/app/App.vue";
|
||||
import PrimeVue from "primevue/config";
|
||||
import Aura from '@primeuix/themes/aura'; // Import Aura once
|
||||
import { definePreset } from '@primeuix/themes'; // Import definePreset
|
||||
import router from "@/app/router";
|
||||
import 'primeicons/primeicons.css';
|
||||
import 'primeflex/primeflex.css';
|
||||
import ConfirmationService from 'primevue/confirmationservice';
|
||||
import Tooltip from 'primevue/tooltip'; // Import Tooltip
|
||||
import ConfirmPopup from 'primevue/confirmpopup'; // Import ConfirmPopup
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
|
||||
const Noir = definePreset(Aura, {
|
||||
semantic: {
|
||||
primary: {
|
||||
50: '{zinc.50}',
|
||||
100: '{zinc.100}',
|
||||
200: '{zinc.200}',
|
||||
300: '{zinc.300}',
|
||||
400: '{zinc.400}',
|
||||
500: '{zinc.500}',
|
||||
600: '{zinc.600}',
|
||||
700: '{zinc.700}',
|
||||
800: '{zinc.800}',
|
||||
900: '{zinc.900}',
|
||||
950: '{zinc.950}'
|
||||
},
|
||||
colorScheme: {
|
||||
light: {
|
||||
primary: {
|
||||
color: '{zinc.950}',
|
||||
inverseColor: '#ffffff',
|
||||
hoverColor: '{zinc.900}',
|
||||
activeColor: '{zinc.800}'
|
||||
},
|
||||
highlight: {
|
||||
background: '{zinc.950}',
|
||||
focusBackground: '{zinc.700}',
|
||||
color: '#ffffff',
|
||||
focusColor: '#ffffff'
|
||||
}
|
||||
},
|
||||
dark: {
|
||||
primary: {
|
||||
color: '{zinc.50}',
|
||||
inverseColor: '{zinc.950}',
|
||||
hoverColor: '{zinc.100}',
|
||||
activeColor: '{zinc.200}'
|
||||
},
|
||||
highlight: {
|
||||
background: 'rgba(250, 250, 250, .16)',
|
||||
focusBackground: 'rgba(250, 250, 250, .24)',
|
||||
color: 'rgba(255,255,255,.87)',
|
||||
focusColor: 'rgba(255,255,255,.87)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Noir,
|
||||
options: {
|
||||
darkModeSelector: 'system',
|
||||
cssLayer: false,
|
||||
csp: {
|
||||
nonce: "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.use(ConfirmationService);
|
||||
app.directive('tooltip', Tooltip);
|
||||
app.component('ConfirmPopup', ConfirmPopup);
|
||||
|
||||
app.mount("#app");
|
||||
7
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
42
tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
45
vite.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [vue()],
|
||||
base: "/",
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: "index.html",
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
}));
|
||||