trainUs/src/views/SettingsView.vue
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

1076 lines
27 KiB
Vue

<script setup>
import { ref, onMounted, computed } from 'vue'
import { useTranslation } from 'i18next-vue'
import { useSettings } from '../composables/useSettings.js'
import { useAudioCues } from '../composables/useAudioCues.js'
import { useBackupStatus } from '../composables/useBackupStatus.js'
import { useBackupDownload } from '../composables/useBackupDownload.js'
import { useJsonExport } from '../composables/useJsonExport.js'
import { useJsonImport } from '../composables/useJsonImport.js'
import { suffixRepository } from '../db/repositories/suffixRepository.js'
import { db } from '../db/database.js'
import { formatDateTime } from '../utils/dateFormatter.js'
import ModalDialog from '../components/ModalDialog.vue'
import ExportFilterDialog from '../components/ExportFilterDialog.vue'
import ImportDialog from '../components/ImportDialog.vue'
import CreditsDialog from '../components/CreditsDialog.vue'
import AboutDialog from '../components/AboutDialog.vue'
import CleanJournalDialog from '../components/CleanJournalDialog.vue'
const { t, i18next } = useTranslation()
const { settings, fetchAll, get, set } = useSettings()
const { playBip, playBIP } = useAudioCues()
const { exportByFilter, exportAll } = useJsonExport()
const { loadFile } = useJsonImport()
const { markBackedUp, load: loadBackupStatus } = useBackupStatus()
const { downloadBackup } = useBackupDownload()
const userName = ref('')
const userUuid = ref('')
const language = ref('en')
const theme = ref('light')
const audioVolume = ref(50)
const nameError = ref('')
const restoring = ref(false)
const restoreReport = ref(null)
const showInitializeDialog = ref(false)
const showCreditsDialog = ref(false)
const showAboutDialog = ref(false)
const showCleanJournalDialog = ref(false)
const initializeConfirmed = ref(false)
const initializing = ref(false)
// Backup reminder
const lastBackupAtRaw = ref(null)
const backupReminderDays = ref('7')
const backupReminderDaysError = ref('')
const lastBackupDisplay = computed(() => {
if (!lastBackupAtRaw.value) return ''
return formatDateTime(lastBackupAtRaw.value, language.value)
})
// Export/Import
const showExportFilter = ref(false)
const showImportDialog = ref(false)
const importEnvelope = ref(null)
const importAsMine = ref(false)
// Suffix management
const suffixes = ref([])
const editingSuffix = ref(null)
const editSuffixValue = ref('')
const editSuffixError = ref('')
const pendingDeleteSuffix = ref(null)
onMounted(async () => {
await fetchAll()
userName.value = settings.value.user_name || ''
userUuid.value = settings.value.user_uuid || ''
language.value = settings.value.language || 'en'
theme.value = settings.value.theme || 'light'
const volumeStr = await get('audio_volume')
const parsed = parseInt(volumeStr, 10)
audioVolume.value = isNaN(parsed) ? 50 : parsed
await loadSuffixes()
await loadBackupInfo()
await loadBackupStatus()
})
async function onLanguageChange() {
const lang = language.value
await set('language', lang)
i18next.changeLanguage(lang)
}
async function onNameBlur() {
const trimmed = userName.value.trim()
if (!trimmed) {
nameError.value = t('settings.nameRequired')
return
}
nameError.value = ''
userName.value = trimmed
await set('user_name', trimmed)
}
async function onThemeChange() {
const newTheme = theme.value
await set('theme', newTheme)
document.documentElement.setAttribute('data-theme', newTheme)
}
async function onVolumeChange() {
await set('audio_volume', String(audioVolume.value))
}
function onSoundTest() {
const vol = audioVolume.value / 100
playBip(vol)
setTimeout(() => playBIP(vol), 300)
}
async function downloadDatabase() {
await downloadBackup()
await loadBackupInfo()
}
function restoreDatabase() {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.sqlite3,.db'
input.onchange = async (e) => {
const file = e.target.files[0]
if (!file) return
const confirmed = window.confirm(t('settings.restoreDbWarning', { version: __APP_VERSION__ }))
if (!confirmed) return
restoring.value = true
restoreReport.value = null
try {
const bytes = await file.arrayBuffer()
const report = await db.importDatabase(new Uint8Array(bytes))
restoreReport.value = report
if (report.success) {
// Re-fetch settings to reflect restored data
await fetchAll()
userName.value = settings.value.user_name || ''
userUuid.value = settings.value.user_uuid || ''
language.value = settings.value.language || 'en'
theme.value = settings.value.theme || 'light'
// Apply restored language and theme
i18next.changeLanguage(language.value)
document.documentElement.setAttribute('data-theme', theme.value)
// Update backup timestamp
await markBackedUp()
await loadBackupInfo()
}
} catch (err) {
restoreReport.value = { success: false, error: 'UNEXPECTED', message: err.message }
} finally {
restoring.value = false
}
}
input.click()
}
function dismissReport() {
restoreReport.value = null
}
function openInitializeDialog() {
showInitializeDialog.value = true
initializeConfirmed.value = false
}
function cancelInitialize() {
showInitializeDialog.value = false
initializeConfirmed.value = false
}
async function confirmInitialize() {
if (!initializeConfirmed.value) return
initializing.value = true
try {
await db.resetDatabase()
window.location.reload()
} catch (err) {
console.error('Failed to reset database:', err)
window.alert(t('error.dbInitFailed'))
} finally {
initializing.value = false
}
}
// --- Export / Import ---
async function onExportAll() {
await exportAll()
}
function onExportData() {
showExportFilter.value = true
}
async function onExportConfirm(filters) {
showExportFilter.value = false
await exportByFilter(filters)
}
async function onImportData(asMine = false) {
const result = await loadFile()
if (result.cancelled) return
if (result.error) {
window.alert(t(result.error))
return
}
importEnvelope.value = result.envelope
importAsMine.value = asMine
showImportDialog.value = true
}
function onImportDone() {
showImportDialog.value = false
importEnvelope.value = null
importAsMine.value = false
}
function onImportClose() {
showImportDialog.value = false
importEnvelope.value = null
importAsMine.value = false
}
// --- Suffix management ---
async function loadSuffixes() {
suffixes.value = await suffixRepository.getAll()
}
function startEditSuffix(entry) {
editingSuffix.value = entry.user_uuid
editSuffixValue.value = entry.suffix
editSuffixError.value = ''
}
function cancelEditSuffix() {
editingSuffix.value = null
editSuffixValue.value = ''
editSuffixError.value = ''
}
async function saveEditSuffix(entry) {
const trimmed = editSuffixValue.value.trim()
if (!trimmed) {
editSuffixError.value = t('import.suffixRequired')
return
}
if (trimmed === entry.suffix) {
cancelEditSuffix()
return
}
// Check uniqueness
const existing = await suffixRepository.getBySuffix(trimmed)
if (existing && existing.user_uuid !== entry.user_uuid) {
editSuffixError.value = t('import.suffixTaken')
return
}
try {
await suffixRepository.renameSuffix(entry.user_uuid, trimmed)
await loadSuffixes()
cancelEditSuffix()
} catch (e) {
editSuffixError.value = e.message
}
}
async function deleteSuffix(entry) {
pendingDeleteSuffix.value = entry
}
async function confirmDeleteSuffix() {
const entry = pendingDeleteSuffix.value
if (!entry) return
await suffixRepository.delete(entry.user_uuid)
pendingDeleteSuffix.value = null
await loadSuffixes()
}
function cancelDeleteSuffix() {
pendingDeleteSuffix.value = null
}
// -- backup DB
async function loadBackupInfo() {
const lastBackupAtStr = await get('last_backup_at')
if (lastBackupAtStr) {
lastBackupAtRaw.value = new Date(lastBackupAtStr)
} else {
lastBackupAtRaw.value = null
}
const daysStr = await get('backup_reminder_days')
if (daysStr !== null && daysStr !== undefined) {
backupReminderDays.value = daysStr
} else {
backupReminderDays.value = '7'
}
}
async function onBackupReminderDaysChange() {
const trimmed = String(backupReminderDays.value).trim()
const parsed = parseInt(trimmed, 10)
if (isNaN(parsed) || parsed < 1 || parsed > 365) {
backupReminderDaysError.value = t('settings.backupReminderInvalid')
return
}
backupReminderDaysError.value = ''
backupReminderDays.value = String(parsed)
await set('backup_reminder_days', String(parsed))
await loadBackupStatus()
}
</script>
<template>
<div class="settings-view">
<!-- Language -->
<div class="card setting-card">
<label class="setting-label" for="settings-language">
<i class="bi bi-translate"></i>
{{ $t('settings.language') }}
</label>
<select
id="settings-language"
v-model="language"
data-testid="settings-language"
@change="onLanguageChange"
>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="da">Dansk</option>
</select>
</div>
<!-- User Name -->
<div class="card setting-card">
<label class="setting-label" for="settings-username">
<i class="bi bi-person"></i>
{{ $t('settings.userName') }}
</label>
<input
id="settings-username"
v-model="userName"
data-testid="settings-username"
type="text"
:placeholder="$t('settings.userNamePlaceholder')"
@blur="onNameBlur"
@keyup.enter="onNameBlur"
/>
<p v-if="nameError" class="field-error" data-testid="settings-name-error">{{ nameError }}</p>
</div>
<!-- UUID -->
<div class="card setting-card">
<label class="setting-label" for="settings-uuid">
<i class="bi bi-fingerprint"></i>
{{ $t('settings.userId') }}
</label>
<input
id="settings-uuid"
data-testid="settings-uuid"
type="text"
:value="userUuid"
readonly
class="input-readonly"
/>
</div>
<!-- Theme -->
<div class="card setting-card">
<label class="setting-label" for="settings-theme">
<i class="bi bi-palette"></i>
{{ $t('settings.theme') }}
</label>
<select
id="settings-theme"
v-model="theme"
data-testid="settings-theme"
@change="onThemeChange"
>
<option value="light">{{ $t('settings.themeLight') }}</option>
<option value="dark">{{ $t('settings.themeDark') }}</option>
<option value="trainus">{{ $t('settings.themeTrainus') }}</option>
</select>
</div>
<!-- Sound -->
<div class="card setting-card">
<label class="setting-label">
<i class="bi bi-volume-up"></i>
{{ $t('settings.sound') }}
</label>
<div class="sound-row">
<label class="sound-volume-label" for="settings-volume">{{
$t('settings.soundVolume')
}}</label>
<input
id="settings-volume"
v-model.number="audioVolume"
data-testid="settings-volume"
type="range"
min="0"
max="100"
step="1"
class="volume-slider"
@change="onVolumeChange"
/>
<span class="volume-value" data-testid="settings-volume-value">{{ audioVolume }}%</span>
<button class="btn btn-primary" data-testid="settings-sound-test" @click="onSoundTest">
<i class="bi bi-play-fill"></i>
{{ $t('settings.soundTest') }}
</button>
</div>
</div>
<!-- Restore overlay (spinner) -->
<div v-if="restoring" class="restore-overlay" data-testid="restore-spinner">
<div class="restore-spinner">
<i class="bi bi-arrow-repeat spin"></i>
<p>{{ $t('settings.restoreInProgress') }}</p>
</div>
</div>
<!-- Restore report -->
<div v-if="restoreReport" class="card setting-card restore-report" data-testid="restore-report">
<div :class="['report-header', restoreReport.success ? 'report-success' : 'report-error']">
<i :class="restoreReport.success ? 'bi bi-check-circle' : 'bi bi-exclamation-triangle'"></i>
<strong>{{
restoreReport.success ? $t('settings.restoreSuccess') : $t('settings.restoreError')
}}</strong>
</div>
<div class="report-body">
<template v-if="restoreReport.success">
<p v-if="restoreReport.adapted" data-testid="restore-adapted">
<i class="bi bi-arrow-left-right"></i>
{{
$t('settings.restoreAdapted', {
from: restoreReport.importedVersion,
to: restoreReport.currentVersion,
})
}}
</p>
<ul class="report-tables">
<li v-for="row in restoreReport.tablesRestored" :key="row.table">
<strong>{{ row.table }}</strong
>: {{ row.rows }} {{ $t('settings.restoreRows') }}
</li>
</ul>
</template>
<template v-else>
<p data-testid="restore-error-message">
{{
$t('settings.restoreErrorCodes.' + restoreReport.error, {
defaultValue: restoreReport.message,
})
}}
</p>
</template>
</div>
<button class="btn btn-primary" data-testid="restore-report-dismiss" @click="dismissReport">
{{ $t('settings.restoreDismiss') }}
</button>
</div>
<!-- Download / Restore Database -->
<div id="settings-backup-section" class="card setting-card">
<label class="setting-label">
<i class="bi bi-database-down"></i>
{{ $t('settings.database') }}
</label>
<div class="export-import-buttons">
<button
class="btn btn-primary flex-fill"
data-testid="settings-download-db"
@click="downloadDatabase"
>
<i class="bi bi-download"></i>
{{ $t('settings.downloadDb') }}
</button>
<button
class="btn btn-danger flex-fill"
data-testid="settings-restore-db"
:disabled="restoring"
@click="restoreDatabase"
>
<i class="bi bi-upload"></i>
{{ $t('settings.restoreDb') }}
</button>
</div>
<div class="export-import-buttons initialize-db-row">
<button
class="btn btn-danger"
data-testid="settings-initialize-db"
:disabled="initializing"
@click="openInitializeDialog"
>
<i class="bi bi-arrow-counterclockwise"></i>
{{ $t('settings.initializeDb') }}
</button>
</div>
<p class="text-muted">{{ $t('settings.downloadDbHint') }}</p>
<div class="backup-info">
<p class="backup-last-backup" data-testid="backup-last-backup">
<i class="bi bi-clock"></i>
{{
lastBackupDisplay
? $t('settings.backup.lastBackup', { when: lastBackupDisplay })
: $t('settings.backup.neverBackedUp')
}}
</p>
</div>
<div class="backup-reminder-setting">
<label for="backup-reminder-days" class="reminder-label">
{{ $t('settings.backup.reminderEvery') }}
</label>
<input
id="backup-reminder-days"
v-model="backupReminderDays"
data-testid="backup-reminder-days"
type="text"
inputmode="numeric"
pattern="[0-9]*"
class="reminder-input"
@blur="onBackupReminderDaysChange"
@keyup.enter="onBackupReminderDaysChange"
/>
<span class="reminder-unit">{{ $t('settings.backup.reminderEveryUnit') }}</span>
<p v-if="backupReminderDaysError" class="field-error" data-testid="backup-reminder-error">
{{ backupReminderDaysError }}
</p>
</div>
</div>
<!-- Export / Import -->
<div class="card setting-card">
<label class="setting-label">
<i class="bi bi-box-arrow-up"></i>
{{ $t('settings.exportImport') }}
</label>
<div class="export-import-grid">
<button class="btn btn-primary" data-testid="settings-export-all" @click="onExportAll">
<i class="bi bi-download"></i>
{{ $t('settings.exportAllData') }}
</button>
<button class="btn btn-primary" data-testid="settings-export" @click="onExportData">
<i class="bi bi-filter"></i>
{{ $t('settings.exportData') }}
</button>
<button class="btn btn-primary" data-testid="settings-import" @click="onImportData(false)">
<i class="bi bi-upload"></i>
{{ $t('settings.importData') }}
</button>
<button
class="btn btn-primary"
data-testid="settings-import-as-mine"
@click="onImportData(true)"
>
<i class="bi bi-person-check"></i>
{{ $t('settings.importAsMine') }}
</button>
</div>
</div>
<!-- Manage Exercise Logs -->
<div class="card setting-card">
<label class="setting-label">
<i class="bi bi-journal-x"></i>
{{ $t('settings.manageJournal') }}
</label>
<p class="text-muted">{{ $t('settings.manageJournalHint') }}</p>
<div>
<button
class="btn btn-danger"
data-testid="settings-clean-journal"
@click="showCleanJournalDialog = true"
>
<i class="bi bi-trash"></i>
{{ $t('settings.cleanJournalBtn') }}
</button>
</div>
</div>
<!-- Suffix Management -->
<div v-if="suffixes.length > 0" class="card setting-card" data-testid="suffix-management">
<label class="setting-label">
<i class="bi bi-people"></i>
{{ $t('settings.suffixManagement') }}
</label>
<p class="text-muted">{{ $t('settings.suffixHint') }}</p>
<div
v-for="entry in suffixes"
:key="entry.user_uuid"
class="suffix-entry"
:data-testid="'suffix-entry-' + entry.user_uuid"
>
<div class="suffix-info">
<strong>{{ entry.user_name }}</strong>
<template v-if="editingSuffix !== entry.user_uuid">
<span class="suffix-badge" data-testid="suffix-value">[{{ entry.suffix }}]</span>
</template>
<template v-else>
<input
v-model="editSuffixValue"
type="text"
class="suffix-edit-input"
data-testid="suffix-edit-input"
@keyup.enter="saveEditSuffix(entry)"
@keyup.escape="cancelEditSuffix"
/>
<p v-if="editSuffixError" class="field-error" data-testid="suffix-edit-error">
{{ editSuffixError }}
</p>
</template>
</div>
<div class="suffix-actions">
<template v-if="editingSuffix !== entry.user_uuid">
<button
class="btn btn-sm"
data-testid="suffix-edit-btn"
@click="startEditSuffix(entry)"
>
<i class="bi bi-pencil"></i>
</button>
<button
class="btn btn-sm btn-danger"
data-testid="suffix-delete-btn"
@click="deleteSuffix(entry)"
>
<i class="bi bi-trash"></i>
</button>
</template>
<template v-else>
<button
class="btn btn-sm btn-primary"
data-testid="suffix-save-btn"
@click="saveEditSuffix(entry)"
>
<i class="bi bi-check-lg"></i>
</button>
<button class="btn btn-sm" data-testid="suffix-cancel-btn" @click="cancelEditSuffix">
<i class="bi bi-x-lg"></i>
</button>
</template>
</div>
</div>
</div>
<!-- Suffix delete confirmation dialog -->
<ModalDialog
v-if="pendingDeleteSuffix"
:title="$t('settings.suffixDeleteTitle')"
@close="cancelDeleteSuffix"
>
<p>
{{
$t('settings.suffixDeleteConfirm', {
name: pendingDeleteSuffix.user_name,
suffix: pendingDeleteSuffix.suffix,
})
}}
</p>
<div class="confirm-actions">
<button class="btn" data-testid="suffix-delete-cancel" @click="cancelDeleteSuffix">
{{ $t('import.cancel') }}
</button>
<button
class="btn btn-danger"
data-testid="suffix-delete-confirm"
@click="confirmDeleteSuffix"
>
<i class="bi bi-trash"></i>
{{ $t('settings.suffixDeleteTitle') }}
</button>
</div>
</ModalDialog>
<!-- Initialize DB confirmation dialog -->
<ModalDialog
v-if="showInitializeDialog"
:title="$t('settings.initializeDbTitle')"
@close="cancelInitialize"
>
<p class="initialize-warning">
<i class="bi bi-exclamation-triangle"></i>
{{ $t('settings.initializeDbHint') }}
</p>
<label class="checkbox-label" data-testid="initialize-confirm-label">
<input
v-model="initializeConfirmed"
type="checkbox"
data-testid="initialize-confirm-checkbox"
/>
<span>{{ $t('settings.initializeDbConfirm') }}</span>
</label>
<div class="confirm-actions">
<button class="btn" data-testid="initialize-cancel" @click="cancelInitialize">
{{ $t('import.cancel') }}
</button>
<button
class="btn btn-danger"
data-testid="initialize-confirm"
:disabled="!initializeConfirmed"
@click="confirmInitialize"
>
<i class="bi bi-arrow-counterclockwise"></i>
{{ $t('settings.initializeDb') }}
</button>
</div>
</ModalDialog>
<!-- Export filter dialog -->
<ExportFilterDialog
v-if="showExportFilter"
@export="onExportConfirm"
@close="showExportFilter = false"
/>
<!-- Import dialog -->
<ImportDialog
v-if="showImportDialog && importEnvelope"
:envelope="importEnvelope"
:local-uuid="userUuid"
:as-mine="importAsMine"
@done="onImportDone"
@close="onImportClose"
/>
<!-- Credits -->
<div class="card setting-card">
<label class="setting-label">
<i class="bi bi-heart"></i>
{{ $t('settings.credits') }}
</label>
<button class="btn" data-testid="settings-credits" @click="showCreditsDialog = true">
<i class="bi bi-info-circle"></i>
{{ $t('credits.title') }}
</button>
</div>
<CreditsDialog v-if="showCreditsDialog" @close="showCreditsDialog = false" />
<!-- About -->
<div class="card setting-card">
<label class="setting-label">
<i class="bi bi-info-circle"></i>
{{ $t('about.title') }}
</label>
<button class="btn" data-testid="settings-about" @click="showAboutDialog = true">
<i class="bi bi-info-circle"></i>
{{ $t('about.title') }}
</button>
</div>
<AboutDialog v-if="showAboutDialog" @close="showAboutDialog = false" />
<!-- Clean Journal dialog -->
<CleanJournalDialog
v-if="showCleanJournalDialog"
@close="showCleanJournalDialog = false"
@cleaned="showCleanJournalDialog = false"
/>
</div>
</template>
<style scoped>
.settings-view {
max-width: 600px;
}
.setting-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.setting-label {
font-weight: 600;
font-size: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.setting-label i {
font-size: 18px;
}
.field-error {
color: var(--btn-danger-bg);
font-size: 13px;
margin: 0;
}
.input-readonly {
opacity: 0.7;
cursor: default;
background-color: var(--bg-color2);
}
.export-import-buttons {
display: flex;
gap: 8px;
}
.export-import-buttons .flex-fill {
flex: 1;
}
.initialize-db-row {
margin-top: 8px;
justify-content: center;
}
.export-import-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.export-import-buttons button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.coming-soon {
font-size: 13px;
font-style: italic;
}
/* Restore overlay */
.restore-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.restore-spinner {
text-align: center;
color: #fff;
font-size: 18px;
}
.restore-spinner .spin {
font-size: 48px;
display: inline-block;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Restore report */
.restore-report {
border-left: 4px solid var(--front-color1);
}
.report-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
}
.report-success {
color: #28a745;
border-color: #28a745;
}
.report-success i {
font-size: 20px;
}
.report-error {
color: var(--btn-danger-bg);
border-color: var(--btn-danger-bg);
}
.report-error i {
font-size: 20px;
}
.report-body {
font-size: 14px;
}
.report-tables {
margin: 4px 0;
padding-left: 20px;
}
.report-tables li {
margin-bottom: 2px;
}
/* Suffix management */
.suffix-entry {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
.suffix-entry:last-child {
border-bottom: none;
}
.suffix-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.suffix-badge {
font-size: 13px;
padding: 2px 8px;
border-radius: 12px;
background-color: var(--btn-primary-bg);
color: var(--btn-primary-color);
}
.suffix-edit-input {
width: 120px;
font-size: 14px;
padding: 4px 8px;
}
.suffix-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: 8px;
}
.btn-sm {
font-size: 12px;
padding: 4px 10px;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.confirm-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.initialize-warning {
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--btn-danger-bg);
font-size: 14px;
}
.initialize-warning i {
font-size: 18px;
flex-shrink: 0;
margin-top: 2px;
}
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 16px;
font-size: 14px;
cursor: pointer;
word-wrap: break-word;
overflow-wrap: break-word;
}
.checkbox-label input[type='checkbox'] {
margin-top: 2px;
flex-shrink: 0;
width: 16px;
height: 16px;
}
.checkbox-label span {
flex: 1 1 0;
min-width: 0;
}
/* Backup section */
.backup-info {
margin-top: 8px;
}
.backup-last-backup {
font-size: 14px;
color: var(--front-color2);
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.backup-reminder-setting {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.reminder-label {
font-size: 14px;
color: var(--front-color2);
}
.reminder-input {
width: 60px;
font-size: 14px;
padding: 4px 8px;
}
.reminder-unit {
font-size: 14px;
color: var(--front-color2);
}
/* Sound section */
.sound-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.sound-volume-label {
font-size: 14px;
color: var(--front-color2);
white-space: nowrap;
}
.volume-slider {
flex: 1;
min-width: 80px;
accent-color: var(--btn-primary-bg);
outline: none;
}
.volume-value {
font-size: 14px;
color: var(--front-color2);
min-width: 36px;
text-align: right;
}
</style>