Initialize repo

This commit is contained in:
Kazimierz Ciołek
2026-02-02 13:42:04 +01:00
commit 61e3482715
97 changed files with 156542 additions and 0 deletions

69
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

35154
LLM_CONTEXTS/OpenWork.txt Normal file

File diff suppressed because it is too large Load Diff

28
LLM_CONTEXTS/Rola.txt Normal file
View 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 users 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 TypeScripts 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.

File diff suppressed because it is too large Load Diff

28074
LLM_CONTEXTS/llms-full-vue.txt Normal file

File diff suppressed because it is too large Load Diff

43673
LLM_CONTEXTS/llms-full.txt Normal file

File diff suppressed because one or more lines are too long

10378
LLM_CONTEXTS/sakaifrontend.txt Normal file

File diff suppressed because it is too large Load Diff

7801
LLM_CONTEXTS/web-llm.txt Normal file

File diff suppressed because it is too large Load Diff

7
README.md Normal file
View 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
View 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
View 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
View 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
View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

34
scripts/health.mjs Normal file
View 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
View File

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

7
src-tauri/2 Normal file
View 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

File diff suppressed because it is too large Load Diff

35
src-tauri/Cargo.toml Normal file
View 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
View File

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,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.

View File

@@ -0,0 +1 @@
[]

View 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),
}
}

View 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)
}

View 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(())
}

View File

@@ -0,0 +1,5 @@
pub mod analysis;
pub mod chat;
pub mod exercises;
pub mod plans;
pub mod programs;

View 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(())
}

View 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, &notes)
}
#[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
View 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)
}

View 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(())
}

View 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)
}

View File

@@ -0,0 +1,3 @@
pub mod analysis;
pub mod chat;
pub mod programs;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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);
};

View 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
};
}

View 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
};
}

View 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,
};
}

View 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
};
}

View 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
};
};

View 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
};
}

View 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
};
}

View 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
View 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;

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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
View 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;

View 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;
}
};

View 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);
};

View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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",
},
},
}));