Latest state on privte laptop
This commit is contained in:
+217
-241
@@ -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'"
|
||||
>
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
<span class="font-medium">Back</span>
|
||||
</button>
|
||||
|
||||
<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]"
|
||||
placeholder="Workout Name"
|
||||
/>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Editor Toggle -->
|
||||
<div class="bg-gray-800 p-0.5 rounded-lg flex text-xs">
|
||||
<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'
|
||||
"
|
||||
@click="editorTab = 'visual'"
|
||||
>
|
||||
<LayoutDashboard class="w-3 h-3" /> Visual
|
||||
</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'
|
||||
"
|
||||
@click="editorTab = 'json'"
|
||||
>
|
||||
<Code class="w-3 h-3" /> JSON
|
||||
<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" />
|
||||
</button>
|
||||
<div class="toolbar-divider"></div>
|
||||
<input
|
||||
v-model="workingWorkout.workoutName"
|
||||
class="header-title-input"
|
||||
placeholder="Workout Name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
: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>
|
||||
<div class="toolbar-right">
|
||||
<!-- View Mode Toggle (Segmented) -->
|
||||
<div class="segmented-control">
|
||||
<button
|
||||
:class="{ active: editorTab === 'visual' }"
|
||||
@click="editorTab = 'visual'"
|
||||
>
|
||||
<LayoutDashboard class="w-4 h-4" />
|
||||
<span>Visual</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: editorTab === 'json' }"
|
||||
@click="editorTab = 'json'"
|
||||
>
|
||||
<Code class="w-4 h-4" />
|
||||
<span>JSON</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"
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- AI Button -->
|
||||
<button
|
||||
class="toolbar-btn ai-enhance-btn"
|
||||
@click="showAiModal = true"
|
||||
>
|
||||
{{ aiLoading ? 'Thinking...' : 'Generate' }}
|
||||
<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" />
|
||||
<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">
|
||||
<WorkoutVisualEditor
|
||||
v-if="editorTab === 'visual'"
|
||||
<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"
|
||||
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
|
||||
:editorTab="editorTab"
|
||||
/>
|
||||
|
||||
<!-- SINGLE VIEW (For New Workouts) -->
|
||||
<div v-else class="h-full">
|
||||
<WorkoutJsonEditor v-model="workingWorkout" />
|
||||
<WorkoutVisualEditor
|
||||
v-if="editorTab === 'visual'"
|
||||
v-model="workingWorkout"
|
||||
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
|
||||
/>
|
||||
<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 {
|
||||
|
||||
Reference in New Issue
Block a user