The 5 sequential UPDATEs could half-rename the database if a renamed title collided with an existing one (UNIQUE constraint) mid-way. They now go through db.batch(), which rolls back on error. SettingsView shows a translated error (new import.suffixRenameFailed key, EN/FR/DA) instead of the raw constraint message. Test seeds a colliding title and asserts the rename fails with nothing renamed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1093 lines
28 KiB
Vue
1093 lines
28 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 voiceEnabled = ref(true)
|
|
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
|
|
const voiceStr = await get('voice_enabled')
|
|
voiceEnabled.value = voiceStr === null ? true : voiceStr === 'true'
|
|
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))
|
|
}
|
|
|
|
async function onVoiceEnabledChange() {
|
|
await set('voice_enabled', String(voiceEnabled.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) {
|
|
console.error('Suffix rename failed:', e)
|
|
editSuffixError.value = t('import.suffixRenameFailed')
|
|
}
|
|
}
|
|
|
|
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>
|
|
<label class="checkbox-label" data-testid="settings-voice-enabled-label">
|
|
<input
|
|
v-model="voiceEnabled"
|
|
type="checkbox"
|
|
data-testid="settings-voice-enabled"
|
|
@change="onVoiceEnabledChange"
|
|
/>
|
|
<span>{{ $t('settings.voiceAnnouncements') }}</span>
|
|
</label>
|
|
</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>
|