Latest state on privte laptop
This commit is contained in:
parent
715da2a816
commit
408cfa87d1
|
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
FitMop is a full-stack fitness analytics and workout planning platform. It follows a decoupled Client-Server architecture with a FastAPI backend and a Vue.js frontend, orchestrated by a single startup script.
|
FitMop is a full-stack fitness analytics and workout planning platform. It follows a decoupled Client-Server architecture with a FastAPI backend and a Vue.js frontend, orchestrated by a single startup script.
|
||||||
|
|
||||||
|
## Core Technology Stack & Tooling
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Adherence to these tools is mandatory.**
|
||||||
|
> - **Backend Package Manager**: [`uv`](https://github.com/astral-sh/uv) (do NOT use pip directly).
|
||||||
|
> - **AI SDK**: [`google-genai`](https://pypi.org/project/google-genai/) (Standard Python SDK for Gemini).
|
||||||
|
> - **Frontend Tooling**: `Vite` + `npm`.
|
||||||
|
|
||||||
## System Overview
|
## System Overview
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|
@ -124,4 +132,4 @@ sequenceDiagram
|
||||||
## Security & Reliability
|
## Security & Reliability
|
||||||
- **CORS**: Restricted to localhost:5173.
|
- **CORS**: Restricted to localhost:5173.
|
||||||
- **Error Handling**: Global FastAPI handler ensures API never crashes silently.
|
- **Error Handling**: Global FastAPI handler ensures API never crashes silently.
|
||||||
- **Pre-flight**: `fitmop.sh` checks for mandatory environment variables before launch.
|
- **Pre-flight**: `Makefile` checks for mandatory environment variables before launch.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
This document provides a set of global instructions and principles for the Gemini CLI to follow during our interactions.
|
This document provides a set of global instructions and principles for the Gemini CLI to follow during our interactions.
|
||||||
|
|
||||||
**Reference:**
|
**Reference:**
|
||||||
- **[Project Architecture](file:///Users/moritz/src/fitness_antigravity/ARCHITECTURE.md)**: ALWAYS refer to this document for the technical layout and data flows of the system.
|
- **[Project Architecture](file:///Users/moritz/src/fitness_antigravity/ARCHITECTURE.md)**: CRITICAL: You MUST read this file at the start of every session to understand the enforced tooling (`uv`, `google-genai`) and architectural patterns.
|
||||||
|
|
||||||
## Environment Management
|
## Environment Management
|
||||||
- **Startup Rule:** ALWAYS start the application using `make run`. NEVER try to start individual services manually or use the old `fitmop.sh`.
|
- **Startup Rule:** ALWAYS start the application using `make run`. NEVER try to start individual services manually or use the old `fitmop.sh`.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from dotenv import load_dotenv, set_key
|
from dotenv import load_dotenv, set_key
|
||||||
|
|
||||||
|
|
@ -34,7 +34,7 @@ class EnvManager:
|
||||||
# Reload after setting
|
# Reload after setting
|
||||||
self.load_service_env(service, override=True)
|
self.load_service_env(service, override=True)
|
||||||
|
|
||||||
def get_status(self, service: str, required_keys: list[str]) -> Dict[str, Any]:
|
def get_status(self, service: str, required_keys: List[str]) -> Dict[str, Any]:
|
||||||
"""Check if required keys are set for a service."""
|
"""Check if required keys are set for a service."""
|
||||||
# Reload just in case
|
# Reload just in case
|
||||||
self.load_service_env(service)
|
self.load_service_env(service)
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,19 @@ class RecommendationEngine:
|
||||||
Validation Rules:
|
Validation Rules:
|
||||||
- SportTypes: RUNNING=1, CYCLING=2
|
- SportTypes: RUNNING=1, CYCLING=2
|
||||||
- StepTypes: WARMUP=1, COOLDOWN=2, INTERVAL=3, RECOVERY=4, REST=5, REPEAT=6
|
- StepTypes: WARMUP=1, COOLDOWN=2, INTERVAL=3, RECOVERY=4, REST=5, REPEAT=6
|
||||||
- EndCondition: DISTANCE=1, TIME=2, LAP_BUTTON=7
|
- EndCondition: DISTANCE=1, TIME=2, LAP_BUTTON=7, ITERATIONS=7
|
||||||
- TargetType: NO_TARGET=1, HEART_RATE=2, PACE=4 (Speed)
|
- TargetType: NO_TARGET=1, HEART_RATE=2, PACE=4 (Speed)
|
||||||
|
|
||||||
|
CRITICAL SCHEMA RULE:
|
||||||
|
Steps MUST use nested objects for types. Do NOT use flat IDs.
|
||||||
|
Example Step:
|
||||||
|
{
|
||||||
|
"type": "ExecutableStepDTO",
|
||||||
|
"stepOrder": 1,
|
||||||
|
"stepType": { "stepTypeId": 1, "stepTypeKey": "warmup" },
|
||||||
|
"endCondition": { "conditionTypeId": 2, "conditionTypeKey": "time" },
|
||||||
|
"endConditionValue": 600
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user_prompt = f"User Request: {prompt}"
|
user_prompt = f"User Request: {prompt}"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
# Ensure we can import from src
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from common.env_manager import EnvManager
|
||||||
|
from garmin.workout_manager import WorkoutManager
|
||||||
|
|
||||||
|
def load_sample_workout() -> Dict[str, Any]:
|
||||||
|
"""Create a minimal valid workout for testing."""
|
||||||
|
return {
|
||||||
|
"workoutName": "Test Workout",
|
||||||
|
"description": "Base for AI test",
|
||||||
|
"sportType": {
|
||||||
|
"sportTypeId": 1,
|
||||||
|
"sportTypeKey": "running"
|
||||||
|
},
|
||||||
|
"workoutSegments": [
|
||||||
|
{
|
||||||
|
"segmentOrder": 1,
|
||||||
|
"sportType": {
|
||||||
|
"sportTypeId": 1,
|
||||||
|
"sportTypeKey": "running"
|
||||||
|
},
|
||||||
|
"workoutSteps": [
|
||||||
|
{
|
||||||
|
"type": "ExecutableStepDTO",
|
||||||
|
"stepOrder": 1,
|
||||||
|
"stepTypeId": 1,
|
||||||
|
"childStepId": None,
|
||||||
|
"endConditionId": 2,
|
||||||
|
"endConditionValue": 300,
|
||||||
|
"targetTypeId": 1,
|
||||||
|
"targetValueOne": 10.0,
|
||||||
|
"targetValueTwo": 12.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_ai_modification():
|
||||||
|
print("\n--- Testing AI Workout Modifier ---")
|
||||||
|
|
||||||
|
# 1. Setup Environment
|
||||||
|
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
|
||||||
|
env = EnvManager(root_dir)
|
||||||
|
env.load_service_env("gemini")
|
||||||
|
|
||||||
|
api_key = os.getenv("GEMINI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
print("SKIP: GEMINI_API_KEY not found in environment.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Prepare Data
|
||||||
|
manager = WorkoutManager()
|
||||||
|
original_workout = load_sample_workout()
|
||||||
|
|
||||||
|
# 3. specific instruction
|
||||||
|
prompt = "Add a 10 minute cooldown at the end."
|
||||||
|
print(f"Prompt: {prompt}")
|
||||||
|
|
||||||
|
# 4. Execute AI Modification
|
||||||
|
try:
|
||||||
|
modified_workout = manager.generate_workout_json(prompt, existing_workout=original_workout)
|
||||||
|
|
||||||
|
# 5. Verify Structure (Schema Validation)
|
||||||
|
errors = manager.validate_workout_json(modified_workout)
|
||||||
|
if errors:
|
||||||
|
print(f"FAIL: Schema Validation Errors: {json.dumps(errors, indent=2)}")
|
||||||
|
print(f"Invalid Workout JSON: {json.dumps(modified_workout, indent=2)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 6. Verify Content Logic
|
||||||
|
segments = modified_workout.get("workoutSegments", [])
|
||||||
|
if not segments:
|
||||||
|
print("FAIL: No segments found in modified workout.")
|
||||||
|
return
|
||||||
|
|
||||||
|
steps = segments[0].get("workoutSteps", [])
|
||||||
|
last_step = steps[-1] if steps else None
|
||||||
|
|
||||||
|
# Check if last step looks like a cooldown
|
||||||
|
# stepTypeId 4 = Cooldown (usually, or we check description/type)
|
||||||
|
is_cooldown = last_step and (last_step.get("stepTypeId") == 4 or "cool" in str(last_step).lower())
|
||||||
|
|
||||||
|
if is_cooldown:
|
||||||
|
print("PASS: Cooldown added successfully.")
|
||||||
|
print(f"Modified Steps Count: {len(steps)}")
|
||||||
|
else:
|
||||||
|
print("WARN: Could not strictly verify cooldown type, but schema is valid.")
|
||||||
|
print(f"Last Step: {json.dumps(last_step, indent=2)}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL: Exception during AI processing: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_ai_modification()
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Sparkles, X, Send, Loader2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: Boolean,
|
||||||
|
workout: Object,
|
||||||
|
loading: Boolean
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'ask'])
|
||||||
|
|
||||||
|
const prompt = ref('')
|
||||||
|
|
||||||
|
const handleAsk = () => {
|
||||||
|
if (!prompt.value.trim() || props.loading) return
|
||||||
|
emit('ask', prompt.value)
|
||||||
|
prompt.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isOpen" class="modal-overlay" @click.self="emit('close')">
|
||||||
|
<div class="modal-content ai-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="p-2 bg-purple-600/20 rounded-lg text-purple-400">
|
||||||
|
<Sparkles :size="20" />
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-lg">AI Workout Enhancer</h3>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" @click="emit('close')"><X :size="20" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="ai-info-box">
|
||||||
|
<p class="text-sm text-gray-400 mb-4">
|
||||||
|
Tell the AI how you want to modify <strong>{{ workout?.workoutName }}</strong>.
|
||||||
|
For example: "Add 3 intervals of 1 minute fast", "Make it a recovery run", or "Add a 15 min warmup".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-input-container">
|
||||||
|
<textarea
|
||||||
|
v-model="prompt"
|
||||||
|
placeholder="Type your instruction here..."
|
||||||
|
class="ai-textarea"
|
||||||
|
:disabled="loading"
|
||||||
|
@keyup.enter.exact.prevent="handleAsk"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-3">
|
||||||
|
<button
|
||||||
|
class="primary-btn ai-submit-btn"
|
||||||
|
:disabled="!prompt.trim() || loading"
|
||||||
|
@click="handleAsk"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="loading" class="w-4 h-4 animate-spin" />
|
||||||
|
<Send v-else :size="16" />
|
||||||
|
{{ loading ? 'Processing...' : 'Modify Workout' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content.ai-modal {
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-bottom: 1px solid #30363d;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-info-box {
|
||||||
|
background: rgba(163, 113, 247, 0.05);
|
||||||
|
border: 1px solid rgba(163, 113, 247, 0.2);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
background: #010409;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #c9d1d9;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #a371f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-submit-btn {
|
||||||
|
background: linear-gradient(135deg, #a371f7 0%, #1f6feb 100%);
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-submit-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(163, 113, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-submit-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Eye, Edit2, Columns, Smartphone } from 'lucide-vue-next'
|
||||||
|
import WorkoutVisualEditor from './WorkoutVisualEditor.vue'
|
||||||
|
import WorkoutJsonEditor from './WorkoutJsonEditor.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
original: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
editorTab: {
|
||||||
|
type: String,
|
||||||
|
default: 'visual'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
// Tab state for mobile or constrained views
|
||||||
|
const activeTab = ref('modified') // 'original' | 'modified' | 'split'
|
||||||
|
|
||||||
|
// Helper to determine if we can show split view (simple width check or just controlled by user preference)
|
||||||
|
const canSplit = ref(true)
|
||||||
|
|
||||||
|
const setTab = (tab) => {
|
||||||
|
activeTab.value = tab
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTabBasedOnWidth = () => {
|
||||||
|
if (window.innerWidth >= 768) {
|
||||||
|
if (activeTab.value !== 'split') activeTab.value = 'split'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateTabBasedOnWidth()
|
||||||
|
window.addEventListener('resize', updateTabBasedOnWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateTabBasedOnWidth)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="compare-view h-full flex flex-col">
|
||||||
|
<!-- View Controls (only visible on small screens or if needed) -->
|
||||||
|
<div class="mobile-tabs md:hidden flex bg-gray-900 border-b border-gray-800 p-1">
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2"
|
||||||
|
:class="activeTab === 'original' ? 'bg-gray-800 text-white shadow' : 'text-gray-400 hover:text-white'"
|
||||||
|
@click="setTab('original')"
|
||||||
|
>
|
||||||
|
<Eye class="w-4 h-4" /> Original
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2"
|
||||||
|
:class="activeTab === 'modified' ? 'bg-blue-900/40 text-blue-100 shadow' : 'text-gray-400 hover:text-white'"
|
||||||
|
@click="setTab('modified')"
|
||||||
|
>
|
||||||
|
<Edit2 class="w-4 h-4" /> Modified
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MAIN CONTENT AREA -->
|
||||||
|
<div class="compare-container flex-1 overflow-hidden relative">
|
||||||
|
|
||||||
|
<!-- LEFT PANEL: ORIGINAL (READONLY) -->
|
||||||
|
<div
|
||||||
|
class="panel original-panel border-r border-gray-800 bg-gray-900/50 flex-1 min-w-0"
|
||||||
|
:class="{ 'hidden': activeTab === 'modified' }"
|
||||||
|
>
|
||||||
|
<div class="panel-header text-xs font-bold text-gray-500 uppercase tracking-wider p-3 border-b border-gray-800 flex justify-between">
|
||||||
|
<span>Original Version</span>
|
||||||
|
<span class="bg-gray-800 px-2 py-0.5 rounded text-gray-400">Read-only</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content overflow-y-auto h-full p-4 relative">
|
||||||
|
<!-- Blocking Overlay for Interaction -->
|
||||||
|
<div class="absolute inset-0 z-10 cursor-not-allowed"></div>
|
||||||
|
|
||||||
|
<WorkoutVisualEditor
|
||||||
|
v-if="editorTab === 'visual'"
|
||||||
|
:modelValue="original"
|
||||||
|
:steps="original.workoutSegments[0].workoutSteps"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<WorkoutJsonEditor
|
||||||
|
v-else
|
||||||
|
:modelValue="original"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT PANEL: MODIFIED (EDITABLE) -->
|
||||||
|
<div
|
||||||
|
class="panel modified-panel flex-1 min-w-0"
|
||||||
|
:class="{ 'hidden': activeTab === 'original' }"
|
||||||
|
>
|
||||||
|
<div class="panel-header text-xs font-bold text-blue-400 uppercase tracking-wider p-3 border-b border-gray-800 bg-blue-900/10 flex justify-between">
|
||||||
|
<span>Working Copy</span>
|
||||||
|
<span class="bg-blue-900/30 px-2 py-0.5 rounded text-blue-200">Editable</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content overflow-y-auto h-full p-4">
|
||||||
|
<WorkoutVisualEditor
|
||||||
|
v-if="editorTab === 'visual'"
|
||||||
|
:modelValue="modelValue"
|
||||||
|
:steps="modelValue.workoutSegments[0].workoutSteps"
|
||||||
|
@update:modelValue="val => emit('update:modelValue', val)"
|
||||||
|
/>
|
||||||
|
<WorkoutJsonEditor
|
||||||
|
v-else
|
||||||
|
:modelValue="modelValue"
|
||||||
|
@update:modelValue="val => emit('update:modelValue', val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.compare-container {
|
||||||
|
display: flex !important; /* Force flex to override any potential blocking */
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure Desktop defaults to split */
|
||||||
|
/* Ensure Desktop defaults to split */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.md\:hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -3,9 +3,22 @@ import draggable from 'vuedraggable'
|
||||||
import { GripVertical, Trash2, Plus, Repeat } from 'lucide-vue-next'
|
import { GripVertical, Trash2, Plus, Repeat } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Object, default: () => ({}) }, // Only used at top level
|
modelValue: {
|
||||||
steps: { type: Array, default: () => [] }, // Used for recursion
|
type: Object,
|
||||||
isNested: { type: Boolean, default: false }
|
default: () => ({})
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isNested: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'update:steps'])
|
const emit = defineEmits(['update:modelValue', 'update:steps'])
|
||||||
|
|
@ -101,9 +114,11 @@ const getStepColor = (typeId) => {
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Workout Name"
|
placeholder="Workout Name"
|
||||||
class="bare-input"
|
class="bare-input"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': readonly }"
|
||||||
style="width: 100%; text-align: left; font-size: 1.1rem; font-weight: 500"
|
style="width: 100%; text-align: left; font-size: 1.1rem; font-weight: 500"
|
||||||
|
:readonly="readonly"
|
||||||
@input="
|
@input="
|
||||||
emit('update:modelValue', {
|
!readonly && emit('update:modelValue', {
|
||||||
...modelValue,
|
...modelValue,
|
||||||
workoutName: $event.target.value
|
workoutName: $event.target.value
|
||||||
})
|
})
|
||||||
|
|
@ -114,8 +129,11 @@ const getStepColor = (typeId) => {
|
||||||
<label>Type</label>
|
<label>Type</label>
|
||||||
<select
|
<select
|
||||||
:value="modelValue.sportType?.sportTypeId"
|
:value="modelValue.sportType?.sportTypeId"
|
||||||
|
:disabled="readonly"
|
||||||
|
class="bare-select"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': readonly }"
|
||||||
@change="
|
@change="
|
||||||
emit('update:modelValue', {
|
!readonly && emit('update:modelValue', {
|
||||||
...modelValue,
|
...modelValue,
|
||||||
sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) }
|
sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) }
|
||||||
})
|
})
|
||||||
|
|
@ -136,6 +154,7 @@ const getStepColor = (typeId) => {
|
||||||
handle=".drag-handle"
|
handle=".drag-handle"
|
||||||
group="steps"
|
group="steps"
|
||||||
class="steps-container"
|
class="steps-container"
|
||||||
|
:disabled="readonly"
|
||||||
@change="emitUpdate"
|
@change="emitUpdate"
|
||||||
>
|
>
|
||||||
<template #item="{ element, index }">
|
<template #item="{ element, index }">
|
||||||
|
|
@ -143,7 +162,7 @@ const getStepColor = (typeId) => {
|
||||||
<!-- REPEAT BLOCK -->
|
<!-- REPEAT BLOCK -->
|
||||||
<div v-if="element.type === 'RepeatGroupDTO'" class="repeat-block">
|
<div v-if="element.type === 'RepeatGroupDTO'" class="repeat-block">
|
||||||
<div class="repeat-header">
|
<div class="repeat-header">
|
||||||
<div class="drag-handle"><GripVertical :size="16" /></div>
|
<div class="drag-handle" :class="{ 'cursor-default opacity-50': readonly }"><GripVertical :size="16" /></div>
|
||||||
<div style="flex: 1; display: flex; align-items: center; gap: 0.5rem">
|
<div style="flex: 1; display: flex; align-items: center; gap: 0.5rem">
|
||||||
<Repeat :size="16" />
|
<Repeat :size="16" />
|
||||||
<span style="font-weight: 600">Repeat</span>
|
<span style="font-weight: 600">Repeat</span>
|
||||||
|
|
@ -152,11 +171,12 @@ const getStepColor = (typeId) => {
|
||||||
type="number"
|
type="number"
|
||||||
class="bare-input"
|
class="bare-input"
|
||||||
min="1"
|
min="1"
|
||||||
|
:readonly="readonly"
|
||||||
@change="emitUpdate"
|
@change="emitUpdate"
|
||||||
/>
|
/>
|
||||||
<span>times</span>
|
<span>times</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="icon-btn delete" @click="removeStep(index)">
|
<button v-if="!readonly" class="icon-btn delete" @click="removeStep(index)">
|
||||||
<Trash2 :size="16" />
|
<Trash2 :size="16" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -165,6 +185,7 @@ const getStepColor = (typeId) => {
|
||||||
<WorkoutVisualEditor
|
<WorkoutVisualEditor
|
||||||
v-model:steps="element.workoutSteps"
|
v-model:steps="element.workoutSteps"
|
||||||
:is-nested="true"
|
:is-nested="true"
|
||||||
|
:readonly="readonly"
|
||||||
@update:steps="onNestedUpdate($event, index)"
|
@update:steps="onNestedUpdate($event, index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,26 +203,27 @@ const getStepColor = (typeId) => {
|
||||||
|
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<div class="step-row-top">
|
<div class="step-row-top">
|
||||||
<div class="drag-handle"><GripVertical :size="16" color="var(--text-muted)" /></div>
|
<div class="drag-handle" :class="{ 'cursor-default opacity-50': readonly }"><GripVertical :size="16" color="var(--text-muted)" /></div>
|
||||||
|
|
||||||
<!-- Step Type Select -->
|
<!-- Step Type Select -->
|
||||||
<!-- Bind direct to stepTypeId if available, else nested -->
|
<!-- Bind direct to stepTypeId if available, else nested -->
|
||||||
<!-- We need a computed setter or just force flat now -->
|
<!-- We need a computed setter or just force flat now -->
|
||||||
<select
|
<select
|
||||||
v-model="element.stepTypeId"
|
:value="element.stepTypeId"
|
||||||
class="bare-select type-select"
|
class="type-select"
|
||||||
@change="emitUpdate"
|
:disabled="readonly"
|
||||||
|
@change="element.stepTypeId = Number($event.target.value); emitUpdate()"
|
||||||
>
|
>
|
||||||
<option :value="0">Warmup</option>
|
<option :value="0">Warmup</option>
|
||||||
<option :value="1">Interval</option>
|
<option :value="1">Interval</option>
|
||||||
<option :value="2">Recover</option>
|
<option :value="2">Recover</option>
|
||||||
<option :value="5">Rest</option>
|
<option :value="3">Rest</option>
|
||||||
<option :value="4">Cooldown</option>
|
<option :value="4">Cooldown</option>
|
||||||
<option :value="3">Other</option>
|
<option :value="5">Other</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div style="flex: 1"></div>
|
<div style="flex: 1"></div>
|
||||||
<button class="icon-btn delete" @click="removeStep(index)">
|
<button v-if="!readonly" class="icon-btn delete" @click="removeStep(index)">
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -210,7 +232,11 @@ const getStepColor = (typeId) => {
|
||||||
<!-- Duration/Target -->
|
<!-- Duration/Target -->
|
||||||
<div class="detail-group">
|
<div class="detail-group">
|
||||||
<label>Duration Type</label>
|
<label>Duration Type</label>
|
||||||
<select v-model="element.endConditionId" class="bare-select" @change="emitUpdate">
|
<select
|
||||||
|
:value="element.endConditionId"
|
||||||
|
:disabled="readonly"
|
||||||
|
@change="element.endConditionId = Number($event.target.value); emitUpdate()"
|
||||||
|
>
|
||||||
<option :value="3">Distance</option>
|
<option :value="3">Distance</option>
|
||||||
<option :value="2">Time</option>
|
<option :value="2">Time</option>
|
||||||
<option :value="1">Lap Button</option>
|
<option :value="1">Lap Button</option>
|
||||||
|
|
@ -223,6 +249,7 @@ const getStepColor = (typeId) => {
|
||||||
v-model.number="element.endConditionValue"
|
v-model.number="element.endConditionValue"
|
||||||
type="number"
|
type="number"
|
||||||
class="bare-input"
|
class="bare-input"
|
||||||
|
:readonly="readonly"
|
||||||
@change="emitUpdate"
|
@change="emitUpdate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -232,6 +259,7 @@ const getStepColor = (typeId) => {
|
||||||
v-model.number="element.endConditionValue"
|
v-model.number="element.endConditionValue"
|
||||||
type="number"
|
type="number"
|
||||||
class="bare-input"
|
class="bare-input"
|
||||||
|
:readonly="readonly"
|
||||||
@change="emitUpdate"
|
@change="emitUpdate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -243,7 +271,7 @@ const getStepColor = (typeId) => {
|
||||||
</draggable>
|
</draggable>
|
||||||
|
|
||||||
<!-- Add Buttons -->
|
<!-- Add Buttons -->
|
||||||
<div class="add-controls">
|
<div v-if="!readonly" class="add-controls">
|
||||||
<button class="add-btn" @click="addStep('interval')"><Plus :size="16" /> Add Step</button>
|
<button class="add-btn" @click="addStep('interval')"><Plus :size="16" /> Add Step</button>
|
||||||
<button class="add-btn" @click="addStep('repeat')"><Repeat :size="16" /> Add Repeat</button>
|
<button class="add-btn" @click="addStep('repeat')"><Repeat :size="16" /> Add Repeat</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -340,16 +368,18 @@ const getStepColor = (typeId) => {
|
||||||
|
|
||||||
/* Typography Inputs */
|
/* Typography Inputs */
|
||||||
.bare-select {
|
.bare-select {
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.05); /* Slight background for visibility */
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 1.1rem; /* Larger */
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.bare-select:focus {
|
.bare-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
|
background: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-select {
|
.type-select {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,12 @@ import {
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import WorkoutVisualEditor from '../components/WorkoutVisualEditor.vue'
|
import WorkoutVisualEditor from '../components/WorkoutVisualEditor.vue'
|
||||||
import WorkoutJsonEditor from '../components/WorkoutJsonEditor.vue'
|
import WorkoutJsonEditor from '../components/WorkoutJsonEditor.vue'
|
||||||
|
import WorkoutCompareView from '../components/WorkoutCompareView.vue'
|
||||||
|
import AiChatModal from '../components/AiChatModal.vue'
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const viewMode = ref('browser') // 'browser' | 'editor'
|
const viewMode = ref('browser') // 'browser' | 'editor'
|
||||||
|
const editorMode = ref('create') // 'create' | 'edit'
|
||||||
const editorTab = ref('visual') // 'visual' | 'json'
|
const editorTab = ref('visual') // 'visual' | 'json'
|
||||||
const sourceMode = ref('remote') // 'remote' | 'local'
|
const sourceMode = ref('remote') // 'remote' | 'local'
|
||||||
const workouts = ref([])
|
const workouts = ref([])
|
||||||
|
|
@ -29,10 +32,12 @@ const syncing = ref(false)
|
||||||
const syncResult = ref(null)
|
const syncResult = ref(null)
|
||||||
|
|
||||||
// Editor State
|
// Editor State
|
||||||
|
const originalWorkout = ref(null) // Immutable reference for diffing
|
||||||
const workingWorkout = ref(null)
|
const workingWorkout = ref(null)
|
||||||
const aiPrompt = ref('')
|
const aiPrompt = ref('')
|
||||||
const aiLoading = ref(false)
|
const aiLoading = ref(false)
|
||||||
const aiError = ref('')
|
const aiError = ref('')
|
||||||
|
const showAiModal = ref(false)
|
||||||
|
|
||||||
// --- BROWSER ACTIONS ---
|
// --- BROWSER ACTIONS ---
|
||||||
|
|
||||||
|
|
@ -90,7 +95,9 @@ const createNewWorkout = () => {
|
||||||
// If local, we need to know it's a new local file
|
// If local, we need to know it's a new local file
|
||||||
isLocal: sourceMode.value === 'local'
|
isLocal: sourceMode.value === 'local'
|
||||||
}
|
}
|
||||||
|
originalWorkout.value = null
|
||||||
viewMode.value = 'editor'
|
viewMode.value = 'editor'
|
||||||
|
editorMode.value = 'create'
|
||||||
editorTab.value = 'visual'
|
editorTab.value = 'visual'
|
||||||
syncResult.value = null
|
syncResult.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +113,14 @@ const duplicateWorkout = (workout) => {
|
||||||
workingWorkout.value = copy
|
workingWorkout.value = copy
|
||||||
workingWorkout.value.isLocal = true // Default to local for safety, or prompt user? Let's say local.
|
workingWorkout.value.isLocal = true // Default to local for safety, or prompt user? Let's say local.
|
||||||
|
|
||||||
|
// For duplicate, we treat it as a NEW workout derived from old, so no diff?
|
||||||
|
// User requested "Diff for all edit/clone". So we should behave like 'edit'.
|
||||||
|
// But wait, if it's a clone, the "Original" is the source?
|
||||||
|
// Let's copy it to original too so user sees what they started with.
|
||||||
|
originalWorkout.value = JSON.parse(JSON.stringify(copy))
|
||||||
|
|
||||||
viewMode.value = 'editor'
|
viewMode.value = 'editor'
|
||||||
|
editorMode.value = 'edit'
|
||||||
editorTab.value = 'visual'
|
editorTab.value = 'visual'
|
||||||
syncResult.value = null
|
syncResult.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +174,11 @@ const editWorkout = async (workout) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set Original for Diff View
|
||||||
|
originalWorkout.value = JSON.parse(JSON.stringify(workingWorkout.value))
|
||||||
|
|
||||||
viewMode.value = 'editor'
|
viewMode.value = 'editor'
|
||||||
|
editorMode.value = 'edit'
|
||||||
editorTab.value = 'visual'
|
editorTab.value = 'visual'
|
||||||
syncResult.value = null
|
syncResult.value = null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -193,6 +211,8 @@ const saveOrSync = async () => {
|
||||||
if (data.status === 'SUCCESS') {
|
if (data.status === 'SUCCESS') {
|
||||||
syncResult.value = { success: true, msg: 'Saved locally!' }
|
syncResult.value = { success: true, msg: 'Saved locally!' }
|
||||||
workingWorkout.value.filename = data.filename
|
workingWorkout.value.filename = data.filename
|
||||||
|
// Update Original Reference on Save
|
||||||
|
originalWorkout.value = JSON.parse(JSON.stringify(workingWorkout.value))
|
||||||
} else {
|
} else {
|
||||||
syncResult.value = { success: false, error: 'Save failed' }
|
syncResult.value = { success: false, error: 'Save failed' }
|
||||||
}
|
}
|
||||||
|
|
@ -214,6 +234,8 @@ const saveOrSync = async () => {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
syncResult.value = { success: true, msg: 'Uploaded to Garmin!' }
|
syncResult.value = { success: true, msg: 'Uploaded to Garmin!' }
|
||||||
|
// Update Original Reference on Save
|
||||||
|
originalWorkout.value = JSON.parse(JSON.stringify(workingWorkout.value))
|
||||||
} else {
|
} else {
|
||||||
let errMsg = 'Upload failed: ' + (data.error || 'Unknown error')
|
let errMsg = 'Upload failed: ' + (data.error || 'Unknown error')
|
||||||
if (data.details) {
|
if (data.details) {
|
||||||
|
|
@ -233,9 +255,7 @@ const saveOrSync = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- AI ACTIONS ---
|
// --- AI ACTIONS ---
|
||||||
const askAI = async () => {
|
const askAI = async (prompt) => {
|
||||||
if (!aiPrompt.value.trim()) return
|
|
||||||
|
|
||||||
aiLoading.value = true
|
aiLoading.value = true
|
||||||
aiError.value = ''
|
aiError.value = ''
|
||||||
try {
|
try {
|
||||||
|
|
@ -243,14 +263,14 @@ const askAI = async () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt: aiPrompt.value,
|
prompt: prompt,
|
||||||
current_workout: workingWorkout.value
|
current_workout: workingWorkout.value
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.workout) {
|
if (data.workout) {
|
||||||
workingWorkout.value = data.workout
|
workingWorkout.value = data.workout
|
||||||
aiPrompt.value = '' // Clear on success
|
showAiModal.value = false // Close on success
|
||||||
} else if (data.error) {
|
} else if (data.error) {
|
||||||
aiError.value = data.error
|
aiError.value = data.error
|
||||||
}
|
}
|
||||||
|
|
@ -393,84 +413,60 @@ const getSportName = (workout) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- EDITOR MODE -->
|
<!-- EDITOR MODE -->
|
||||||
<div v-else class="flex flex-col h-full gap-4">
|
<div v-else class="flex flex-col h-full gap-4 editor-view">
|
||||||
<!-- HEADER ROW -->
|
<!-- HARMONIZED TOOLBAR -->
|
||||||
<div class="flex items-center gap-4 bg-gray-900/50 p-2 rounded-xl border border-gray-800">
|
<div class="editor-toolbar">
|
||||||
<button
|
<div class="toolbar-left">
|
||||||
class="flex items-center gap-2 px-3 py-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
|
<button class="minimal-btn" @click="viewMode = 'browser'">
|
||||||
@click="viewMode = 'browser'"
|
|
||||||
>
|
|
||||||
<ArrowLeft class="w-5 h-5" />
|
<ArrowLeft class="w-5 h-5" />
|
||||||
<span class="font-medium">Back</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<div class="toolbar-divider"></div>
|
||||||
<input
|
<input
|
||||||
v-model="workingWorkout.workoutName"
|
v-model="workingWorkout.workoutName"
|
||||||
class="bg-transparent text-xl font-bold focus:outline-none border-b border-transparent focus:border-blue-500 px-2 py-1 min-w-[200px]"
|
class="header-title-input"
|
||||||
placeholder="Workout Name"
|
placeholder="Workout Name"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="toolbar-right">
|
||||||
|
<!-- View Mode Toggle (Segmented) -->
|
||||||
<!-- Editor Toggle -->
|
<div class="segmented-control">
|
||||||
<div class="bg-gray-800 p-0.5 rounded-lg flex text-xs">
|
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1.5 rounded-md transition-all flex items-center gap-2"
|
:class="{ active: editorTab === 'visual' }"
|
||||||
:class="
|
|
||||||
editorTab === 'visual'
|
|
||||||
? 'bg-gray-700 text-white shadow'
|
|
||||||
: 'text-gray-400 hover:text-white'
|
|
||||||
"
|
|
||||||
@click="editorTab = 'visual'"
|
@click="editorTab = 'visual'"
|
||||||
>
|
>
|
||||||
<LayoutDashboard class="w-3 h-3" /> Visual
|
<LayoutDashboard class="w-4 h-4" />
|
||||||
|
<span>Visual</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1.5 rounded-md transition-all flex items-center gap-2"
|
:class="{ active: editorTab === 'json' }"
|
||||||
:class="
|
|
||||||
editorTab === 'json'
|
|
||||||
? 'bg-gray-700 text-white shadow'
|
|
||||||
: 'text-gray-400 hover:text-white'
|
|
||||||
"
|
|
||||||
@click="editorTab = 'json'"
|
@click="editorTab = 'json'"
|
||||||
>
|
>
|
||||||
<Code class="w-3 h-3" /> JSON
|
<Code class="w-4 h-4" />
|
||||||
|
<span>JSON</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-divider"></div>
|
||||||
|
|
||||||
|
<!-- AI Button -->
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium shadow-lg shadow-blue-900/20 transition-all"
|
class="toolbar-btn ai-enhance-btn"
|
||||||
|
@click="showAiModal = true"
|
||||||
|
>
|
||||||
|
<Sparkles class="w-4 h-4" />
|
||||||
|
<span>Enhance</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Sync/Save Button -->
|
||||||
|
<button
|
||||||
|
class="toolbar-btn primary-btn sync-btn"
|
||||||
:disabled="syncing"
|
:disabled="syncing"
|
||||||
@click="saveOrSync"
|
@click="saveOrSync"
|
||||||
>
|
>
|
||||||
<Cloud v-if="!syncing" class="w-4 h-4" />
|
<Cloud v-if="!syncing" class="w-4 h-4" />
|
||||||
<Loader2 v-else class="w-4 h-4 animate-spin" />
|
<Loader2 v-else class="w-4 h-4 animate-spin" />
|
||||||
{{ workingWorkout.isLocal ? 'Save Local' : 'Sync to Garmin' }}
|
<span>{{ workingWorkout.isLocal ? 'Save Local' : 'Sync to Garmin' }}</span>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI BAR & ERRORS -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'flex-1 flex items-center gap-3 bg-gray-900 border border-gray-700 px-4 py-2 rounded-xl focus-within:border-purple-500 focus-within:ring-1 focus-within:ring-purple-500/50 transition-all shadow-sm',
|
|
||||||
aiLoading ? 'opacity-75' : ''
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<Sparkles class="w-5 h-5 text-purple-400" />
|
|
||||||
<input
|
|
||||||
v-model="aiPrompt"
|
|
||||||
class="ai-prompt-input bg-transparent border-none focus:outline-none text-sm w-full placeholder-gray-500"
|
|
||||||
placeholder="Ask AI to modify... (e.g. 'Add a 10 min warmup' or 'Make intervals harder')"
|
|
||||||
:disabled="aiLoading"
|
|
||||||
@keyup.enter="askAI"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="ai-btn px-3 py-1 bg-purple-600/20 text-purple-300 hover:bg-purple-600 hover:text-white text-xs font-bold rounded uppercase tracking-wider transition-colors"
|
|
||||||
:disabled="!aiPrompt.trim() || aiLoading"
|
|
||||||
@click="askAI"
|
|
||||||
>
|
|
||||||
{{ aiLoading ? 'Thinking...' : 'Generate' }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -517,17 +513,40 @@ const getSportName = (workout) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- EDITOR CONTENT -->
|
<!-- EDITOR CONTENT -->
|
||||||
<div class="flex-1 bg-gray-900/30 border border-gray-800 rounded-xl overflow-hidden relative">
|
<div class="flex-1 bg-gray-900/30 border border-gray-800 rounded-xl overflow-hidden relative p-4 overflow-y-auto">
|
||||||
|
|
||||||
|
<!-- COMPARE VIEW (For Edits) -->
|
||||||
|
<WorkoutCompareView
|
||||||
|
v-if="editorMode === 'edit'"
|
||||||
|
:original="originalWorkout"
|
||||||
|
v-model="workingWorkout"
|
||||||
|
:editorTab="editorTab"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- SINGLE VIEW (For New Workouts) -->
|
||||||
|
<div v-else class="h-full">
|
||||||
<WorkoutVisualEditor
|
<WorkoutVisualEditor
|
||||||
v-if="editorTab === 'visual'"
|
v-if="editorTab === 'visual'"
|
||||||
v-model="workingWorkout"
|
v-model="workingWorkout"
|
||||||
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
|
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
|
||||||
/>
|
/>
|
||||||
<div v-else class="h-full">
|
<WorkoutJsonEditor
|
||||||
<WorkoutJsonEditor v-model="workingWorkout" />
|
v-else
|
||||||
</div>
|
v-model="workingWorkout"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI MODAL -->
|
||||||
|
<AiChatModal
|
||||||
|
:is-open="showAiModal"
|
||||||
|
:workout="workingWorkout"
|
||||||
|
:loading="aiLoading"
|
||||||
|
@close="showAiModal = false"
|
||||||
|
@ask="askAI"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -553,177 +572,134 @@ const getSportName = (workout) => {
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workout-card {
|
.editor-view {
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(13, 17, 23, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-input {
|
.toolbar-left, .toolbar-right {
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 2px solid var(--border-color);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: var(--text-color);
|
|
||||||
padding: 0.25rem;
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AI Bar */
|
|
||||||
.ai-bar {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-input-wrapper {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
background: rgba(163, 113, 247, 0.1); /* Subtle purple tint */
|
}
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
|
.toolbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimal-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-icon {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-input-wrapper input {
|
|
||||||
flex: 1;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-color);
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-input-wrapper input:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-btn {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-error {
|
|
||||||
color: #fa4549;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Steps Editor */
|
|
||||||
.workout-structure {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-row:hover {
|
|
||||||
border-color: var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-idx {
|
|
||||||
background: var(--border-color);
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-type-input {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-color);
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
width: 120px;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-details {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiny-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 0.25rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiny-btn:hover {
|
|
||||||
background: var(--border-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiny-btn.danger:hover {
|
|
||||||
background: #fa4549;
|
|
||||||
color: white;
|
|
||||||
border-color: #fa4549;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-step-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 2px dashed var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-step-btn:hover {
|
.minimal-btn:hover {
|
||||||
border-color: var(--accent-color);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: var(--accent-color);
|
color: var(--text-color);
|
||||||
background: rgba(46, 160, 67, 0.05);
|
}
|
||||||
|
|
||||||
|
.header-title-input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-input:focus {
|
||||||
|
outline: none;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmented Control */
|
||||||
|
.segmented-control {
|
||||||
|
display: flex;
|
||||||
|
background: #010409;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button.active {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button:hover:not(.active) {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar Buttons */
|
||||||
|
.toolbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-enhance-btn {
|
||||||
|
background: rgba(163, 113, 247, 0.1);
|
||||||
|
color: #a371f7;
|
||||||
|
border: 1px solid rgba(163, 113, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-enhance-btn:hover {
|
||||||
|
background: rgba(163, 113, 247, 0.2);
|
||||||
|
border-color: rgba(163, 113, 247, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-btn {
|
||||||
|
background: linear-gradient(135deg, #1f6feb 0%, #094cb5 100%);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(31, 111, 235, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-res {
|
.sync-res {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue