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.
|
||||
|
||||
## 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
|
||||
|
||||
```mermaid
|
||||
|
|
@ -124,4 +132,4 @@ sequenceDiagram
|
|||
## Security & Reliability
|
||||
- **CORS**: Restricted to localhost:5173.
|
||||
- **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.
|
||||
|
||||
**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
|
||||
- **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
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from dotenv import load_dotenv, set_key
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ class EnvManager:
|
|||
# Reload after setting
|
||||
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."""
|
||||
# Reload just in case
|
||||
self.load_service_env(service)
|
||||
|
|
|
|||
|
|
@ -89,8 +89,19 @@ class RecommendationEngine:
|
|||
Validation Rules:
|
||||
- SportTypes: RUNNING=1, CYCLING=2
|
||||
- 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)
|
||||
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: () => ({}) }, // Only used at top level
|
||||
steps: { type: Array, default: () => [] }, // Used for recursion
|
||||
isNested: { type: Boolean, default: false }
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
steps: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isNested: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:steps'])
|
||||
|
|
@ -101,9 +114,11 @@ const getStepColor = (typeId) => {
|
|||
type="text"
|
||||
placeholder="Workout Name"
|
||||
class="bare-input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': readonly }"
|
||||
style="width: 100%; text-align: left; font-size: 1.1rem; font-weight: 500"
|
||||
:readonly="readonly"
|
||||
@input="
|
||||
emit('update:modelValue', {
|
||||
!readonly && emit('update:modelValue', {
|
||||
...modelValue,
|
||||
workoutName: $event.target.value
|
||||
})
|
||||
|
|
@ -114,8 +129,11 @@ const getStepColor = (typeId) => {
|
|||
<label>Type</label>
|
||||
<select
|
||||
:value="modelValue.sportType?.sportTypeId"
|
||||
:disabled="readonly"
|
||||
class="bare-select"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': readonly }"
|
||||
@change="
|
||||
emit('update:modelValue', {
|
||||
!readonly && emit('update:modelValue', {
|
||||
...modelValue,
|
||||
sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) }
|
||||
})
|
||||
|
|
@ -136,6 +154,7 @@ const getStepColor = (typeId) => {
|
|||
handle=".drag-handle"
|
||||
group="steps"
|
||||
class="steps-container"
|
||||
:disabled="readonly"
|
||||
@change="emitUpdate"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
|
|
@ -143,7 +162,7 @@ const getStepColor = (typeId) => {
|
|||
<!-- REPEAT BLOCK -->
|
||||
<div v-if="element.type === 'RepeatGroupDTO'" class="repeat-block">
|
||||
<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">
|
||||
<Repeat :size="16" />
|
||||
<span style="font-weight: 600">Repeat</span>
|
||||
|
|
@ -152,11 +171,12 @@ const getStepColor = (typeId) => {
|
|||
type="number"
|
||||
class="bare-input"
|
||||
min="1"
|
||||
:readonly="readonly"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
<span>times</span>
|
||||
</div>
|
||||
<button class="icon-btn delete" @click="removeStep(index)">
|
||||
<button v-if="!readonly" class="icon-btn delete" @click="removeStep(index)">
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -165,6 +185,7 @@ const getStepColor = (typeId) => {
|
|||
<WorkoutVisualEditor
|
||||
v-model:steps="element.workoutSteps"
|
||||
:is-nested="true"
|
||||
:readonly="readonly"
|
||||
@update:steps="onNestedUpdate($event, index)"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -182,26 +203,27 @@ const getStepColor = (typeId) => {
|
|||
|
||||
<div class="step-content">
|
||||
<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 -->
|
||||
<!-- Bind direct to stepTypeId if available, else nested -->
|
||||
<!-- We need a computed setter or just force flat now -->
|
||||
<select
|
||||
v-model="element.stepTypeId"
|
||||
class="bare-select type-select"
|
||||
@change="emitUpdate"
|
||||
:value="element.stepTypeId"
|
||||
class="type-select"
|
||||
:disabled="readonly"
|
||||
@change="element.stepTypeId = Number($event.target.value); emitUpdate()"
|
||||
>
|
||||
<option :value="0">Warmup</option>
|
||||
<option :value="1">Interval</option>
|
||||
<option :value="2">Recover</option>
|
||||
<option :value="5">Rest</option>
|
||||
<option :value="3">Rest</option>
|
||||
<option :value="4">Cooldown</option>
|
||||
<option :value="3">Other</option>
|
||||
<option :value="5">Other</option>
|
||||
</select>
|
||||
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -210,7 +232,11 @@ const getStepColor = (typeId) => {
|
|||
<!-- Duration/Target -->
|
||||
<div class="detail-group">
|
||||
<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="2">Time</option>
|
||||
<option :value="1">Lap Button</option>
|
||||
|
|
@ -223,6 +249,7 @@ const getStepColor = (typeId) => {
|
|||
v-model.number="element.endConditionValue"
|
||||
type="number"
|
||||
class="bare-input"
|
||||
:readonly="readonly"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -232,6 +259,7 @@ const getStepColor = (typeId) => {
|
|||
v-model.number="element.endConditionValue"
|
||||
type="number"
|
||||
class="bare-input"
|
||||
:readonly="readonly"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -243,7 +271,7 @@ const getStepColor = (typeId) => {
|
|||
</draggable>
|
||||
|
||||
<!-- 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('repeat')"><Repeat :size="16" /> Add Repeat</button>
|
||||
</div>
|
||||
|
|
@ -340,16 +368,18 @@ const getStepColor = (typeId) => {
|
|||
|
||||
/* Typography Inputs */
|
||||
.bare-select {
|
||||
background: transparent;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.05); /* Slight background for visibility */
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem; /* Larger */
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.bare-select:focus {
|
||||
outline: none;
|
||||
color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.type-select {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -18,9 +18,12 @@ import {
|
|||
} from 'lucide-vue-next'
|
||||
import WorkoutVisualEditor from '../components/WorkoutVisualEditor.vue'
|
||||
import WorkoutJsonEditor from '../components/WorkoutJsonEditor.vue'
|
||||
import WorkoutCompareView from '../components/WorkoutCompareView.vue'
|
||||
import AiChatModal from '../components/AiChatModal.vue'
|
||||
|
||||
// State
|
||||
const viewMode = ref('browser') // 'browser' | 'editor'
|
||||
const editorMode = ref('create') // 'create' | 'edit'
|
||||
const editorTab = ref('visual') // 'visual' | 'json'
|
||||
const sourceMode = ref('remote') // 'remote' | 'local'
|
||||
const workouts = ref([])
|
||||
|
|
@ -29,10 +32,12 @@ const syncing = ref(false)
|
|||
const syncResult = ref(null)
|
||||
|
||||
// Editor State
|
||||
const originalWorkout = ref(null) // Immutable reference for diffing
|
||||
const workingWorkout = ref(null)
|
||||
const aiPrompt = ref('')
|
||||
const aiLoading = ref(false)
|
||||
const aiError = ref('')
|
||||
const showAiModal = ref(false)
|
||||
|
||||
// --- BROWSER ACTIONS ---
|
||||
|
||||
|
|
@ -90,7 +95,9 @@ const createNewWorkout = () => {
|
|||
// If local, we need to know it's a new local file
|
||||
isLocal: sourceMode.value === 'local'
|
||||
}
|
||||
originalWorkout.value = null
|
||||
viewMode.value = 'editor'
|
||||
editorMode.value = 'create'
|
||||
editorTab.value = 'visual'
|
||||
syncResult.value = null
|
||||
}
|
||||
|
|
@ -106,7 +113,14 @@ const duplicateWorkout = (workout) => {
|
|||
workingWorkout.value = copy
|
||||
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'
|
||||
editorMode.value = 'edit'
|
||||
editorTab.value = 'visual'
|
||||
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'
|
||||
editorMode.value = 'edit'
|
||||
editorTab.value = 'visual'
|
||||
syncResult.value = null
|
||||
} catch (e) {
|
||||
|
|
@ -193,6 +211,8 @@ const saveOrSync = async () => {
|
|||
if (data.status === 'SUCCESS') {
|
||||
syncResult.value = { success: true, msg: 'Saved locally!' }
|
||||
workingWorkout.value.filename = data.filename
|
||||
// Update Original Reference on Save
|
||||
originalWorkout.value = JSON.parse(JSON.stringify(workingWorkout.value))
|
||||
} else {
|
||||
syncResult.value = { success: false, error: 'Save failed' }
|
||||
}
|
||||
|
|
@ -214,6 +234,8 @@ const saveOrSync = async () => {
|
|||
const data = await res.json()
|
||||
if (data.success) {
|
||||
syncResult.value = { success: true, msg: 'Uploaded to Garmin!' }
|
||||
// Update Original Reference on Save
|
||||
originalWorkout.value = JSON.parse(JSON.stringify(workingWorkout.value))
|
||||
} else {
|
||||
let errMsg = 'Upload failed: ' + (data.error || 'Unknown error')
|
||||
if (data.details) {
|
||||
|
|
@ -233,9 +255,7 @@ const saveOrSync = async () => {
|
|||
}
|
||||
|
||||
// --- AI ACTIONS ---
|
||||
const askAI = async () => {
|
||||
if (!aiPrompt.value.trim()) return
|
||||
|
||||
const askAI = async (prompt) => {
|
||||
aiLoading.value = true
|
||||
aiError.value = ''
|
||||
try {
|
||||
|
|
@ -243,14 +263,14 @@ const askAI = async () => {
|
|||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: aiPrompt.value,
|
||||
prompt: prompt,
|
||||
current_workout: workingWorkout.value
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.workout) {
|
||||
workingWorkout.value = data.workout
|
||||
aiPrompt.value = '' // Clear on success
|
||||
showAiModal.value = false // Close on success
|
||||
} else if (data.error) {
|
||||
aiError.value = data.error
|
||||
}
|
||||
|
|
@ -393,84 +413,60 @@ const getSportName = (workout) => {
|
|||
</div>
|
||||
|
||||
<!-- EDITOR MODE -->
|
||||
<div v-else class="flex flex-col h-full gap-4">
|
||||
<!-- HEADER ROW -->
|
||||
<div class="flex items-center gap-4 bg-gray-900/50 p-2 rounded-xl border border-gray-800">
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
|
||||
@click="viewMode = 'browser'"
|
||||
>
|
||||
<div v-else class="flex flex-col h-full gap-4 editor-view">
|
||||
<!-- HARMONIZED TOOLBAR -->
|
||||
<div class="editor-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="minimal-btn" @click="viewMode = 'browser'">
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
<span class="font-medium">Back</span>
|
||||
</button>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Editor Toggle -->
|
||||
<div class="bg-gray-800 p-0.5 rounded-lg flex text-xs">
|
||||
<div class="toolbar-right">
|
||||
<!-- View Mode Toggle (Segmented) -->
|
||||
<div class="segmented-control">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md transition-all flex items-center gap-2"
|
||||
:class="
|
||||
editorTab === 'visual'
|
||||
? 'bg-gray-700 text-white shadow'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
"
|
||||
:class="{ active: editorTab === 'visual' }"
|
||||
@click="editorTab = 'visual'"
|
||||
>
|
||||
<LayoutDashboard class="w-3 h-3" /> Visual
|
||||
<LayoutDashboard class="w-4 h-4" />
|
||||
<span>Visual</span>
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md transition-all flex items-center gap-2"
|
||||
:class="
|
||||
editorTab === 'json'
|
||||
? 'bg-gray-700 text-white shadow'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
"
|
||||
:class="{ active: editorTab === 'json' }"
|
||||
@click="editorTab = 'json'"
|
||||
>
|
||||
<Code class="w-3 h-3" /> JSON
|
||||
<Code class="w-4 h-4" />
|
||||
<span>JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- AI 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"
|
||||
@click="saveOrSync"
|
||||
>
|
||||
<Cloud v-if="!syncing" class="w-4 h-4" />
|
||||
<Loader2 v-else class="w-4 h-4 animate-spin" />
|
||||
{{ workingWorkout.isLocal ? 'Save Local' : 'Sync to Garmin' }}
|
||||
</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' }}
|
||||
<span>{{ workingWorkout.isLocal ? 'Save Local' : 'Sync to Garmin' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -517,17 +513,40 @@ const getSportName = (workout) => {
|
|||
</div>
|
||||
|
||||
<!-- 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
|
||||
v-if="editorTab === 'visual'"
|
||||
v-model="workingWorkout"
|
||||
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
|
||||
/>
|
||||
<div v-else class="h-full">
|
||||
<WorkoutJsonEditor v-model="workingWorkout" />
|
||||
</div>
|
||||
<WorkoutJsonEditor
|
||||
v-else
|
||||
v-model="workingWorkout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI MODAL -->
|
||||
<AiChatModal
|
||||
:is-open="showAiModal"
|
||||
:workout="workingWorkout"
|
||||
:loading="aiLoading"
|
||||
@close="showAiModal = false"
|
||||
@ask="askAI"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -553,177 +572,134 @@ const getSportName = (workout) => {
|
|||
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;
|
||||
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 {
|
||||
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 {
|
||||
.toolbar-left, .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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: 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;
|
||||
align-items: 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;
|
||||
}
|
||||
|
||||
.add-step-btn:hover {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
background: rgba(46, 160, 67, 0.05);
|
||||
.minimal-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue