feat(client): implement route-aware page titles and author deletion

This commit is contained in:
neon
2026-03-19 04:29:06 -06:00
parent 9adf7fc85b
commit 0cf8eff3b4
22 changed files with 635 additions and 288 deletions
+11
View File
@@ -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)
}
}
+26
View File
@@ -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')
})
})
+7
View File
@@ -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
View File
@@ -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
+55
View File
@@ -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}"`)
}
})
}
+7
View File
@@ -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 })
+7
View File
@@ -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>
+6
View File
@@ -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()
+6
View File
@@ -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()
+6
View File
@@ -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