feat(settings): update reader and library settings with auto-finalize

This commit is contained in:
neon
2026-03-11 20:33:31 -06:00
parent c8bb7dd90a
commit febed98ff5
16 changed files with 513 additions and 86 deletions
@@ -4,17 +4,31 @@ import type { CbxReaderSettings } from '@projectx/types'
import { useReaderDefaultSettings } from '@/features/reader/shared/composables/useReaderSettings'
import SettingsPageHeader from './SettingsPageHeader.vue'
const props = withDefaults(
defineProps<{
embedded?: boolean
}>(),
{
embedded: false,
},
)
const { effective, load, update, reset } = useReaderDefaultSettings<CbxReaderSettings>('cbx')
onMounted(load)
</script>
<template>
<SettingsPageHeader title="Comics Reader" subtitle="Default settings applied when opening CBZ, CBR, and CB7 files.">
<SettingsPageHeader v-if="!props.embedded" title="Comics Reader" subtitle="Default settings applied when opening CBZ, CBR, and CB7 files.">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
Reset to defaults
</button>
</SettingsPageHeader>
<div v-else class="flex justify-end mb-4">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
Reset to defaults
</button>
</div>
<!-- View -->
<div class="mb-6">
+15 -1
View File
@@ -7,6 +7,15 @@ import { Check } from 'lucide-vue-next'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
const props = withDefaults(
defineProps<{
embedded?: boolean
}>(),
{
embedded: false,
},
)
const { effective, load, update, reset } = useReaderDefaultSettings<EpubReaderSettings>('epub')
onMounted(load)
@@ -22,11 +31,16 @@ const fontFamilies: { id: string | null; label: string }[] = [
</script>
<template>
<SettingsPageHeader title="eBook Reader" subtitle="Default settings applied when opening EPUB, MOBI, FB2, and TXT files.">
<SettingsPageHeader v-if="!props.embedded" title="eBook Reader" subtitle="Default settings applied when opening EPUB, MOBI, FB2, and TXT files.">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
Reset to defaults
</button>
</SettingsPageHeader>
<div v-else class="flex justify-end mb-4">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
Reset to defaults
</button>
</div>
<!-- Formatting source -->
<div class="mb-6">
@@ -52,7 +52,7 @@ async function confirmSyncFiles() {
} catch (e) {
const msg = e instanceof Error ? e.message : ''
if (msg.includes('400')) {
toast.error('Metadata file write is not enabled. Turn it on in Maintenance settings.')
toast.error('Metadata file write is not enabled. Turn it on in Operations settings.')
} else {
toast.error(`File sync failed for "${lib.name}"`)
}
@@ -100,7 +100,7 @@ function toggleCbxFormat(fmt: 'cbz' | 'cb7') {
</script>
<template>
<SettingsPageHeader title="Maintenance" subtitle="System maintenance and data operations." />
<SettingsPageHeader title="Operations" subtitle="Background jobs and data operations." />
<div>
<p class="settings-group-label">Recommendations</p>
+15 -1
View File
@@ -4,6 +4,15 @@ import type { PdfReaderSettings } from '@projectx/types'
import { useReaderDefaultSettings } from '@/features/reader/shared/composables/useReaderSettings'
import SettingsPageHeader from './SettingsPageHeader.vue'
const props = withDefaults(
defineProps<{
embedded?: boolean
}>(),
{
embedded: false,
},
)
const { effective, load, update, reset } = useReaderDefaultSettings<PdfReaderSettings>('pdf')
onMounted(load)
@@ -12,11 +21,16 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
</script>
<template>
<SettingsPageHeader title="PDF Reader" subtitle="Default settings applied when opening PDF files.">
<SettingsPageHeader v-if="!props.embedded" title="PDF Reader" subtitle="Default settings applied when opening PDF files.">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
Reset to defaults
</button>
</SettingsPageHeader>
<div v-else class="flex justify-end mb-4">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
Reset to defaults
</button>
</div>
<!-- Layout -->
<div class="mb-6">
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SettingsPageHeader from './SettingsPageHeader.vue'
import ReaderSettings from './ReaderSettings.vue'
import EbookSettings from './EbookSettings.vue'
import PdfSettings from './PdfSettings.vue'
import ComicsSettings from './ComicsSettings.vue'
type Tab = 'general' | 'ebook' | 'pdf' | 'comics'
const route = useRoute()
const router = useRouter()
function normalizeTab(value: unknown): Tab {
if (value === 'ebook' || value === 'pdf' || value === 'comics') return value
return 'general'
}
const activeTab = ref<Tab>(normalizeTab(route.query.tab))
watch(
() => route.query.tab,
(value) => {
activeTab.value = normalizeTab(value)
},
)
const tabs: { id: Tab; label: string }[] = [
{ id: 'ebook', label: 'eBook' },
{ id: 'pdf', label: 'PDF' },
{ id: 'comics', label: 'Comics' },
{ id: 'general', label: 'General' },
]
function selectTab(tab: Tab) {
activeTab.value = tab
const query = { ...route.query }
if (tab === 'general') {
delete query.tab
} else {
query.tab = tab
}
router.replace({ name: 'settings-reader-general', query })
}
</script>
<template>
<SettingsPageHeader title="Reader" subtitle="Configure defaults for all reading modes in one place." />
<div class="flex gap-1 mb-6 border-b border-border overflow-x-auto">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-3 py-2 text-sm font-medium shrink-0 border-b-2 -mb-px transition-colors"
:class="
activeTab === tab.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
"
@click="selectTab(tab.id)"
>
{{ tab.label }}
</button>
</div>
<ReaderSettings v-if="activeTab === 'general'" embedded />
<EbookSettings v-else-if="activeTab === 'ebook'" embedded />
<PdfSettings v-else-if="activeTab === 'pdf'" embedded />
<ComicsSettings v-else-if="activeTab === 'comics'" embedded />
</template>
@@ -6,6 +6,15 @@ import { api } from '@/lib/api'
import { useAuth } from '@/features/auth/composables/useAuth'
import SettingsPageHeader from './SettingsPageHeader.vue'
const props = withDefaults(
defineProps<{
embedded?: boolean
}>(),
{
embedded: false,
},
)
const { user } = useAuth()
const syncEnabled = computed(() => user.value?.settings?.syncReaderPreferences ?? false)
@@ -31,7 +40,7 @@ async function setStorageMode(sync: boolean) {
</script>
<template>
<SettingsPageHeader title="Reading" subtitle="General behavior that applies across all reader types." />
<SettingsPageHeader v-if="!props.embedded" title="Reading" subtitle="General behavior that applies across all reader types." />
<!-- Preference storage -->
<div class="mb-2">
+33 -71
View File
@@ -12,70 +12,41 @@ interface Section {
routeName: string
}
interface Group {
id: string
label: string
sections: Section[]
}
const groups = computed<Group[]>(() => {
const sections = computed<Section[]>(() => {
const perms = userPermissions.value
const su = isSuperuser.value
const generalSections: Section[] = [
const result: Section[] = [
{ label: 'Libraries', routeName: 'settings-libraries' },
{ label: 'Appearance', routeName: 'settings-appearance' },
{ label: 'Reader', routeName: 'settings-reader-general' },
]
if (su || perms.includes('opds_access')) {
generalSections.push({ label: 'OPDS', routeName: 'settings-opds' })
}
if (su || perms.includes('kobo_sync')) {
generalSections.push({ label: 'Kobo', routeName: 'settings-kobo' })
}
if (su || perms.includes('email_send')) {
generalSections.push({ label: 'Email', routeName: 'settings-email' })
}
const result: Group[] = [
{
id: 'general',
label: 'General',
sections: generalSections,
},
{
id: 'reader',
label: 'Reader',
sections: [
{ label: 'General', routeName: 'settings-reader-general' },
{ label: 'eBook', routeName: 'settings-reader-ebook' },
{ label: 'PDF', routeName: 'settings-reader-pdf' },
{ label: 'Comics', routeName: 'settings-reader-comics' },
],
},
]
const adminSections: Section[] = []
if (su || perms.includes('manage_users')) {
adminSections.push({ label: 'Users', routeName: 'settings-admin-users' })
}
if (su || perms.includes('manage_metadata_config')) {
adminSections.push({ label: 'Metadata', routeName: 'settings-admin-metadata' })
result.push({ label: 'Metadata', routeName: 'settings-admin-metadata' })
}
if (su || perms.includes('manage_app_settings')) {
adminSections.push({ label: 'OIDC / SSO', routeName: 'settings-admin-oidc' })
adminSections.push({ label: 'File Naming', routeName: 'settings-admin-file-naming' })
adminSections.push({ label: 'Staging', routeName: 'settings-admin-staging' })
adminSections.push({ label: 'Maintenance', routeName: 'settings-admin-maintenance' })
result.push({ label: 'Operations', routeName: 'settings-admin-maintenance' })
}
if (adminSections.length) {
result.push({ id: 'admin', label: 'Administration', sections: adminSections })
if (su || perms.includes('manage_app_settings')) {
result.push({ label: 'Staging', routeName: 'settings-admin-staging' })
result.push({ label: 'File Naming', routeName: 'settings-admin-file-naming' })
}
result.push({
id: 'system',
label: 'System',
sections: [{ label: 'About', routeName: 'settings-about' }],
})
if (su || perms.includes('manage_users')) {
result.push({ label: 'Users', routeName: 'settings-admin-users' })
}
if (su || perms.includes('manage_app_settings')) {
result.push({ label: 'OIDC / SSO', routeName: 'settings-admin-oidc' })
}
if (su || perms.includes('email_send')) {
result.push({ label: 'Email', routeName: 'settings-email' })
}
if (su || perms.includes('opds_access')) {
result.push({ label: 'OPDS', routeName: 'settings-opds' })
}
if (su || perms.includes('kobo_sync')) {
result.push({ label: 'Kobo', routeName: 'settings-kobo' })
}
result.push({ label: 'About', routeName: 'settings-about' })
return result
})
@@ -83,23 +54,14 @@ const groups = computed<Group[]>(() => {
<template>
<div class="flex items-stretch h-11 px-6 border-b overflow-x-auto shrink-0 scrollbar-none">
<template v-for="group in groups" :key="group.id">
<span
class="flex items-center px-2 text-[10px] font-semibold text-muted-foreground/35 uppercase tracking-widest whitespace-nowrap shrink-0 select-none"
>
{{ group.label }}
</span>
<button
v-for="section in group.sections"
:key="section.routeName"
class="px-3 h-full text-sm font-medium border-b-2 transition-colors whitespace-nowrap shrink-0"
:class="
route.name === section.routeName ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
"
@click="router.push({ name: section.routeName })"
>
{{ section.label }}
</button>
</template>
<button
v-for="section in sections"
:key="section.routeName"
class="px-3 h-full text-sm font-medium border-b-2 transition-colors whitespace-nowrap shrink-0"
:class="route.name === section.routeName ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'"
@click="router.push({ name: section.routeName })"
>
{{ section.label }}
</button>
</div>
</template>
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { StagingAutoFinalizeMetadataMode } from '@projectx/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { api } from '@/lib/api'
@@ -12,6 +13,7 @@ const autoFinalizeEnabled = ref(false)
const autoFinalizeThreshold = ref(85)
const autoFinalizeLibraryId = ref<number | null>(null)
const autoFinalizeFolderId = ref<number | null>(null)
const autoFinalizeMetadataMode = ref<StagingAutoFinalizeMetadataMode>('safe_merge')
const loading = ref(true)
const saving = ref(false)
@@ -19,6 +21,7 @@ const { libraries, fetchLibraries } = useLibraries()
const autoFinalizeLibrary = computed(() => libraries.value.find((l) => l.id === autoFinalizeLibraryId.value))
const autoFinalizeFolders = computed(() => autoFinalizeLibrary.value?.folders ?? [])
const isThresholdApplicable = computed(() => autoFinalizeMetadataMode.value !== 'embedded_only')
onMounted(async () => {
try {
@@ -31,8 +34,10 @@ onMounted(async () => {
autoFinalizeThreshold.value = parseInt(get('staging_auto_finalize_threshold') ?? '85', 10)
const libId = parseInt(get('staging_auto_finalize_library_id') ?? '', 10)
const folderId = parseInt(get('staging_auto_finalize_folder_id') ?? '', 10)
const metadataMode = get('staging_auto_finalize_metadata_mode')
autoFinalizeLibraryId.value = isNaN(libId) ? null : libId
autoFinalizeFolderId.value = isNaN(folderId) ? null : folderId
autoFinalizeMetadataMode.value = metadataMode === 'fetched_only' || metadataMode === 'embedded_only' ? metadataMode : 'safe_merge'
}
} finally {
loading.value = false
@@ -111,9 +116,17 @@ async function onFolderChange(event: Event) {
}
async function onThresholdChange() {
if (!isThresholdApplicable.value) return
await saveSetting('staging_auto_finalize_threshold', String(autoFinalizeThreshold.value))
toast.success('Confidence threshold updated')
}
async function onMetadataModeChange(event: Event) {
const value = (event.target as HTMLSelectElement).value as StagingAutoFinalizeMetadataMode
autoFinalizeMetadataMode.value = value
await saveSetting('staging_auto_finalize_metadata_mode', value)
toast.success('Metadata mode updated')
}
</script>
<template>
@@ -151,7 +164,10 @@ async function onThresholdChange() {
<div v-if="autoFinalizeEnabled" class="px-5 py-4 bg-card space-y-4">
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Confidence threshold: {{ autoFinalizeThreshold }}%</span>
<span class="text-xs font-medium text-muted-foreground">
Confidence threshold: {{ autoFinalizeThreshold }}%
<span v-if="!isThresholdApplicable"> (ignored in Embedded only mode)</span>
</span>
<input
v-model.number="autoFinalizeThreshold"
type="range"
@@ -159,6 +175,7 @@ async function onThresholdChange() {
max="100"
step="5"
class="mt-1 w-full accent-primary"
:disabled="!isThresholdApplicable"
@change="onThresholdChange"
/>
<div class="flex justify-between settings-hint">
@@ -175,6 +192,15 @@ async function onThresholdChange() {
</select>
</label>
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Metadata mode</span>
<select class="select-field mt-1 w-full" :value="autoFinalizeMetadataMode" @change="onMetadataModeChange">
<option value="safe_merge">Safe merge (recommended)</option>
<option value="fetched_only">Fetched only</option>
<option value="embedded_only">Embedded only</option>
</select>
</label>
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Destination folder</span>
<select class="select-field mt-1 w-full" :value="autoFinalizeFolderId ?? ''" @change="onFolderChange">
+4 -4
View File
@@ -49,22 +49,22 @@ const router = createRouter({
{
path: 'reader',
name: 'settings-reader-general',
component: () => import('@/features/settings/ReaderSettings.vue'),
component: () => import('@/features/settings/ReaderAllSettings.vue'),
},
{
path: 'reader/ebook',
name: 'settings-reader-ebook',
component: () => import('@/features/settings/EbookSettings.vue'),
redirect: { name: 'settings-reader-general', query: { tab: 'ebook' } },
},
{
path: 'reader/pdf',
name: 'settings-reader-pdf',
component: () => import('@/features/settings/PdfSettings.vue'),
redirect: { name: 'settings-reader-general', query: { tab: 'pdf' } },
},
{
path: 'reader/comics',
name: 'settings-reader-comics',
component: () => import('@/features/settings/ComicsSettings.vue'),
redirect: { name: 'settings-reader-general', query: { tab: 'comics' } },
},
{
path: 'admin/users',