mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(settings): update reader and library settings with auto-finalize
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user