mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(client): implement route-aware page titles and author deletion
This commit is contained in:
Vendored
+11
@@ -1 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import 'vue-router'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
public?: boolean
|
||||
maxWidth?: string
|
||||
title?: string | ((to: RouteLocationNormalizedLoaded) => string)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { onBeforeUnmount, toValue, watch, type MaybeRefOrGetter } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { formatPageTitle } from '@/lib/page-title'
|
||||
import { resolveRouteTitle } from '@/router/title-resolver'
|
||||
|
||||
function setTitle(title: string | null | undefined) {
|
||||
if (typeof document === 'undefined') return
|
||||
document.title = formatPageTitle(title)
|
||||
}
|
||||
|
||||
export function usePageTitle(title: MaybeRefOrGetter<string | null | undefined>) {
|
||||
const route = useRoute()
|
||||
|
||||
const stop = watch(
|
||||
() => toValue(title),
|
||||
(value) => setTitle(value),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stop()
|
||||
if (typeof document !== 'undefined') {
|
||||
document.title = resolveRouteTitle(route)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { AuthorDetail } from '@projectx/types'
|
||||
import { MoreHorizontal, Pencil, RefreshCcw, UsersRound, X } from 'lucide-vue-next'
|
||||
import { MoreHorizontal, Pencil, RefreshCcw, Trash2, UsersRound, X } from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -12,6 +12,7 @@ const props = defineProps<{
|
||||
loadingPreview?: boolean
|
||||
canUpdate?: boolean
|
||||
canMerge?: boolean
|
||||
canDelete?: boolean
|
||||
refreshing?: boolean
|
||||
}>()
|
||||
|
||||
@@ -19,6 +20,7 @@ const emit = defineEmits<{
|
||||
edit: []
|
||||
merge: []
|
||||
refresh: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const initials = computed(() => {
|
||||
@@ -49,7 +51,7 @@ const previewProviderLabel = computed(() => {
|
||||
return props.previewProvider
|
||||
})
|
||||
|
||||
const showMenu = computed(() => props.canUpdate || props.canMerge)
|
||||
const showMenu = computed(() => props.canUpdate || props.canMerge || props.canDelete)
|
||||
|
||||
const imageLightboxOpen = ref(false)
|
||||
const canOpenImageLightbox = computed(() => Boolean(props.imageUrl))
|
||||
@@ -103,6 +105,11 @@ const canOpenImageLightbox = computed(() => Boolean(props.imageUrl))
|
||||
<RefreshCcw class="mr-2 h-4 w-4" :class="refreshing ? 'animate-spin' : ''" />
|
||||
{{ refreshing ? 'Refreshing...' : 'Refresh Metadata' }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator v-if="canDelete && (canUpdate || canMerge)" />
|
||||
<DropdownMenuItem v-if="canDelete" class="text-destructive focus:text-destructive" @click="emit('delete')">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Delete Author
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,10 @@ import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import AuthorHeader from '../components/AuthorHeader.vue'
|
||||
import AuthorConfirmDialog from '../components/AuthorConfirmDialog.vue'
|
||||
import { fetchAuthors, mergeAuthors, refreshAuthorMetadata, updateAuthor } from '../api/author'
|
||||
import { deleteAuthors, fetchAuthors, mergeAuthors, refreshAuthorMetadata, updateAuthor } from '../api/author'
|
||||
import { useAuthorBooks } from '../composables/useAuthorBooks'
|
||||
import { useAuthorDetail } from '../composables/useAuthorDetail'
|
||||
import { useAuthorMetadataPreview } from '../composables/useAuthorMetadataPreview'
|
||||
@@ -28,6 +29,11 @@ const authorId = computed(() => Number(route.params.id))
|
||||
const { author, loading: loadingAuthor, error: authorError, load: loadAuthor } = useAuthorDetail(authorId)
|
||||
const { items: books, total, loading: loadingBooks, error: booksError, hasMore, sort, order, libraryId, load: loadBooks } = useAuthorBooks(authorId)
|
||||
const authorName = computed(() => author.value?.name ?? '')
|
||||
const pageTitle = computed(() => {
|
||||
if (author.value?.name) return `Author · ${author.value.name}`
|
||||
return Number.isFinite(authorId.value) ? `Author #${authorId.value}` : 'Author'
|
||||
})
|
||||
usePageTitle(pageTitle)
|
||||
const {
|
||||
preview: metadataPreview,
|
||||
loading: loadingMetadataPreview,
|
||||
@@ -38,12 +44,15 @@ const {
|
||||
|
||||
const canUpdate = computed(() => hasPermission('library_edit_metadata'))
|
||||
const canMerge = computed(() => isSuperuser.value)
|
||||
const canDelete = computed(() => isSuperuser.value)
|
||||
|
||||
const editOpen = ref(false)
|
||||
const mergeOpen = ref(false)
|
||||
const confirmMergeOpen = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
const savingEdit = ref(false)
|
||||
const merging = ref(false)
|
||||
const deleting = ref(false)
|
||||
const refreshingMetadata = ref(false)
|
||||
|
||||
const draftName = ref('')
|
||||
@@ -206,6 +215,22 @@ async function runMerge() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runDelete() {
|
||||
if (!author.value || deleting.value) return
|
||||
|
||||
confirmDeleteOpen.value = false
|
||||
deleting.value = true
|
||||
try {
|
||||
const result = await deleteAuthors({ authorIds: [author.value.id] })
|
||||
toast.success(`Deleted author; affected ${result.affectedBookCount} books`)
|
||||
await router.push({ name: 'authors' })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete author')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function promptRunMerge() {
|
||||
if (!author.value || merging.value || selectedMergeIds.value.length === 0) return
|
||||
confirmMergeOpen.value = true
|
||||
@@ -317,10 +342,12 @@ watch(authorName, () => {
|
||||
:loading-preview="loadingMetadataPreview"
|
||||
:can-update="canUpdate"
|
||||
:can-merge="canMerge"
|
||||
:can-delete="canDelete"
|
||||
:refreshing="refreshingMetadata"
|
||||
@edit="toggleEdit"
|
||||
@merge="toggleMerge"
|
||||
@refresh="refreshMetadata"
|
||||
@delete="confirmDeleteOpen = true"
|
||||
/>
|
||||
|
||||
<div v-if="metadataPreviewError" class="mt-3 rounded-md border border-border/70 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
@@ -450,6 +477,17 @@ watch(authorName, () => {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<AuthorConfirmDialog
|
||||
:open="confirmDeleteOpen"
|
||||
title="Delete author?"
|
||||
description="This removes the author from the catalog and unlinks it from associated books. This action cannot be undone."
|
||||
confirm-label="Delete"
|
||||
:loading="deleting"
|
||||
destructive
|
||||
@confirm="runDelete"
|
||||
@cancel="confirmDeleteOpen = false"
|
||||
/>
|
||||
|
||||
<AuthorConfirmDialog
|
||||
:open="confirmMergeOpen"
|
||||
:title="mergeDialogTitle"
|
||||
|
||||
@@ -12,31 +12,16 @@ import { useEmailProviders } from '../composables/useEmailProviders'
|
||||
import { useEmailRecipients } from '../composables/useEmailRecipients'
|
||||
import { useEmailTemplates } from '../composables/useEmailTemplates'
|
||||
import { useEmailGroups } from '../composables/useEmailGroups'
|
||||
import { EMAIL_TAB_LABELS, EMAIL_TABS, normalizeEmailTab, type EmailTab as Tab } from '@/features/email/lib/email-tabs'
|
||||
|
||||
const { fetchProviders } = useEmailProviders()
|
||||
const { fetchRecipients } = useEmailRecipients()
|
||||
const { fetchTemplates } = useEmailTemplates()
|
||||
const { fetchGroups } = useEmailGroups()
|
||||
|
||||
type Tab = 'providers' | 'recipients' | 'groups' | 'templates' | 'preferences' | 'history'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function normalizeTab(value: unknown): Tab {
|
||||
if (
|
||||
value === 'providers' ||
|
||||
value === 'recipients' ||
|
||||
value === 'groups' ||
|
||||
value === 'templates' ||
|
||||
value === 'preferences' ||
|
||||
value === 'history'
|
||||
)
|
||||
return value
|
||||
return 'providers'
|
||||
}
|
||||
|
||||
const activeTab = ref<Tab>(normalizeTab(route.query.tab))
|
||||
const activeTab = ref<Tab>(normalizeEmailTab(route.query.tab))
|
||||
|
||||
if (!route.query.tab) {
|
||||
router.replace({ name: 'settings-email', query: { ...route.query, tab: activeTab.value } })
|
||||
@@ -45,7 +30,7 @@ if (!route.query.tab) {
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(value) => {
|
||||
activeTab.value = normalizeTab(value)
|
||||
activeTab.value = normalizeEmailTab(value)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -57,14 +42,7 @@ function selectTab(tab: Tab) {
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'providers', label: 'Providers' },
|
||||
{ id: 'recipients', label: 'Recipients' },
|
||||
{ id: 'groups', label: 'Groups' },
|
||||
{ id: 'templates', label: 'Templates' },
|
||||
{ id: 'preferences', label: 'Preferences' },
|
||||
{ id: 'history', label: 'History' },
|
||||
]
|
||||
const tabs: { id: Tab; label: string }[] = EMAIL_TABS.map((id) => ({ id, label: EMAIL_TAB_LABELS[id] }))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const EMAIL_TABS = ['providers', 'recipients', 'groups', 'templates', 'preferences', 'history'] as const
|
||||
|
||||
export type EmailTab = (typeof EMAIL_TABS)[number]
|
||||
|
||||
export const EMAIL_TAB_LABELS: Record<EmailTab, string> = {
|
||||
providers: 'Providers',
|
||||
recipients: 'Recipients',
|
||||
groups: 'Groups',
|
||||
templates: 'Templates',
|
||||
preferences: 'Preferences',
|
||||
history: 'History',
|
||||
}
|
||||
|
||||
export function normalizeEmailTab(value: unknown): EmailTab {
|
||||
if (typeof value === 'string' && EMAIL_TABS.includes(value as EmailTab)) {
|
||||
return value as EmailTab
|
||||
}
|
||||
return 'providers'
|
||||
}
|
||||
@@ -7,18 +7,12 @@ import BookMetadataFetchSettings from './metadata-auto-fetch/BookMetadataFetchSe
|
||||
import AuthorEnrichmentSettings from './AuthorEnrichmentSettings.vue'
|
||||
import MetadataFileSyncSettings from './MetadataFileSyncSettings.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
|
||||
type Tab = 'providers' | 'score' | 'auto-fetch' | 'authors' | 'file-sync'
|
||||
import { METADATA_TAB_INFO, METADATA_TABS, normalizeMetadataTab, type MetadataTab as Tab } from './lib/metadata-tabs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function normalizeTab(value: unknown): Tab {
|
||||
if (value === 'providers' || value === 'score' || value === 'auto-fetch' || value === 'authors' || value === 'file-sync') return value
|
||||
return 'providers'
|
||||
}
|
||||
|
||||
const activeTab = ref<Tab>(normalizeTab(route.query.tab))
|
||||
const activeTab = ref<Tab>(normalizeMetadataTab(route.query.tab))
|
||||
|
||||
if (!route.query.tab) {
|
||||
router.replace({ name: 'settings-admin-metadata', query: { ...route.query, tab: activeTab.value } })
|
||||
@@ -27,31 +21,18 @@ if (!route.query.tab) {
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(value) => {
|
||||
activeTab.value = normalizeTab(value)
|
||||
activeTab.value = normalizeMetadataTab(value)
|
||||
},
|
||||
)
|
||||
|
||||
const tabs: { id: Tab; label: string; subtitle: string }[] = [
|
||||
{
|
||||
id: 'providers',
|
||||
label: 'Source & Priority',
|
||||
subtitle: 'Define how book information is collected from external services and prioritized across your libraries.',
|
||||
},
|
||||
{ id: 'score', label: 'Confidence Score', subtitle: 'Assign weights to metadata fields to calculate how much to trust fetched results.' },
|
||||
{
|
||||
id: 'auto-fetch',
|
||||
label: 'Book Auto-Fetch',
|
||||
subtitle: 'Automatically fetch covers, descriptions, and other details when new books are added to your library.',
|
||||
},
|
||||
{
|
||||
id: 'authors',
|
||||
label: 'Author Auto-Fetch',
|
||||
subtitle: 'Automatically fetch biographies and profile photos when new authors appear in your library.',
|
||||
},
|
||||
{ id: 'file-sync', label: 'File Write-Back', subtitle: 'Configure which metadata fields are saved directly into your book files on disk.' },
|
||||
]
|
||||
const tabs = METADATA_TABS.map((id) => ({
|
||||
id,
|
||||
navLabel: METADATA_TAB_INFO[id].navLabel,
|
||||
titleLabel: METADATA_TAB_INFO[id].titleLabel,
|
||||
subtitle: METADATA_TAB_INFO[id].subtitle,
|
||||
}))
|
||||
|
||||
const activeTabInfo = computed(() => tabs.find((t) => t.id === activeTab.value)!)
|
||||
const activeTabInfo = computed(() => METADATA_TAB_INFO[activeTab.value])
|
||||
|
||||
const tabWidths: Record<Tab, string> = {
|
||||
providers: 'max-w-5xl',
|
||||
@@ -69,7 +50,7 @@ function selectTab(tab: Tab) {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SettingsPageHeader :title="activeTabInfo.label" :subtitle="activeTabInfo.subtitle" />
|
||||
<SettingsPageHeader :title="activeTabInfo.titleLabel" :subtitle="activeTabInfo.subtitle" />
|
||||
|
||||
<div class="flex gap-1 mb-6 border-b border-border overflow-x-auto">
|
||||
<button
|
||||
@@ -83,17 +64,7 @@ function selectTab(tab: Tab) {
|
||||
"
|
||||
@click="selectTab(tab.id)"
|
||||
>
|
||||
{{
|
||||
tab.id === 'providers'
|
||||
? 'Providers'
|
||||
: tab.id === 'score'
|
||||
? 'Score'
|
||||
: tab.id === 'auto-fetch'
|
||||
? 'Books'
|
||||
: tab.id === 'authors'
|
||||
? 'Authors'
|
||||
: 'File Sync'
|
||||
}}
|
||||
{{ tab.navLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,18 +6,12 @@ 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'
|
||||
import { READER_TAB_LABELS, READER_TABS, normalizeReaderTab, type ReaderTab as Tab } from './lib/reader-tabs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function normalizeTab(value: unknown): Tab {
|
||||
if (value === 'general' || value === 'ebook' || value === 'pdf' || value === 'comics') return value
|
||||
return 'ebook'
|
||||
}
|
||||
|
||||
const activeTab = ref<Tab>(normalizeTab(route.query.tab))
|
||||
const activeTab = ref<Tab>(normalizeReaderTab(route.query.tab))
|
||||
|
||||
if (!route.query.tab) {
|
||||
router.replace({ name: 'settings-reader-general', query: { ...route.query, tab: activeTab.value } })
|
||||
@@ -26,16 +20,13 @@ if (!route.query.tab) {
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(value) => {
|
||||
activeTab.value = normalizeTab(value)
|
||||
activeTab.value = normalizeReaderTab(value)
|
||||
},
|
||||
)
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'ebook', label: 'eBook' },
|
||||
{ id: 'pdf', label: 'PDF' },
|
||||
{ id: 'comics', label: 'Comics' },
|
||||
{ id: 'general', label: 'General' },
|
||||
]
|
||||
const tabs: { id: Tab; label: string }[] = READER_TABS.slice()
|
||||
.sort((a, b) => (a === 'general' ? 1 : b === 'general' ? -1 : 0))
|
||||
.map((id) => ({ id, label: READER_TAB_LABELS[id] }))
|
||||
|
||||
function selectTab(tab: Tab) {
|
||||
activeTab.value = tab
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export const METADATA_TABS = ['providers', 'score', 'auto-fetch', 'authors', 'file-sync'] as const
|
||||
|
||||
export type MetadataTab = (typeof METADATA_TABS)[number]
|
||||
|
||||
type MetadataTabInfo = {
|
||||
navLabel: string
|
||||
titleLabel: string
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
export const METADATA_TAB_INFO: Record<MetadataTab, MetadataTabInfo> = {
|
||||
providers: {
|
||||
navLabel: 'Providers',
|
||||
titleLabel: 'Source & Priority',
|
||||
subtitle: 'Define how book information is collected from external services and prioritized across your libraries.',
|
||||
},
|
||||
score: {
|
||||
navLabel: 'Score',
|
||||
titleLabel: 'Confidence Score',
|
||||
subtitle: 'Assign weights to metadata fields to calculate how much to trust fetched results.',
|
||||
},
|
||||
'auto-fetch': {
|
||||
navLabel: 'Books',
|
||||
titleLabel: 'Book Auto-Fetch',
|
||||
subtitle: 'Automatically fetch covers, descriptions, and other details when new books are added to your library.',
|
||||
},
|
||||
authors: {
|
||||
navLabel: 'Authors',
|
||||
titleLabel: 'Author Auto-Fetch',
|
||||
subtitle: 'Automatically fetch biographies and profile photos when new authors appear in your library.',
|
||||
},
|
||||
'file-sync': {
|
||||
navLabel: 'File Sync',
|
||||
titleLabel: 'File Write-Back',
|
||||
subtitle: 'Configure which metadata fields are saved directly into your book files on disk.',
|
||||
},
|
||||
}
|
||||
|
||||
export function normalizeMetadataTab(value: unknown): MetadataTab {
|
||||
if (typeof value === 'string' && METADATA_TABS.includes(value as MetadataTab)) {
|
||||
return value as MetadataTab
|
||||
}
|
||||
return 'providers'
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const READER_TABS = ['general', 'ebook', 'pdf', 'comics'] as const
|
||||
|
||||
export type ReaderTab = (typeof READER_TABS)[number]
|
||||
|
||||
export const READER_TAB_LABELS: Record<ReaderTab, string> = {
|
||||
general: 'General',
|
||||
ebook: 'eBook',
|
||||
pdf: 'PDF',
|
||||
comics: 'Comics',
|
||||
}
|
||||
|
||||
export const READER_TAB_TITLE_LABELS: Record<ReaderTab, string> = {
|
||||
general: 'Reader Settings',
|
||||
ebook: 'eBook Reader',
|
||||
pdf: 'PDF Reader',
|
||||
comics: 'Comics Reader',
|
||||
}
|
||||
|
||||
export function normalizeReaderTab(value: unknown): ReaderTab {
|
||||
if (typeof value === 'string' && READER_TABS.includes(value as ReaderTab)) {
|
||||
return value as ReaderTab
|
||||
}
|
||||
return 'ebook'
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_TITLE, formatPageTitle } from '@/lib/page-title'
|
||||
|
||||
describe('formatPageTitle', () => {
|
||||
it('returns app title when leaf title is empty', () => {
|
||||
expect(formatPageTitle('')).toBe(APP_TITLE)
|
||||
expect(formatPageTitle(' ')).toBe(APP_TITLE)
|
||||
expect(formatPageTitle(null)).toBe(APP_TITLE)
|
||||
expect(formatPageTitle(undefined)).toBe(APP_TITLE)
|
||||
})
|
||||
|
||||
it('formats non-empty leaf title', () => {
|
||||
expect(formatPageTitle('Authors')).toBe('Authors · Projectx')
|
||||
expect(formatPageTitle(' Dashboard ')).toBe('Dashboard · Projectx')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export const APP_TITLE = 'Projectx'
|
||||
|
||||
export function formatPageTitle(leaf: string | null | undefined): string {
|
||||
const trimmed = (leaf ?? '').trim()
|
||||
if (!trimmed) return APP_TITLE
|
||||
return `${trimmed} · ${APP_TITLE}`
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { routes } from '@/router'
|
||||
|
||||
type MissingRoute = {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
function walkRoutes(records: RouteRecordRaw[], visit: (route: RouteRecordRaw, fullPath: string) => void, parentPath = '') {
|
||||
for (const record of records) {
|
||||
const segment = record.path ?? ''
|
||||
const fullPath = segment.startsWith('/') ? segment : `${parentPath.replace(/\/$/, '')}/${segment}`.replace(/\/+/g, '/')
|
||||
visit(record, fullPath || '/')
|
||||
if (record.children?.length) {
|
||||
walkRoutes(record.children, visit, fullPath || '/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('router title metadata', () => {
|
||||
it('requires meta.title on all named non-redirect routes', () => {
|
||||
const missing: MissingRoute[] = []
|
||||
|
||||
walkRoutes(routes, (route, fullPath) => {
|
||||
if (!route.name || route.redirect) return
|
||||
const title = route.meta?.title
|
||||
const hasTitle = typeof title === 'string' || typeof title === 'function'
|
||||
if (!hasTitle) {
|
||||
missing.push({ name: String(route.name), path: fullPath })
|
||||
}
|
||||
})
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RouteLocationMatched, RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { resolveRouteTitle } from '@/router/title-resolver'
|
||||
|
||||
function routeWithMeta(title: string | ((route: RouteLocationNormalizedLoaded) => string) | undefined): RouteLocationNormalizedLoaded {
|
||||
return {
|
||||
fullPath: '/test',
|
||||
name: 'test',
|
||||
matched: [{ meta: { title } } as RouteLocationMatched],
|
||||
} as RouteLocationNormalizedLoaded
|
||||
}
|
||||
|
||||
describe('resolveRouteTitle', () => {
|
||||
it('uses string route title', () => {
|
||||
expect(resolveRouteTitle(routeWithMeta('Authors'))).toBe('Authors · Projectx')
|
||||
})
|
||||
|
||||
it('uses function route title', () => {
|
||||
const route = routeWithMeta(() => 'Audit Log')
|
||||
expect(resolveRouteTitle(route)).toBe('Audit Log · Projectx')
|
||||
})
|
||||
|
||||
it('falls back to app title when route title is missing', () => {
|
||||
expect(resolveRouteTitle(routeWithMeta(undefined))).toBe('Projectx')
|
||||
})
|
||||
})
|
||||
+262
-203
@@ -1,212 +1,271 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded, type RouteRecordRaw } from 'vue-router'
|
||||
import { EMAIL_TAB_LABELS, normalizeEmailTab } from '@/features/email/lib/email-tabs'
|
||||
import { METADATA_TAB_INFO, normalizeMetadataTab } from '@/features/settings/lib/metadata-tabs'
|
||||
import { READER_TAB_TITLE_LABELS, normalizeReaderTab } from '@/features/settings/lib/reader-tabs'
|
||||
import { registerAuthGuard } from './guards/auth.guard'
|
||||
import { registerRouteTitleHook } from './title-resolver'
|
||||
|
||||
function firstText(value: unknown): string | null {
|
||||
if (Array.isArray(value)) return firstText(value[0])
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function numericParam(to: RouteLocationNormalizedLoaded, key: string): number | null {
|
||||
const value = firstText(to.params[key])
|
||||
if (!value) return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function fallbackById(prefix: string, id: number | null): string {
|
||||
return id === null ? prefix : `${prefix} #${id}`
|
||||
}
|
||||
|
||||
function resolveReaderTitle(to: RouteLocationNormalizedLoaded): string {
|
||||
const tab = normalizeReaderTab(to.query.tab)
|
||||
return READER_TAB_TITLE_LABELS[tab]
|
||||
}
|
||||
|
||||
function resolveEmailTitle(to: RouteLocationNormalizedLoaded): string {
|
||||
const tab = normalizeEmailTab(to.query.tab)
|
||||
return `${EMAIL_TAB_LABELS[tab]} · Email`
|
||||
}
|
||||
|
||||
function resolveMetadataTitle(to: RouteLocationNormalizedLoaded): string {
|
||||
const tab = normalizeMetadataTab(to.query.tab)
|
||||
return METADATA_TAB_INFO[tab].titleLabel
|
||||
}
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/components/AppLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/DashboardView.vue'),
|
||||
meta: { title: 'Dashboard' },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: { name: 'settings-libraries' } },
|
||||
{
|
||||
path: 'libraries',
|
||||
name: 'settings-libraries',
|
||||
component: () => import('@/features/settings/LibrariesSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-3xl', title: 'Libraries' },
|
||||
},
|
||||
{
|
||||
path: 'appearance',
|
||||
name: 'settings-appearance',
|
||||
component: () => import('@/features/settings/AppearanceSettings.vue'),
|
||||
meta: { title: 'Appearance' },
|
||||
},
|
||||
{
|
||||
path: 'opds',
|
||||
name: 'settings-opds',
|
||||
component: () => import('@/features/settings/OpdsSettings.vue'),
|
||||
meta: { title: 'OPDS' },
|
||||
},
|
||||
{
|
||||
path: 'kobo',
|
||||
name: 'settings-kobo',
|
||||
component: () => import('@/features/settings/KoboSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-3xl', title: 'Kobo Sync' },
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'settings-email',
|
||||
component: () => import('@/features/email/components/EmailSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-3xl', title: resolveEmailTitle },
|
||||
},
|
||||
{
|
||||
path: 'reader',
|
||||
name: 'settings-reader-general',
|
||||
component: () => import('@/features/settings/ReaderAllSettings.vue'),
|
||||
meta: { title: resolveReaderTitle },
|
||||
},
|
||||
{
|
||||
path: 'reader/ebook',
|
||||
name: 'settings-reader-ebook',
|
||||
redirect: { name: 'settings-reader-general', query: { tab: 'ebook' } },
|
||||
},
|
||||
{
|
||||
path: 'reader/pdf',
|
||||
name: 'settings-reader-pdf',
|
||||
redirect: { name: 'settings-reader-general', query: { tab: 'pdf' } },
|
||||
},
|
||||
{
|
||||
path: 'reader/comics',
|
||||
name: 'settings-reader-comics',
|
||||
redirect: { name: 'settings-reader-general', query: { tab: 'comics' } },
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
name: 'settings-admin-users',
|
||||
component: () => import('@/features/admin/UsersPage.vue'),
|
||||
meta: { maxWidth: 'max-w-4xl', title: 'Users' },
|
||||
},
|
||||
{
|
||||
path: 'admin/metadata',
|
||||
name: 'settings-admin-metadata',
|
||||
component: () => import('@/features/settings/MetadataAllSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-7xl', title: resolveMetadataTitle },
|
||||
},
|
||||
{
|
||||
path: 'admin/metadata-auto-fetch',
|
||||
name: 'settings-admin-metadata-auto-fetch',
|
||||
redirect: { name: 'settings-admin-metadata', query: { tab: 'auto-fetch' } },
|
||||
},
|
||||
{
|
||||
path: 'admin/oidc',
|
||||
name: 'settings-admin-oidc',
|
||||
component: () => import('@/features/settings/OidcSettings.vue'),
|
||||
meta: { title: 'OIDC / SSO' },
|
||||
},
|
||||
{
|
||||
path: 'admin/file-naming',
|
||||
name: 'settings-admin-file-naming',
|
||||
component: () => import('@/features/settings/FileNamingSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-6xl', title: 'File Naming' },
|
||||
},
|
||||
{
|
||||
path: 'admin/staging',
|
||||
name: 'settings-admin-staging',
|
||||
component: () => import('@/features/settings/StagingSettings.vue'),
|
||||
meta: { title: 'Staging Settings' },
|
||||
},
|
||||
{
|
||||
path: 'admin/maintenance',
|
||||
name: 'settings-admin-maintenance',
|
||||
component: () => import('@/features/settings/MaintenanceSettings.vue'),
|
||||
meta: { title: 'Maintenance' },
|
||||
},
|
||||
{
|
||||
path: 'admin/audit-log',
|
||||
name: 'settings-admin-audit-log',
|
||||
component: () => import('@/features/audit/AuditLogPage.vue'),
|
||||
meta: { maxWidth: 'max-w-7xl', title: 'Audit Log' },
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
name: 'settings-about',
|
||||
component: () => import('@/features/settings/AboutSettings.vue'),
|
||||
meta: { title: 'About' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/staging',
|
||||
name: 'staging',
|
||||
component: () => import('@/views/StagingView.vue'),
|
||||
meta: { title: 'Staging' },
|
||||
},
|
||||
{
|
||||
path: '/statistics',
|
||||
name: 'statistics',
|
||||
component: () => import('@/features/statistics/components/StatisticsPage.vue'),
|
||||
meta: { title: 'Statistics' },
|
||||
},
|
||||
{
|
||||
path: '/library/:id',
|
||||
name: 'library',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
meta: { title: (to) => fallbackById('Library', numericParam(to, 'id')) },
|
||||
},
|
||||
{
|
||||
path: '/lens/:id',
|
||||
name: 'lens',
|
||||
component: () => import('@/views/LensView.vue'),
|
||||
meta: { title: (to) => fallbackById('Lens', numericParam(to, 'id')) },
|
||||
},
|
||||
{
|
||||
path: '/collection/:id',
|
||||
name: 'collection',
|
||||
component: () => import('@/views/CollectionView.vue'),
|
||||
meta: { title: (to) => fallbackById('Collection', numericParam(to, 'id')) },
|
||||
},
|
||||
{
|
||||
path: '/authors',
|
||||
name: 'authors',
|
||||
component: () => import('@/features/author/views/AuthorsView.vue'),
|
||||
meta: { title: 'Authors' },
|
||||
},
|
||||
{
|
||||
path: '/authors/:id',
|
||||
name: 'author-detail',
|
||||
component: () => import('@/features/author/views/AuthorDetailView.vue'),
|
||||
meta: { title: (to) => fallbackById('Author', numericParam(to, 'id')) },
|
||||
},
|
||||
{
|
||||
path: '/book/:bookId',
|
||||
name: 'book-detail',
|
||||
component: () => import('@/views/BookDetailView.vue'),
|
||||
meta: { title: (to) => fallbackById('Book', numericParam(to, 'bookId')) },
|
||||
},
|
||||
{
|
||||
path: '/book/:bookId/files',
|
||||
name: 'book-files',
|
||||
component: () => import('@/views/BookFilesView.vue'),
|
||||
meta: { title: (to) => `Files · ${fallbackById('Book', numericParam(to, 'bookId'))}` },
|
||||
},
|
||||
{
|
||||
path: '/book/:bookId/edit',
|
||||
name: 'book-edit',
|
||||
component: () => import('@/views/BookEditMetadataView.vue'),
|
||||
meta: { title: (to) => `Edit Metadata · ${fallbackById('Book', numericParam(to, 'bookId'))}` },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/read/:bookId/:fileId',
|
||||
name: 'reader',
|
||||
component: () => import('@/features/reader/ReaderView.vue'),
|
||||
meta: { title: (to) => `Read · ${fallbackById('Book', numericParam(to, 'bookId'))}` },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/features/auth/LoginPage.vue'),
|
||||
meta: { public: true, title: 'Sign In' },
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'setup',
|
||||
component: () => import('@/features/auth/SetupPage.vue'),
|
||||
meta: { public: true, title: 'Initial Setup' },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('@/features/auth/ForgotPasswordPage.vue'),
|
||||
meta: { public: true, title: 'Forgot Password' },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: () => import('@/features/auth/ResetPasswordPage.vue'),
|
||||
meta: { public: true, title: 'Reset Password' },
|
||||
},
|
||||
{
|
||||
path: '/oauth2-callback',
|
||||
name: 'oidc-callback',
|
||||
component: () => import('@/features/auth/OidcCallbackPage.vue'),
|
||||
meta: { public: true, title: 'Completing Sign In' },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/components/AppLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/DashboardView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: { name: 'settings-libraries' } },
|
||||
{
|
||||
path: 'libraries',
|
||||
name: 'settings-libraries',
|
||||
component: () => import('@/features/settings/LibrariesSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-3xl' },
|
||||
},
|
||||
{
|
||||
path: 'appearance',
|
||||
name: 'settings-appearance',
|
||||
component: () => import('@/features/settings/AppearanceSettings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'opds',
|
||||
name: 'settings-opds',
|
||||
component: () => import('@/features/settings/OpdsSettings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'kobo',
|
||||
name: 'settings-kobo',
|
||||
component: () => import('@/features/settings/KoboSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-3xl' },
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'settings-email',
|
||||
component: () => import('@/features/email/components/EmailSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-3xl' },
|
||||
},
|
||||
{
|
||||
path: 'reader',
|
||||
name: 'settings-reader-general',
|
||||
component: () => import('@/features/settings/ReaderAllSettings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'reader/ebook',
|
||||
name: 'settings-reader-ebook',
|
||||
redirect: { name: 'settings-reader-general', query: { tab: 'ebook' } },
|
||||
},
|
||||
{
|
||||
path: 'reader/pdf',
|
||||
name: 'settings-reader-pdf',
|
||||
redirect: { name: 'settings-reader-general', query: { tab: 'pdf' } },
|
||||
},
|
||||
{
|
||||
path: 'reader/comics',
|
||||
name: 'settings-reader-comics',
|
||||
redirect: { name: 'settings-reader-general', query: { tab: 'comics' } },
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
name: 'settings-admin-users',
|
||||
component: () => import('@/features/admin/UsersPage.vue'),
|
||||
meta: { maxWidth: 'max-w-4xl' },
|
||||
},
|
||||
{
|
||||
path: 'admin/metadata',
|
||||
name: 'settings-admin-metadata',
|
||||
component: () => import('@/features/settings/MetadataAllSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-7xl' },
|
||||
},
|
||||
{
|
||||
path: 'admin/metadata-auto-fetch',
|
||||
name: 'settings-admin-metadata-auto-fetch',
|
||||
redirect: { name: 'settings-admin-metadata', query: { tab: 'auto-fetch' } },
|
||||
},
|
||||
{
|
||||
path: 'admin/oidc',
|
||||
name: 'settings-admin-oidc',
|
||||
component: () => import('@/features/settings/OidcSettings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'admin/file-naming',
|
||||
name: 'settings-admin-file-naming',
|
||||
component: () => import('@/features/settings/FileNamingSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-6xl' },
|
||||
},
|
||||
{
|
||||
path: 'admin/staging',
|
||||
name: 'settings-admin-staging',
|
||||
component: () => import('@/features/settings/StagingSettings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'admin/maintenance',
|
||||
name: 'settings-admin-maintenance',
|
||||
component: () => import('@/features/settings/MaintenanceSettings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'admin/audit-log',
|
||||
name: 'settings-admin-audit-log',
|
||||
component: () => import('@/features/audit/AuditLogPage.vue'),
|
||||
meta: { maxWidth: 'max-w-7xl' },
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
name: 'settings-about',
|
||||
component: () => import('@/features/settings/AboutSettings.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/staging',
|
||||
name: 'staging',
|
||||
component: () => import('@/views/StagingView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/statistics',
|
||||
name: 'statistics',
|
||||
component: () => import('@/features/statistics/components/StatisticsPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/library/:id',
|
||||
name: 'library',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/lens/:id',
|
||||
name: 'lens',
|
||||
component: () => import('@/views/LensView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/collection/:id',
|
||||
name: 'collection',
|
||||
component: () => import('@/views/CollectionView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/authors',
|
||||
name: 'authors',
|
||||
component: () => import('@/features/author/views/AuthorsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/authors/:id',
|
||||
name: 'author-detail',
|
||||
component: () => import('@/features/author/views/AuthorDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/book/:bookId',
|
||||
name: 'book-detail',
|
||||
component: () => import('@/views/BookDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/book/:bookId/files',
|
||||
name: 'book-files',
|
||||
component: () => import('@/views/BookFilesView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/book/:bookId/edit',
|
||||
name: 'book-edit',
|
||||
component: () => import('@/views/BookEditMetadataView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/read/:bookId/:fileId',
|
||||
name: 'reader',
|
||||
component: () => import('@/features/reader/ReaderView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/features/auth/LoginPage.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'setup',
|
||||
component: () => import('@/features/auth/SetupPage.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('@/features/auth/ForgotPasswordPage.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: () => import('@/features/auth/ResetPasswordPage.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/oauth2-callback',
|
||||
name: 'oidc-callback',
|
||||
component: () => import('@/features/auth/OidcCallbackPage.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
],
|
||||
routes,
|
||||
})
|
||||
|
||||
registerAuthGuard(router)
|
||||
registerRouteTitleHook(router)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||
import { formatPageTitle } from '@/lib/page-title'
|
||||
|
||||
function firstText(value: unknown): string | null {
|
||||
if (Array.isArray(value)) return firstText(value[0])
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function resolveMetaTitle(route: RouteLocationNormalizedLoaded): string | null {
|
||||
for (let idx = route.matched.length - 1; idx >= 0; idx -= 1) {
|
||||
const record = route.matched[idx]
|
||||
if (!record) continue
|
||||
const entry = record.meta.title
|
||||
if (typeof entry === 'function') {
|
||||
let resolved: string | null = null
|
||||
try {
|
||||
resolved = firstText(entry(route))
|
||||
} catch {
|
||||
resolved = null
|
||||
}
|
||||
if (resolved) return resolved
|
||||
continue
|
||||
}
|
||||
if (typeof entry === 'string') {
|
||||
const resolved = firstText(entry)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function needsTitleWarning(route: RouteLocationNormalizedLoaded): boolean {
|
||||
if (!route.name) return false
|
||||
const leaf = route.matched[route.matched.length - 1]
|
||||
if (!leaf || leaf.redirect) return false
|
||||
return !resolveMetaTitle(route)
|
||||
}
|
||||
|
||||
export function resolveRouteTitle(route: RouteLocationNormalizedLoaded): string {
|
||||
return formatPageTitle(resolveMetaTitle(route))
|
||||
}
|
||||
|
||||
export function registerRouteTitleHook(router: Router): void {
|
||||
router.afterEach((to) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.title = resolveRouteTitle(to)
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV && needsTitleWarning(to)) {
|
||||
console.warn(`[router:title] Missing meta.title for route "${String(to.name)}" at "${to.fullPath}"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,12 +6,19 @@ import DetailsTab from '@/features/book/components/detail/tabs/DetailsTab.vue'
|
||||
import { useBookDetail } from '@/features/book/composables/useBookDetail'
|
||||
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
||||
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const bookId = computed(() => Number(route.params.bookId))
|
||||
|
||||
const { detail, loading, fetch } = useBookDetail()
|
||||
const pageTitle = computed(() => {
|
||||
const title = detail.value?.title?.trim()
|
||||
if (title) return `Book · ${title}`
|
||||
return Number.isFinite(bookId.value) ? `Book #${bookId.value}` : 'Book'
|
||||
})
|
||||
usePageTitle(pageTitle)
|
||||
|
||||
const { subscribeLibrary } = useScanProgress()
|
||||
watch(
|
||||
|
||||
@@ -5,12 +5,19 @@ import type { BookDetail } from '@projectx/types'
|
||||
import BookDetailLayout from '@/features/book/components/detail/BookDetailLayout.vue'
|
||||
import EditMetadataTab from '@/features/book/components/detail/tabs/EditMetadataTab.vue'
|
||||
import { useBookDetail } from '@/features/book/composables/useBookDetail'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const bookId = computed(() => Number(route.params.bookId))
|
||||
|
||||
const { detail, loading, fetch } = useBookDetail()
|
||||
const pageTitle = computed(() => {
|
||||
const title = detail.value?.title?.trim()
|
||||
const base = title || (Number.isFinite(bookId.value) ? `Book #${bookId.value}` : 'Book')
|
||||
return `Edit · ${base}`
|
||||
})
|
||||
usePageTitle(pageTitle)
|
||||
|
||||
watch(bookId, (id) => fetch(id), { immediate: true })
|
||||
|
||||
|
||||
@@ -4,12 +4,19 @@ import { useRoute } from 'vue-router'
|
||||
import BookDetailLayout from '@/features/book/components/detail/BookDetailLayout.vue'
|
||||
import FilesTab from '@/features/book/components/detail/tabs/FilesTab.vue'
|
||||
import { useBookDetail } from '@/features/book/composables/useBookDetail'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const bookId = computed(() => Number(route.params.bookId))
|
||||
|
||||
const { detail, loading, fetch } = useBookDetail()
|
||||
const pageTitle = computed(() => {
|
||||
const title = detail.value?.title?.trim()
|
||||
const base = title || (Number.isFinite(bookId.value) ? `Book #${bookId.value}` : 'Book')
|
||||
return `Files · ${base}`
|
||||
})
|
||||
usePageTitle(pageTitle)
|
||||
|
||||
watch(bookId, (id) => fetch(id), { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useBookSelection } from '@/features/book/composables/useBookSelection'
|
||||
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
||||
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -29,6 +30,11 @@ const { coverSize, gridGap, viewMode } = useDisplaySettings()
|
||||
const collectionId = computed(() => Number(route.params.id))
|
||||
const { collections, fetchCollections, removeBooksFromCollection } = useCollections()
|
||||
const collection = computed(() => collections.value.find((c) => c.id === collectionId.value))
|
||||
const pageTitle = computed(() => {
|
||||
if (collection.value?.name) return `Collection · ${collection.value.name}`
|
||||
return Number.isFinite(collectionId.value) ? `Collection #${collectionId.value}` : 'Collection'
|
||||
})
|
||||
usePageTitle(pageTitle)
|
||||
|
||||
const { items: books, total, loading, hasMore, load } = useCollectionBooks(collectionId)
|
||||
const { setBookContext, registerLoadMore } = useBookNavigation()
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
|
||||
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
|
||||
import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import type { GroupRule, SortSpec } from '@projectx/types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -39,6 +40,11 @@ const libraryId = shallowRef<number | null>(route.params.id ? Number(route.param
|
||||
const currentLibrary = computed(() => libraries.value.find((l) => l.id === libraryId.value))
|
||||
const title = computed(() => currentLibrary.value?.name ?? 'Library')
|
||||
const libraryIcon = computed(() => currentLibrary.value?.icon ?? 'BookOpen')
|
||||
const pageTitle = computed(() => {
|
||||
if (currentLibrary.value?.name) return `Library · ${currentLibrary.value.name}`
|
||||
return libraryId.value === null ? 'Library' : `Library #${libraryId.value}`
|
||||
})
|
||||
usePageTitle(pageTitle)
|
||||
|
||||
const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
|
||||
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
||||
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
|
||||
import FilterSummary from '@/features/book/components/FilterSummary.vue'
|
||||
import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import type { BookCard, GroupRule, SortField } from '@projectx/types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -52,6 +53,11 @@ onUnmounted(() => {
|
||||
const { lenses, fetchLenses, deleteLens } = useLenses()
|
||||
|
||||
const lens = computed(() => lenses.value.find((l) => l.id === lensId.value))
|
||||
const pageTitle = computed(() => {
|
||||
if (lens.value?.name) return `Lens · ${lens.value.name}`
|
||||
return Number.isFinite(lensId.value) ? `Lens #${lensId.value}` : 'Lens'
|
||||
})
|
||||
usePageTitle(pageTitle)
|
||||
|
||||
const sortChip = computed(() => {
|
||||
const specs = lens.value?.defaultSort
|
||||
|
||||
Reference in New Issue
Block a user