964 lines
26 KiB
Vue
964 lines
26 KiB
Vue
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import {
|
|
Activity,
|
|
Dumbbell,
|
|
TrendingUp,
|
|
Cpu,
|
|
Loader2,
|
|
RefreshCw,
|
|
X,
|
|
CheckCircle2,
|
|
Monitor,
|
|
Terminal,
|
|
AlertTriangle,
|
|
Settings
|
|
} from 'lucide-vue-next'
|
|
import AnalyzeView from './views/AnalyzeView.vue'
|
|
import PlanView from './views/PlanView.vue'
|
|
|
|
const activities = ref([])
|
|
const recommendation = ref('Loading recommendations...')
|
|
const loading = ref(true)
|
|
const syncing = ref(false)
|
|
const authenticated = ref(false)
|
|
const mfaRequired = ref(false)
|
|
const authError = ref('')
|
|
|
|
// Navigation State
|
|
const currentView = ref('dashboard')
|
|
|
|
// Settings State
|
|
const settingsOpen = ref(false)
|
|
const activeTab = ref('garmin')
|
|
const currentTheme = ref(localStorage.getItem('theme') || 'modern')
|
|
const profile = ref({
|
|
fitness_goals: '',
|
|
dietary_preferences: '',
|
|
focus_days: []
|
|
})
|
|
const settingsStatus = ref({ garmin: {}, withings: {}, gemini: {} })
|
|
const settingsForms = ref({
|
|
garmin: { email: '', password: '', mfa_code: '' },
|
|
withings: { client_id: '', client_secret: '' },
|
|
gemini: { api_key: '' }
|
|
})
|
|
|
|
const dashboardStats = ref({
|
|
summary: { total_hours: 0, trend_pct: 0 },
|
|
breakdown: [],
|
|
strength_sessions: 0
|
|
})
|
|
|
|
const checkAuth = async () => {
|
|
try {
|
|
const res = await fetch('http://localhost:8000/auth/status')
|
|
const data = await res.json()
|
|
authenticated.value = data.authenticated
|
|
if (data.status === 'MFA_REQUIRED') {
|
|
mfaRequired.value = true
|
|
}
|
|
// Always fetch local data regardless of online bauth status
|
|
fetchData()
|
|
} catch (error) {
|
|
console.error('Auth check failed:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
checkAuth()
|
|
fetchSettings()
|
|
})
|
|
|
|
const fetchSettings = async () => {
|
|
try {
|
|
const res = await fetch('http://localhost:8000/settings/status')
|
|
if (res.ok) {
|
|
settingsStatus.value = await res.json()
|
|
// Pre-fill forms if configured
|
|
if (settingsStatus.value.garmin.configured) {
|
|
// Note: Password/Key are not sent back for security
|
|
settingsForms.value.garmin.email = settingsStatus.value.garmin.email || ''
|
|
}
|
|
if (settingsStatus.value.gemini.configured) {
|
|
settingsForms.value.gemini.api_key = '••••••••'
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch settings status:', error)
|
|
}
|
|
}
|
|
|
|
const saveServiceSettings = async (service) => {
|
|
loading.value = true
|
|
try {
|
|
const res = await fetch(`http://localhost:8000/settings/${service}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(settingsForms.value[service])
|
|
})
|
|
if (res.ok) {
|
|
await fetchSettings()
|
|
} else {
|
|
const err = await res.json()
|
|
authError.value = err.detail || 'Save failed'
|
|
}
|
|
} catch {
|
|
authError.value = 'Failed to communicate with backend'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const triggerSync = async () => {
|
|
if (syncing.value) return
|
|
syncing.value = true
|
|
try {
|
|
const res = await fetch('http://localhost:8000/sync', { method: 'POST' })
|
|
if (res.ok) {
|
|
await fetchData()
|
|
}
|
|
} catch (error) {
|
|
console.error('Sync failed:', error)
|
|
} finally {
|
|
syncing.value = false
|
|
}
|
|
}
|
|
|
|
const loginGarmin = async () => {
|
|
loading.value = true
|
|
authError.value = ''
|
|
try {
|
|
const res = await fetch('http://localhost:8000/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(settingsForms.value.garmin)
|
|
})
|
|
const data = await res.json()
|
|
if (data.status === 'MFA_REQUIRED') {
|
|
mfaRequired.value = true
|
|
} else if (data.status === 'SUCCESS') {
|
|
authenticated.value = true
|
|
mfaRequired.value = false
|
|
fetchData()
|
|
} else {
|
|
authError.value = data.message || 'Login failed'
|
|
}
|
|
} catch {
|
|
authError.value = 'Connection error'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
const actRes = await fetch('http://localhost:8000/activities')
|
|
activities.value = await actRes.json()
|
|
|
|
// Fetch dashboard stats
|
|
const dashRes = await fetch('http://localhost:8000/analyze/dashboard')
|
|
if (dashRes.ok) {
|
|
dashboardStats.value = await dashRes.json()
|
|
}
|
|
|
|
const recRes = await fetch('http://localhost:8000/recommendation')
|
|
const recData = await recRes.json()
|
|
recommendation.value = recData.recommendation
|
|
} catch (error) {
|
|
console.error('Data fetch failed:', error)
|
|
}
|
|
}
|
|
|
|
const setTheme = (theme) => {
|
|
currentTheme.value = theme
|
|
document.documentElement.setAttribute('data-theme', theme)
|
|
localStorage.setItem('theme', theme)
|
|
}
|
|
|
|
const openGeminiSettings = () => {
|
|
settingsOpen.value = true
|
|
activeTab.value = 'gemini'
|
|
}
|
|
|
|
const saveProfile = async () => {
|
|
loading.value = true
|
|
try {
|
|
const res = await fetch('http://localhost:8000/settings/profile', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(profile.value)
|
|
})
|
|
if (!res.ok) {
|
|
authError.value = 'Failed to save profile'
|
|
}
|
|
} catch {
|
|
authError.value = 'Failed to connect'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<header>
|
|
<h1>FitMop</h1>
|
|
<p>Your Ultimate Strength & Endurance Companion</p>
|
|
<button class="settings-btn icon-btn" @click="settingsOpen = true">
|
|
<AlertTriangle
|
|
v-if="!settingsStatus.gemini.configured"
|
|
:size="24"
|
|
style="color: var(--error-color); margin-right: 0.5rem"
|
|
/>
|
|
<Settings :size="24" />
|
|
</button>
|
|
|
|
<div class="main-nav">
|
|
<button :class="{ active: currentView === 'dashboard' }" @click="currentView = 'dashboard'">
|
|
<Activity :size="18" /> Dashboard
|
|
</button>
|
|
<button :class="{ active: currentView === 'analyze' }" @click="currentView = 'analyze'">
|
|
<TrendingUp :size="18" /> Analysis
|
|
</button>
|
|
<button :class="{ active: currentView === 'plan' }" @click="currentView = 'plan'">
|
|
<Dumbbell :size="18" /> Workout Plans
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="content-area">
|
|
<!-- DASHBOARD VIEW -->
|
|
<div v-if="currentView === 'dashboard'" class="dashboard">
|
|
<!-- Sync Status Overlay (Visible when syncing or if forced) -->
|
|
<div v-if="syncing" class="sync-overlay">
|
|
<div
|
|
class="card"
|
|
style="display: flex; align-items: center; gap: 1rem; border-color: var(--accent-color)"
|
|
>
|
|
<Loader2 class="spinner" :size="32" />
|
|
<div>
|
|
<h3 style="margin: 0">Syncing Garmin Data...</h3>
|
|
<p style="margin: 0; font-size: 0.9rem">Gathering your latest workouts locally.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Stats -->
|
|
<div class="card">
|
|
<div style="display: flex; justify-content: space-between; align-items: start">
|
|
<h3><Activity :size="20" /> Last 7 Days</h3>
|
|
<button v-if="authenticated" class="icon-btn" :disabled="syncing" @click="triggerSync">
|
|
<RefreshCw :size="16" :class="{ spinner: syncing }" />
|
|
</button>
|
|
</div>
|
|
<div class="stat-value">{{ dashboardStats.summary.total_hours }}h</div>
|
|
<p
|
|
:style="{
|
|
color:
|
|
dashboardStats.summary.trend_pct >= 0 ? 'var(--success-color)' : 'var(--error-color)'
|
|
}"
|
|
>
|
|
{{ dashboardStats.summary.trend_pct >= 0 ? '+' : ''
|
|
}}{{ dashboardStats.summary.trend_pct }}% from previous
|
|
</p>
|
|
|
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem">
|
|
<span v-for="item in dashboardStats.breakdown" :key="item.label" class="badge">
|
|
{{ item.count }}x {{ item.label }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3><Dumbbell :size="20" /> Strength Sessions</h3>
|
|
<div class="stat-value">{{ dashboardStats.strength_sessions }}</div>
|
|
<p>Target: 4 sessions</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3><TrendingUp :size="20" /> VO2 Max</h3>
|
|
<div class="stat-value">{{ dashboardStats.vo2_max || '—' }}</div>
|
|
<p>Status: {{ dashboardStats.vo2_max ? 'Excellent' : 'Not enough data' }}</p>
|
|
</div>
|
|
|
|
<!-- AI Recommendation -->
|
|
<div class="card" style="grid-column: 1 / -1; border-color: var(--accent-color)">
|
|
<div
|
|
style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.5rem;
|
|
"
|
|
>
|
|
<h3 style="margin: 0"><Cpu :size="20" /> Gemini Recommendation</h3>
|
|
<CheckCircle2
|
|
v-if="settingsStatus.gemini.configured"
|
|
color="var(--success-color)"
|
|
:size="18"
|
|
/>
|
|
<AlertTriangle
|
|
v-else
|
|
color="var(--error-color)"
|
|
:size="18"
|
|
title="Gemini API Key missing"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="!settingsStatus.gemini.configured"
|
|
class="doc-box"
|
|
style="margin-top: 1rem; border-color: var(--error-color)"
|
|
>
|
|
<strong>AI Recommendations Disabled</strong><br />
|
|
Please set your Gemini API Key in
|
|
<a href="#" @click.prevent="openGeminiSettings">Settings</a>
|
|
to get personalized coaching.
|
|
</div>
|
|
<p v-else-if="loading">Thinking...</p>
|
|
<p v-else style="font-size: 1.1rem; font-style: italic">"{{ recommendation }}"</p>
|
|
</div>
|
|
|
|
<!-- Recent Activities -->
|
|
<div class="card" style="grid-column: 1 / -1">
|
|
<div
|
|
style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
"
|
|
>
|
|
<h3>Recent Workouts</h3>
|
|
<span v-if="!authenticated" style="font-size: 0.8rem; color: var(--text-muted)">
|
|
Offline Mode - <a href="#" @click.prevent="settingsOpen = true">Connect Garmin</a>
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
v-if="loading && !dashboardStats.recent_activities"
|
|
style="text-align: center; padding: 2rem"
|
|
>
|
|
Loading history...
|
|
</div>
|
|
<div
|
|
v-else-if="
|
|
!dashboardStats.recent_activities || dashboardStats.recent_activities.length === 0
|
|
"
|
|
style="text-align: center; padding: 2rem"
|
|
>
|
|
No local data found. Hit refresh or connect account to sync.
|
|
</div>
|
|
<div
|
|
v-for="activity in dashboardStats.recent_activities"
|
|
:key="activity.activityId"
|
|
class="activity-item"
|
|
>
|
|
<div style="display: flex; align-items: center; gap: 1rem">
|
|
<!-- Icon based on type -->
|
|
<div class="activity-icon">
|
|
<Activity v-if="activity.activityType.typeKey.includes('running')" :size="20" />
|
|
<Dumbbell v-else-if="activity.activityType.typeKey.includes('strength')" :size="20" />
|
|
<TrendingUp v-else :size="20" />
|
|
</div>
|
|
<div>
|
|
<strong>{{ activity.activityName || 'Workout' }}</strong>
|
|
<div style="font-size: 0.8rem; color: var(--text-muted)">
|
|
{{ activity.activityType?.typeKey || 'Training' }} •
|
|
{{ new Date(activity.startTimeLocal).toLocaleDateString() }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="font-weight: 600">
|
|
{{ Math.round(activity.duration / 60) }}m
|
|
<span
|
|
style="
|
|
font-size: 0.8rem;
|
|
font-weight: normal;
|
|
color: var(--text-muted);
|
|
margin-left: 0.5rem;
|
|
"
|
|
>
|
|
{{ (activity.distance / 1000).toFixed(2) }}km
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ANALYZE VIEW -->
|
|
<AnalyzeView v-if="currentView === 'analyze'" />
|
|
|
|
<!-- PLAN VIEW -->
|
|
<PlanView v-if="currentView === 'plan'" />
|
|
</main>
|
|
|
|
<!-- Settings Modal -->
|
|
<div v-if="settingsOpen" class="modal-overlay" @click.self="settingsOpen = false">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3>Settings</h3>
|
|
<button class="icon-btn" @click="settingsOpen = false"><X :size="20" /></button>
|
|
</div>
|
|
|
|
<div class="modal-body">
|
|
<div class="modal-sidebar">
|
|
<div
|
|
class="sidebar-item"
|
|
:class="{ active: activeTab === 'garmin' }"
|
|
@click="activeTab = 'garmin'"
|
|
>
|
|
Garmin
|
|
</div>
|
|
<div
|
|
class="sidebar-item"
|
|
:class="{ active: activeTab === 'withings' }"
|
|
@click="activeTab = 'withings'"
|
|
>
|
|
Withings
|
|
</div>
|
|
<div
|
|
class="sidebar-item"
|
|
:class="{ active: activeTab === 'gemini' }"
|
|
@click="activeTab = 'gemini'"
|
|
>
|
|
Gemini AI
|
|
</div>
|
|
<div
|
|
class="sidebar-item"
|
|
:class="{ active: activeTab === 'profile' }"
|
|
@click="activeTab = 'profile'"
|
|
>
|
|
Your Profile
|
|
</div>
|
|
<div
|
|
class="sidebar-item"
|
|
:class="{ active: activeTab === 'appearance' }"
|
|
@click="activeTab = 'appearance'"
|
|
>
|
|
Appearance
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-main">
|
|
<!-- Garmin Tab -->
|
|
<!-- STATE 1 & 2: Not Configured or Configured but not Authenticated -->
|
|
<div v-if="!authenticated && !mfaRequired">
|
|
<div v-if="settingsStatus.garmin.configured" class="doc-box success-border">
|
|
<strong>Credentials Saved</strong><br />
|
|
Ready to connect.
|
|
</div>
|
|
<div v-else class="doc-box">
|
|
<strong>Not Connected</strong><br />
|
|
Enter your Garmin credentials to start.
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<input
|
|
v-model="settingsForms.garmin.email"
|
|
type="email"
|
|
placeholder="Garmin Email"
|
|
:disabled="loading"
|
|
/>
|
|
<input
|
|
v-model="settingsForms.garmin.password"
|
|
type="password"
|
|
placeholder="Garmin Password"
|
|
:disabled="loading"
|
|
/>
|
|
|
|
<button :disabled="loading" @click="loginGarmin">
|
|
<span v-if="loading" class="spinner"><RefreshCw :size="16" /></span>
|
|
{{ settingsStatus.garmin.configured ? 'Request MFA Code' : 'Connect Garmin' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STATE 3: MFA Required -->
|
|
<div v-else-if="mfaRequired">
|
|
<div class="doc-box" style="border-color: var(--accent-color)">
|
|
<strong>MFA Required</strong><br />
|
|
Please check your email for a verification code from Garmin.
|
|
</div>
|
|
<div class="form-group">
|
|
<input
|
|
v-model="settingsForms.garmin.mfa_code"
|
|
type="text"
|
|
placeholder="Enter 6-digit MFA Code"
|
|
:disabled="loading"
|
|
/>
|
|
<button :disabled="loading || !settingsForms.garmin.mfa_code" @click="loginGarmin">
|
|
<span v-if="loading" class="spinner"><RefreshCw :size="16" /></span>
|
|
Verify Code
|
|
</button>
|
|
|
|
<div style="display: flex; gap: 1rem; margin-top: 0.5rem">
|
|
<button class="secondary" style="flex: 1" :disabled="loading" @click="loginGarmin">
|
|
Resend Code
|
|
</button>
|
|
<button class="secondary" style="flex: 1" @click="mfaRequired = false">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STATE 4 & 5: Authenticated & Syncing -->
|
|
<div v-else-if="authenticated">
|
|
<div class="doc-box success-border">
|
|
<CheckCircle2
|
|
:size="20"
|
|
color="var(--success-color)"
|
|
style="float: left; margin-right: 0.5rem"
|
|
/>
|
|
<strong>Connected</strong><br />
|
|
Logged in as {{ settingsStatus.garmin.email || settingsForms.garmin.email }}
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<button :disabled="syncing" @click="triggerSync">
|
|
<RefreshCw :size="16" :class="{ spinner: syncing }" />
|
|
{{ syncing ? 'Syncing...' : 'Sync Now' }}
|
|
</button>
|
|
|
|
<div class="text-center" style="margin-top: 1rem">
|
|
<p style="font-size: 0.8rem; color: var(--text-muted)">
|
|
Garmin session is valid. Data is synced locally.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="authError" class="error">{{ authError }}</p>
|
|
|
|
<!-- Withings Tab -->
|
|
<div v-if="activeTab === 'withings'">
|
|
<div class="doc-box">
|
|
<strong>Withings Health</strong><br />
|
|
1. Create a Withings Developer app at
|
|
<a href="https://developer.withings.com" target="_blank">developer.withings.com</a
|
|
>.<br />
|
|
2. Copy your Client ID and Client Secret.<br />
|
|
3. Data is stored in <code>.env_withings</code>.
|
|
</div>
|
|
<div class="form-group">
|
|
<input
|
|
v-model="settingsForms.withings.client_id"
|
|
type="text"
|
|
placeholder="Client ID"
|
|
/>
|
|
<input
|
|
v-model="settingsForms.withings.client_secret"
|
|
type="password"
|
|
placeholder="Client Secret"
|
|
/>
|
|
<button :disabled="loading" @click="saveServiceSettings('withings')">
|
|
Save Withings Config
|
|
</button>
|
|
<p v-if="settingsStatus.withings.configured" class="success">✓ Withings Configured</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Gemini Tab -->
|
|
<div v-if="activeTab === 'gemini'">
|
|
<div class="doc-box">
|
|
<strong>Gemini AI Coaching</strong><br />
|
|
1. Get an API key from
|
|
<a href="https://aistudio.google.com" target="_blank">Google AI Studio</a>.<br />
|
|
2. This enables personalized training recommendations.<br />
|
|
3. Stored in <code>.env_gemini</code>.
|
|
</div>
|
|
<div class="form-group">
|
|
<input
|
|
v-model="settingsForms.gemini.api_key"
|
|
type="password"
|
|
placeholder="Gemini API Key"
|
|
/>
|
|
<button :disabled="loading" @click="saveServiceSettings('gemini')">
|
|
Save API Key
|
|
</button>
|
|
<p v-if="settingsStatus.gemini.configured" class="success">✓ Gemini AI Configured</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Profile Tab -->
|
|
<div v-if="activeTab === 'profile'">
|
|
<div class="doc-box">
|
|
<strong>Your Athlete Profile</strong><br />
|
|
The AI uses this information to personalize your analysis and workout plans.
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Main Fitness Goal</label>
|
|
<textarea
|
|
v-model="profile.fitness_goals"
|
|
rows="3"
|
|
placeholder="e.g. Run a sub-4 hour marathon in October"
|
|
></textarea>
|
|
|
|
<label>Dietary/Training Preferences</label>
|
|
<textarea
|
|
v-model="profile.dietary_preferences"
|
|
rows="3"
|
|
placeholder="e.g. Vegan, prefer morning workouts, hate swimming"
|
|
></textarea>
|
|
|
|
<button :disabled="loading" @click="saveProfile">Save Profile</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Appearance Tab -->
|
|
<div v-if="activeTab === 'appearance'">
|
|
<div class="doc-box">
|
|
<strong>Theme Settings</strong><br />
|
|
Choose the aesthetic that fits your mood.
|
|
</div>
|
|
<div class="theme-preview">
|
|
<div
|
|
class="theme-card"
|
|
:class="{ active: currentTheme === 'modern' }"
|
|
@click="setTheme('modern')"
|
|
>
|
|
<Monitor :size="32" />
|
|
<p>Modern Blue</p>
|
|
</div>
|
|
<div
|
|
class="theme-card"
|
|
:class="{ active: currentTheme === 'hacker' }"
|
|
@click="setTheme('hacker')"
|
|
>
|
|
<Terminal :size="32" />
|
|
<p>Retro Hacker</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
:root {
|
|
/* Modern Blue (Default) */
|
|
--bg-color: #010409;
|
|
--card-bg: #0d1117;
|
|
--text-color: #c9d1d9;
|
|
--text-muted: #8b949e;
|
|
--accent-color: #1f6feb;
|
|
--accent-hover: #388bfd;
|
|
--border-color: #30363d;
|
|
--error-color: #f85149;
|
|
--success-color: #238636;
|
|
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
[data-theme='hacker'] {
|
|
--bg-color: #002b36;
|
|
--card-bg: #073642;
|
|
--text-color: #859900;
|
|
--text-muted: #586e75;
|
|
--accent-color: #2aa198;
|
|
--accent-hover: #93a1a1;
|
|
--border-color: #586e75;
|
|
--error-color: #dc322f;
|
|
--success-color: #859900;
|
|
--font-family: 'Courier New', Courier, monospace;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-color);
|
|
color: var(--text-color);
|
|
font-family: var(--font-family);
|
|
margin: 0;
|
|
padding: 0;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
#app {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 2rem;
|
|
}
|
|
</style>
|
|
|
|
<style scoped>
|
|
header {
|
|
margin-bottom: 3rem;
|
|
text-align: center;
|
|
position: relative;
|
|
}
|
|
|
|
.settings-btn {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.settings-btn:hover {
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
header h1 {
|
|
font-size: 3rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
header p {
|
|
color: var(--text-muted);
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.card {
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
box-shadow: var(--card-shadow);
|
|
}
|
|
|
|
.doc-box {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border-left: 3px solid var(--accent-color);
|
|
padding: 0.75rem;
|
|
font-size: 0.9rem;
|
|
line-height: 1.4;
|
|
color: var(--text-muted);
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.success-border {
|
|
border-left-color: var(--success-color) !important;
|
|
background: rgba(35, 134, 54, 0.1);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.dashboard {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
margin: 0.5rem 0;
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
input {
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-color);
|
|
padding: 0.75rem;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
button {
|
|
background: var(--accent-color);
|
|
color: white;
|
|
border: none;
|
|
padding: 0.75rem;
|
|
border-radius: 6px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
button:hover {
|
|
background: var(--accent-hover);
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.error {
|
|
color: var(--error-color);
|
|
font-size: 0.9rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.success {
|
|
color: var(--success-color);
|
|
font-size: 0.9rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.main-nav {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-top: 1rem;
|
|
margin-bottom: -1rem;
|
|
}
|
|
|
|
.main-nav button {
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
border: 1px solid transparent;
|
|
padding: 0.5rem 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
border-radius: 20px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.main-nav button:hover {
|
|
background: var(--card-bg);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.main-nav button.active {
|
|
background: var(--card-bg);
|
|
color: var(--accent-color);
|
|
border-color: var(--border-color);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.sync-overlay {
|
|
grid-column: 1 / -1;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.spinner {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.icon-btn {
|
|
background: transparent;
|
|
padding: 0.25rem;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.badge {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
padding: 2px 8px;
|
|
border-radius: 12px;
|
|
font-size: 0.8rem;
|
|
color: var(--text-color);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.activity-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem 0;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.activity-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
/* Modal Styles */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal-content {
|
|
background: var(--bg-color);
|
|
width: 90%;
|
|
max-width: 800px;
|
|
max-height: 90vh;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.modal-header {
|
|
padding: 1.5rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-body {
|
|
display: flex;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.modal-sidebar {
|
|
width: 200px;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
border-right: 1px solid var(--border-color);
|
|
padding: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.sidebar-item {
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
color: var(--text-muted);
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.sidebar-item:hover {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.sidebar-item.active {
|
|
background: var(--accent-color);
|
|
color: white;
|
|
}
|
|
|
|
.modal-main {
|
|
flex: 1;
|
|
padding: 2rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* Fix for Menu Visibility Issues */
|
|
.main-nav button {
|
|
white-space: nowrap; /* Prevent text wrapping */
|
|
opacity: 0.7; /* Better than muted color for text visibility */
|
|
}
|
|
|
|
.main-nav button:hover,
|
|
.main-nav button.active {
|
|
opacity: 1;
|
|
}
|
|
</style>
|