web/src/App.vue
Rama main · réplica de lectura del repositorio original.
<template>
<v-app>
<!-- Top App Bar (only show when authenticated) -->
<v-app-bar
v-if="showAppBar"
color="primary"
elevation="0"
density="comfortable"
app
>
<v-app-bar-title class="d-flex align-center">
<!-- Logo -->
<router-link to="/dashboard" class="d-flex align-center text-decoration-none">
<img :src="authStore.logoPath" alt="ActaLog Logo" style="height: 32px; width: 32px; margin-right: 8px;" />
<span class="text-white font-weight-bold">ActaLog</span>
</router-link>
</v-app-bar-title>
<template #append>
<!-- Network Status Indicator -->
<v-chip
v-if="!networkStore.isOnline"
size="small"
color="warning"
variant="flat"
class="mr-2"
>
<v-icon start size="small">mdi-cloud-off-outline</v-icon>
Offline
</v-chip>
<v-chip
v-else-if="networkStore.isSyncing"
size="small"
color="info"
variant="flat"
class="mr-2"
>
<v-icon start size="small">mdi-sync</v-icon>
Syncing...
</v-chip>
<!-- Current Date -->
<div class="text-white text-caption mr-2" style="opacity: 0.9">
{{ currentDate }}
</div>
<!-- Theme Switcher -->
<theme-switcher class="mr-1" />
<!-- Leaderboard icon -->
<v-btn icon color="white" variant="text" size="small" to="/leaderboard">
<v-icon>mdi-podium</v-icon>
</v-btn>
<!-- Notifications icon with badge -->
<v-btn icon color="white" variant="text" size="small" to="/notifications">
<v-badge
:content="unreadNotificationCount"
:model-value="unreadNotificationCount > 0"
color="warning"
overlap
>
<v-icon>mdi-bell-outline</v-icon>
</v-badge>
</v-btn>
</template>
</v-app-bar>
<!-- Network Status Notifications -->
<v-snackbar
v-model="networkStore.showOfflineNotification"
:timeout="-1"
location="top"
color="warning"
elevation="8"
>
<div class="d-flex align-center">
<v-icon start>mdi-cloud-off-outline</v-icon>
<div>
<strong>You're offline</strong>
<div class="text-caption">Changes will be saved locally and synced when you're back online</div>
</div>
</div>
<template #actions>
<v-btn
variant="text"
size="small"
@click="networkStore.dismissOfflineNotification()"
>
Dismiss
</v-btn>
</template>
</v-snackbar>
<v-snackbar
v-model="networkStore.showOnlineNotification"
:timeout="3000"
location="top"
color="success"
elevation="8"
>
<div class="d-flex align-center">
<v-icon start>mdi-cloud-check-outline</v-icon>
<div>
<strong>You're back online</strong>
<div v-if="networkStore.hasPendingSync" class="text-caption">Syncing your changes...</div>
</div>
</div>
</v-snackbar>
<v-snackbar
v-model="networkStore.showSyncNotification"
:timeout="3000"
location="top"
color="success"
elevation="8"
>
<div class="d-flex align-center">
<v-icon start>mdi-check-circle-outline</v-icon>
<div>
<strong>All changes synced</strong>
<div class="text-caption">Your data is up to date</div>
</div>
</div>
</v-snackbar>
<!-- Offline Save Notification -->
<v-snackbar
v-model="showOfflineSaveNotification"
:timeout="4000"
location="top"
color="info"
elevation="8"
>
<div class="d-flex align-center">
<v-icon start>mdi-content-save-outline</v-icon>
<div>
<strong>Saved Offline</strong>
<div class="text-caption">{{ offlineSaveMessage }}</div>
</div>
</div>
<template #actions>
<v-btn
variant="text"
size="small"
@click="showOfflineSaveNotification = false"
>
OK
</v-btn>
</template>
</v-snackbar>
<!-- Subscription Expired Banner -->
<subscription-expired-banner v-if="authStore.isAuthenticated" />
<v-main :style="mainStyle">
<router-view />
</v-main>
<!-- Install Prompt -->
<install-prompt v-if="authStore.isAuthenticated" />
<!-- PWA Update Prompt -->
<update-prompt />
<!-- Bottom Navigation (only show when authenticated) -->
<v-bottom-navigation
v-if="showBottomNav"
v-model="activeTab"
grow
app
elevation="8"
bg-color="surface"
height="56"
class="bottom-nav-compact"
>
<v-btn value="dashboard" to="/dashboard" size="x-small">
<v-icon size="20">mdi-home</v-icon>
<span class="nav-label">Home</span>
</v-btn>
<v-btn value="performance" to="/performance" size="x-small">
<v-icon size="20">mdi-chart-bar</v-icon>
<span class="nav-label">Stats</span>
</v-btn>
<!-- Schedule Button (when scheduling enabled) -->
<v-btn
v-if="authStore.schedulingEnabled"
value="schedule"
to="/schedule"
size="x-small"
class="fab-button"
>
<v-avatar color="primary" size="40">
<v-icon color="white" size="24">mdi-calendar-clock</v-icon>
</v-avatar>
</v-btn>
<!-- Quick-log fallback (when scheduling disabled) -->
<v-btn
v-else
value="log"
to="/dashboard?open=quick-log"
size="x-small"
class="fab-button"
>
<v-avatar color="primary" size="40">
<v-icon color="white" size="24">mdi-plus-thick</v-icon>
</v-avatar>
</v-btn>
<v-btn value="workouts" to="/workouts" size="x-small">
<v-icon size="20">mdi-run</v-icon>
<span class="nav-label">Log</span>
</v-btn>
<!-- Coach Button (when user has coach/admin role AND scheduling enabled) -->
<v-btn
v-if="authStore.isCoachOrAdmin && authStore.schedulingEnabled"
value="coach"
to="/coach"
size="x-small"
>
<v-icon size="20">mdi-clipboard-check</v-icon>
<span class="nav-label">Coach</span>
</v-btn>
<v-btn value="profile" to="/profile" size="x-small">
<!-- Show avatar if user has one, otherwise show default icon -->
<v-avatar v-if="userAvatar" size="20">
<v-img :src="userAvatar" alt="Profile" />
</v-avatar>
<v-icon v-else size="20" color="#597a6a">mdi-account-circle</v-icon>
<span class="nav-label">Me</span>
</v-btn>
</v-bottom-navigation>
</v-app>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, provide } from 'vue'
import { useRoute } from 'vue-router'
import { useTheme } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { useNetworkStore } from '@/stores/network'
import { useSubscriptionStore } from '@/stores/subscription'
import { useSettingsStore } from '@/stores/settings'
import { formatDateInTimezone } from '@/utils/timezone'
import InstallPrompt from '@/components/InstallPrompt.vue'
import UpdatePrompt from '@/components/UpdatePrompt.vue'
import SubscriptionExpiredBanner from '@/components/SubscriptionExpiredBanner.vue'
import ThemeSwitcher from '@/components/ThemeSwitcher.vue'
import axios from '@/utils/axios'
const route = useRoute()
const theme = useTheme()
const authStore = useAuthStore()
const networkStore = useNetworkStore()
const subscriptionStore = useSubscriptionStore()
const settingsStore = useSettingsStore()
const activeTab = ref('dashboard')
const currentDate = ref('')
// Notification state
const unreadNotificationCount = ref(0)
let notificationPollInterval = null
// Offline save notification state
const showOfflineSaveNotification = ref(false)
const offlineSaveMessage = ref('')
// Reactive user avatar - watches auth store for changes
const userAvatar = computed(() => {
const user = authStore.user
if (user && user.profile_image) {
return user.profile_image
}
return null
})
// Show app bar and bottom nav only when authenticated and not on login/register
const showAppBar = computed(() => {
const publicRoutes = ['login', 'register', 'not-found']
return authStore.isAuthenticated && !publicRoutes.includes(route.name)
})
const showBottomNav = computed(() => {
const publicRoutes = ['login', 'register', 'not-found']
return authStore.isAuthenticated && !publicRoutes.includes(route.name)
})
// Computed style for v-main to handle safe areas and fixed chrome.
// NOTE: Vuetify 4 wraps all its CSS in @layer, which has lower cascade priority
// than any unlayered CSS (including our * { padding: 0 } reset in main.css).
// Inline styles always win regardless of layers, so we must set both paddings
// here rather than relying on Vuetify's CSS class to apply them.
const mainStyle = computed(() => {
// Read top offset from Vuetify's layout system (set by v-app-bar registration)
const topPadding = showAppBar.value
? 'var(--v-layout-top, 56px)'
: '0'
// Bottom nav height + safe area for devices with home indicator
const bottomPadding = showBottomNav.value
? 'calc(var(--v-layout-bottom, 56px) + env(safe-area-inset-bottom, 0px))'
: '0'
return {
paddingTop: topPadding,
paddingBottom: bottomPadding,
minHeight: '100%',
maxWidth: '100vw',
overflowX: 'hidden'
}
})
// Update current date using user's timezone
function updateCurrentDate() {
const now = new Date()
const tz = settingsStore.timezone
currentDate.value = formatDateInTimezone(now, tz, 'EEE, MMM d, yyyy')
}
// Watch for timezone changes to update the header date
watch(() => settingsStore.timezone, () => {
updateCurrentDate()
})
// Watch for auth state changes — start/stop polling as the user logs in or out
watch(() => authStore.isAuthenticated, (isAuth) => {
if (isAuth) {
startNotificationPolling()
} else {
stopNotificationPolling()
unreadNotificationCount.value = 0
}
})
// Fetch unread notification count
async function fetchUnreadNotificationCount() {
if (!authStore.isAuthenticated) return
try {
const response = await axios.get('/api/notifications/count')
unreadNotificationCount.value = response.data.count || 0
} catch (error) {
// Silently fail - don't disrupt user experience
console.error('Failed to fetch notification count:', error)
}
}
// Expose so child views (e.g. NotificationsView) can trigger an immediate refresh
provide('refreshNotificationCount', fetchUnreadNotificationCount)
// Start polling for notifications
function startNotificationPolling() {
// Fetch immediately
fetchUnreadNotificationCount()
// Then poll every 30 seconds
notificationPollInterval = setInterval(fetchUnreadNotificationCount, 30000)
}
// Stop polling for notifications
function stopNotificationPolling() {
if (notificationPollInterval) {
clearInterval(notificationPollInterval)
notificationPollInterval = null
}
}
// Handle offline save events
function handleOfflineSave(event) {
offlineSaveMessage.value = event.detail?.message || 'Saved offline. Will sync when back online.'
showOfflineSaveNotification.value = true
// Update pending sync count
networkStore.incrementPendingSync()
}
// Handle subscription expired events (triggered by axios interceptor)
function handleSubscriptionExpired(event) {
console.log('Subscription expired event received:', event.detail)
// The SubscriptionExpiredBanner will automatically show when subscriptionStore.isExpired is true
// The axios interceptor already called subscriptionStore.setExpired()
}
// Check for user's preferred theme
onMounted(() => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
theme.global.name.value = savedTheme
}
updateCurrentDate()
// Update date every minute
setInterval(updateCurrentDate, 60000)
// Initialize network status listeners
networkStore.initNetworkListeners()
// Listen for offline save events
window.addEventListener('offline-save', handleOfflineSave)
// Listen for subscription expired events
window.addEventListener('subscription-expired', handleSubscriptionExpired)
// Fetch subscription status if authenticated
if (authStore.isAuthenticated) {
subscriptionStore.fetchStatus().catch(err => {
console.error('Failed to fetch subscription status on app mount:', err)
})
// Start polling for notifications
startNotificationPolling()
}
})
onBeforeUnmount(() => {
window.removeEventListener('offline-save', handleOfflineSave)
window.removeEventListener('subscription-expired', handleSubscriptionExpired)
stopNotificationPolling()
})
</script>
<style>
/* Global styles based on requirements */
* {
font-family: var(--app-font-family);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
padding: 0;
background-color: rgb(var(--v-theme-background));
}
/* Typography - optimized for mobile */
h1 {
font-size: 24px;
font-weight: 600;
}
h2 {
font-size: 20px;
font-weight: 600;
}
h3 {
font-size: 18px;
font-weight: 500;
}
h4, h5, h6 {
font-size: 16px;
font-weight: 500;
}
p, span, div {
font-weight: 400;
font-size: 14px;
}
/* Smaller text variants */
.text-caption {
font-size: 12px !important;
}
.text-body-2 {
font-size: 13px !important;
}
.text-body-1 {
font-size: 14px !important;
}
/* Spacing from requirements - 20px outer padding, 16px gutter */
.v-container {
padding: 20px;
}
.v-row {
margin: -8px;
}
.v-col {
padding: 8px;
}
/* FAB button styling */
.fab-button .v-avatar {
position: relative;
top: -8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
/* Bottom nav button styling */
.v-bottom-navigation .v-btn {
flex-direction: column;
height: 100% !important;
}
.v-bottom-navigation .v-btn .v-icon {
margin-bottom: 2px;
}
/* Compact bottom navigation for mobile */
.bottom-nav-compact .v-btn {
min-width: 50px !important;
padding: 4px 2px !important;
}
.bottom-nav-compact .nav-label {
font-size: 9px !important;
line-height: 1.1;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.bottom-nav-compact .fab-button .v-avatar {
top: -4px;
}
/* Reduce overall app font sizes for mobile */
@media (max-width: 600px) {
.v-card-title {
font-size: 16px !important;
}
.v-card-subtitle {
font-size: 12px !important;
}
.v-list-item-title {
font-size: 14px !important;
}
.v-list-item-subtitle {
font-size: 11px !important;
}
}
</style>