trainUs/src/App.vue
David 270ff24c60
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Tests (${{ matrix.browser }}) (chromium) (push) Has been cancelled
CI / Tests (${{ matrix.browser }}) (firefox) (push) Has been cancelled
Fix some issue at first load and cache issue
2026-07-26 13:03:09 +02:00

350 lines
9.3 KiB
Vue

<script setup>
import { useTranslation } from 'i18next-vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import AppUpdateDialog from './components/AppUpdateDialog.vue'
import BackupReminder from './components/BackupReminder.vue'
import FirstLoadWarning from './components/FirstLoadWarning.vue'
import InstallPrompt from './components/InstallPrompt.vue'
import SaveFileDialog from './components/SaveFileDialog.vue'
import { db } from './db/database.js'
import { bootstrapDatabase } from './db/bootstrap.js'
import { useBackupDownload } from './composables/useBackupDownload.js'
const { t } = useTranslation()
const route = useRoute()
function onNavKeydown(event) {
if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(event.key)) return
const links = Array.from(event.currentTarget.querySelectorAll('a'))
const idx = links.indexOf(document.activeElement)
if (idx === -1) return
event.preventDefault()
const isForward = event.key === 'ArrowRight' || event.key === 'ArrowDown'
links[(idx + (isForward ? 1 : -1) + links.length) % links.length].focus()
}
const dbReady = ref(false)
const dbError = ref(null)
const migrationPending = ref(null)
if (window.__dbReady) {
window.__dbReady
.then((status) => {
if (status && status.migrationPending) {
migrationPending.value = status.migrationPending
} else {
dbReady.value = true
}
})
.catch((err) => {
dbError.value = err.message || String(err)
})
}
const dbErrorBody = computed(() => {
if (dbError.value === 'DB_NEWER_THAN_APP') return t('error.dbNewerThanApp')
if (dbError.value === 'DB_MIGRATION_FAILED') return t('error.dbMigrationFailed')
if (dbError.value && dbError.value.startsWith('DB_LOAD_FAILED')) return t('error.dbLoadFailed')
return t('error.browserNotCompatible')
})
// Retrying makes no sense when the data itself is ahead of the app or a
// migration just failed; every other init error (network, storage) may be
// transient.
const dbErrorRetryable = computed(
() => dbError.value !== 'DB_NEWER_THAN_APP' && dbError.value !== 'DB_MIGRATION_FAILED',
)
async function onRetryInit() {
dbError.value = null // back to the loading overlay while the retry runs
try {
const status = await bootstrapDatabase()
if (status && status.migrationPending) {
migrationPending.value = status.migrationPending
} else {
dbReady.value = true
}
} catch (err) {
dbError.value = err.message || String(err)
}
}
// Blocking migration screen: backup is mandatory before the update can run
const backupDownloaded = ref(false)
const migrating = ref(false)
const { downloadBackup } = useBackupDownload()
async function onMigrationBackup() {
const saved = await downloadBackup()
if (saved) backupDownloaded.value = true
}
async function onMigrationUpdate() {
migrating.value = true
try {
await db.runPendingMigrations()
migrationPending.value = null
document.documentElement.removeAttribute('data-db-migration')
document.documentElement.setAttribute('data-db-ready', 'true')
dbReady.value = true
} catch (err) {
dbError.value = err.message || String(err)
document.documentElement.setAttribute('data-db-error', dbError.value)
} finally {
migrating.value = false
}
}
const dbConnectionLost = ref(false)
onMounted(() => {
window.addEventListener('db-connection-lost', () => {
dbConnectionLost.value = true
})
})
function reloadPage() {
window.location.reload()
}
const pageTitle = computed(() => {
const name = route.name
// Map sub-route names to their parent nav key
const navKeyMap = {
'exercise-new': 'exercises',
'exercise-detail': 'exercises',
'exercise-edit': 'exercises',
'combo-new': 'combos',
'combo-detail': 'combos',
'combo-edit': 'combos',
'session-new': 'sessions',
'session-detail': 'sessions',
'session-edit': 'sessions',
'session-execute': 'sessions',
'collection-new': 'collections',
'collection-detail': 'collections',
'collection-edit': 'collections',
}
const navKey = navKeyMap[name] || name
if (navKey && t(`nav.${navKey}`, '')) {
return t(`nav.${navKey}`)
}
return t('app.name')
})
</script>
<template>
<div v-if="dbConnectionLost" class="db-overlay" data-testid="db-connection-lost">
<div class="db-overlay-card">
<i class="bi bi-plug db-error-icon"></i>
<h1>{{ $t('error.dbInitFailed') }}</h1>
<p>{{ $t('error.dbConnectionLost') }}</p>
<button class="btn btn-primary" style="margin-top: 1rem" @click="reloadPage">
{{ $t('error.reload') }}
</button>
</div>
</div>
<div v-else-if="dbError" class="db-overlay" data-testid="db-error">
<div class="db-overlay-card">
<i class="bi bi-exclamation-triangle db-error-icon"></i>
<h1>{{ $t('error.dbInitFailed') }}</h1>
<p>{{ dbErrorBody }}</p>
<button
v-if="dbErrorRetryable"
class="btn btn-primary"
style="margin-top: 1rem"
data-testid="db-retry"
@click="onRetryInit"
>
{{ $t('error.retry') }}
</button>
</div>
</div>
<div v-else-if="migrationPending" class="db-overlay" data-testid="db-migration">
<div class="db-overlay-card">
<i class="bi bi-database-up db-migration-icon"></i>
<h1>{{ $t('migration.title') }}</h1>
<p>
{{ $t('migration.body', { from: migrationPending.from, to: migrationPending.to }) }}
</p>
<p>{{ $t('migration.backupRequired') }}</p>
<div class="db-migration-actions">
<button class="btn btn-primary" data-testid="migration-backup" @click="onMigrationBackup">
<i class="bi bi-download"></i>
{{ $t('migration.downloadBackup') }}
</button>
<button
class="btn btn-primary"
data-testid="migration-update"
:disabled="!backupDownloaded || migrating"
@click="onMigrationUpdate"
>
<span v-if="migrating" class="spinner spinner-inline"></span>
{{ migrating ? $t('migration.updating') : $t('migration.update') }}
</button>
</div>
</div>
</div>
<div v-else-if="!dbReady" class="db-overlay" data-testid="db-loading">
<div class="db-overlay-card">
<div class="spinner"></div>
<p class="loading-text">{{ $t('app.loading') }}</p>
</div>
</div>
<template v-else>
<BackupReminder :key="route.fullPath" />
<FirstLoadWarning />
<InstallPrompt />
<AppUpdateDialog />
<nav class="navbar" :aria-label="$t('nav.mainNav')" @keydown="onNavKeydown">
<div class="navbar-brand">{{ $t('app.name') }}</div>
<router-link to="/home">
<i class="bi bi-house"></i>
<span>{{ $t('nav.home') }}</span>
</router-link>
<router-link to="/exercises">
<i class="bi bi-person-arms-up"></i>
<span>{{ $t('nav.exercises') }}</span>
</router-link>
<router-link to="/combos">
<i class="bi bi-layers"></i>
<span>{{ $t('nav.combos') }}</span>
</router-link>
<router-link to="/sessions">
<i class="bi bi-journal-text"></i>
<span>{{ $t('nav.sessions') }}</span>
</router-link>
<router-link to="/collections">
<i class="bi bi-collection"></i>
<span>{{ $t('nav.collections') }}</span>
</router-link>
<div class="navbar-spacer"></div>
<router-link to="/stats">
<i class="bi bi-graph-up"></i>
<span>{{ $t('nav.stats') }}</span>
</router-link>
<router-link to="/settings">
<i class="bi bi-gear"></i>
<span>{{ $t('nav.settings') }}</span>
</router-link>
</nav>
<div class="content">
<div class="actionbar">
<h1>{{ pageTitle }}</h1>
<div id="actionbar-actions"></div>
</div>
<div class="content-body">
<router-view />
</div>
</div>
</template>
<SaveFileDialog />
</template>
<style scoped>
.db-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-background, #f5f5f5);
z-index: 9999;
padding: 2rem;
}
.db-overlay-card {
text-align: center;
max-width: 480px;
}
.db-error-icon {
font-size: 3rem;
color: var(--color-danger, #dc3545);
}
.db-migration-icon {
font-size: 3rem;
color: var(--color-primary, #4a90d9);
}
.db-migration-actions {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.spinner-inline {
width: 1em;
height: 1em;
border-width: 2px;
display: inline-block;
vertical-align: -0.15em;
margin: 0 0.35em 0 0;
}
.db-overlay-card h1 {
margin: 1rem 0 0.5rem;
font-size: 1.5rem;
}
.db-overlay-card p {
color: var(--color-text-secondary, #666);
line-height: 1.5;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--color-border, #ddd);
border-top-color: var(--color-primary, #4a90d9);
border-radius: 50%;
margin: 0 auto 1rem;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 1.1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 18px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.btn-primary {
background: var(--btn-primary-bg, #4a90d9);
color: var(--btn-primary-color, #fff);
}
</style>