Ctrl K
ComunidadGuías…

web/src/App.vue

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<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>