350 lines
9.3 KiB
Vue
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>
|