feat: add inline not-found states for all entity views and 404 routing

This commit is contained in:
neon
2026-03-29 13:34:09 -06:00
parent 91842fe212
commit ec25a62ebe
18 changed files with 444 additions and 413 deletions
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { SearchX } from 'lucide-vue-next'
defineProps<{ entity: string }>()
const router = useRouter()
function goBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
</script>
<template>
<div class="flex flex-col items-center justify-center min-h-[50vh] gap-4 text-center px-4">
<div class="flex items-center justify-center w-16 h-16 rounded-full bg-muted text-muted-foreground">
<SearchX :size="28" />
</div>
<div class="space-y-1">
<h2 class="text-lg font-semibold text-foreground">{{ entity }} not found</h2>
<p class="text-sm text-muted-foreground">This {{ entity.toLowerCase() }} doesn't exist or has been deleted.</p>
</div>
<button
class="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-border bg-background text-sm font-medium text-foreground hover:bg-muted transition-colors mt-1"
@click="goBack"
>
Go back
</button>
</div>
</template>
+2 -1
View File
@@ -111,8 +111,9 @@ export async function fetchAuthors(params: ListAuthorsParams): Promise<AuthorsPa
return res.json()
}
export async function fetchAuthor(id: number): Promise<AuthorDetail> {
export async function fetchAuthor(id: number): Promise<AuthorDetail | null> {
const res = await api(`/api/v1/authors/${id}`)
if (res.status === 404) return null
if (!res.ok) throw new Error('Failed to load author')
return res.json()
}
@@ -7,6 +7,7 @@ export function useAuthorDetail(authorId: Ref<number>) {
const author = ref<AuthorDetail | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const notFound = ref(false)
async function load() {
if (!authorId.value || Number.isNaN(authorId.value)) {
@@ -16,9 +17,16 @@ export function useAuthorDetail(authorId: Ref<number>) {
loading.value = true
error.value = null
notFound.value = false
try {
author.value = await fetchAuthor(authorId.value)
const result = await fetchAuthor(authorId.value)
if (result === null) {
notFound.value = true
author.value = null
} else {
author.value = result
}
} catch (err) {
author.value = null
error.value = err instanceof Error ? err.message : 'Failed to load author'
@@ -27,5 +35,5 @@ export function useAuthorDetail(authorId: Ref<number>) {
}
}
return { author, loading, error, load }
return { author, loading, error, notFound, load }
}
@@ -17,6 +17,7 @@ import { deleteAuthors, fetchAuthors, mergeAuthors, refreshAuthorMetadata, updat
import { useAuthorBooks } from '../composables/useAuthorBooks'
import { useAuthorDetail } from '../composables/useAuthorDetail'
import { useAuthorMetadataPreview } from '../composables/useAuthorMetadataPreview'
import EntityNotFound from '@/components/EntityNotFound.vue'
const route = useRoute()
const router = useRouter()
@@ -26,7 +27,7 @@ const { coverSize, gridGap, viewMode } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries()
const authorId = computed(() => Number(route.params.id))
const { author, loading: loadingAuthor, error: authorError, load: loadAuthor } = useAuthorDetail(authorId)
const { author, loading: loadingAuthor, error: authorError, notFound: authorNotFound, 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(() => {
@@ -326,155 +327,161 @@ watch(authorName, () => {
</button>
</div>
<div v-if="authorError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{{ authorError }}
<div v-if="authorNotFound">
<EntityNotFound entity="Author" />
</div>
<div v-if="loadingAuthor && !author" class="mb-4 rounded-xl border border-border/70 bg-card/60 p-4 text-sm text-muted-foreground">
Loading author...
</div>
<AuthorHeader
v-else-if="author"
:author="author"
:image-url="author.imageUrl ?? metadataPreview?.imageUrl ?? null"
:preview-description="metadataPreview?.description ?? null"
:preview-provider="metadataPreview?.provider ?? null"
: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">
Could not load external author preview metadata right now.
</div>
<section v-if="author && (editOpen || mergeOpen)" class="mt-4 rounded-xl border border-border/70 bg-card/60 p-3 space-y-3">
<div v-if="editOpen && canUpdate" class="space-y-2">
<div class="grid gap-2 md:grid-cols-2">
<label class="text-xs text-muted-foreground">
Name
<input v-model="draftName" class="mt-1 h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm" />
</label>
<label class="text-xs text-muted-foreground">
Sort Name
<input v-model="draftSortName" class="mt-1 h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm" />
</label>
</div>
<label class="block text-xs text-muted-foreground">
Description
<textarea v-model="draftDescription" rows="3" class="mt-1 w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm" />
</label>
<div class="flex items-center justify-end gap-2">
<button class="h-8 rounded-md border border-input px-3 text-sm text-muted-foreground hover:bg-muted" @click="editOpen = false">
Cancel
</button>
<button
:disabled="savingEdit"
class="h-8 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-60"
@click="saveAuthorEdits"
>
{{ savingEdit ? 'Saving...' : 'Save' }}
</button>
</div>
<template v-else>
<div v-if="authorError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{{ authorError }}
</div>
<div v-if="mergeOpen && canMerge" class="space-y-2">
<input
v-model="mergeQuery"
class="h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm"
placeholder="Search authors to merge into this author"
/>
<div v-if="searchingMergeCandidates" class="text-xs text-muted-foreground">Searching...</div>
<div v-if="mergeCandidates.length > 0" class="max-h-48 space-y-1 overflow-y-auto rounded-md border border-border/70 bg-background/50 p-2">
<label v-for="candidate in mergeCandidates" :key="candidate.id" class="flex items-center gap-2 text-sm">
<input type="checkbox" :checked="selectedMergeIds.includes(candidate.id)" @change="onMergeCandidateToggle(candidate.id, $event)" />
<span class="min-w-0 flex-1 truncate">{{ candidate.name }}</span>
<span class="text-xs text-muted-foreground">{{ candidate.bookCount }} books</span>
</label>
</div>
<div v-if="selectedMergeIds.length > 0" class="text-xs text-muted-foreground">
Selected {{ selectedMergeIds.length }} author(s), approx. {{ selectedMergeBookCount }} books affected.
</div>
<div class="flex items-center justify-end">
<button
:disabled="merging || selectedMergeIds.length === 0"
class="h-8 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-60"
@click="promptRunMerge"
>
{{ merging ? 'Merging...' : 'Merge Selected' }}
</button>
</div>
<div v-if="loadingAuthor && !author" class="mb-4 rounded-xl border border-border/70 bg-card/60 p-4 text-sm text-muted-foreground">
Loading author...
</div>
</section>
<section class="mt-4 rounded-xl border border-border/70 bg-card/60 p-3">
<div class="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h2 class="text-sm font-semibold text-foreground">Books</h2>
<div class="flex items-center gap-2">
<select
v-model="sort"
class="h-8 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
>
<option value="addedAt">Recently Added</option>
<option value="title">Title</option>
<option value="publishedYear">Published Year</option>
</select>
<select
v-model="order"
class="h-8 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
>
<option value="desc">Descending</option>
<option value="asc">Ascending</option>
</select>
<select
:value="libraryId ?? ''"
class="h-8 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
@change="libraryId = ($event.target as HTMLSelectElement).value ? Number(($event.target as HTMLSelectElement).value) : null"
>
<option value="">All Libraries</option>
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option>
</select>
</div>
</div>
<div v-if="booksError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{{ booksError }}
</div>
<div v-if="!loadingBooks && books.length === 0" class="flex flex-col items-center justify-center gap-2 py-16 text-center">
<p class="text-sm font-medium text-foreground">No books found for this author</p>
<p class="text-xs text-muted-foreground">Try changing sort/order or selecting another library.</p>
</div>
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="coverSize"
:grid-gap="gridGap"
@action="handleBookAction"
<AuthorHeader
v-else-if="author"
:author="author"
:image-url="author.imageUrl ?? metadataPreview?.imageUrl ?? null"
:preview-description="metadataPreview?.description ?? null"
:preview-provider="metadataPreview?.provider ?? null"
: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="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow v-for="book in books" :key="book.id" :book="book" @action="handleBookAction(book, $event)" />
<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">
Could not load external author preview metadata right now.
</div>
<div ref="sentinel" class="mt-4 flex h-8 items-center justify-center">
<span v-if="loadingBooks" class="text-xs text-muted-foreground">Loading...</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">All {{ total.toLocaleString() }} books loaded</span>
</div>
</section>
<section v-if="author && (editOpen || mergeOpen)" class="mt-4 rounded-xl border border-border/70 bg-card/60 p-3 space-y-3">
<div v-if="editOpen && canUpdate" class="space-y-2">
<div class="grid gap-2 md:grid-cols-2">
<label class="text-xs text-muted-foreground">
Name
<input v-model="draftName" class="mt-1 h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm" />
</label>
<label class="text-xs text-muted-foreground">
Sort Name
<input v-model="draftSortName" class="mt-1 h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm" />
</label>
</div>
<label class="block text-xs text-muted-foreground">
Description
<textarea v-model="draftDescription" rows="3" class="mt-1 w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm" />
</label>
<div class="flex items-center justify-end gap-2">
<button class="h-8 rounded-md border border-input px-3 text-sm text-muted-foreground hover:bg-muted" @click="editOpen = false">
Cancel
</button>
<button
:disabled="savingEdit"
class="h-8 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-60"
@click="saveAuthorEdits"
>
{{ savingEdit ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
<div v-if="mergeOpen && canMerge" class="space-y-2">
<input
v-model="mergeQuery"
class="h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm"
placeholder="Search authors to merge into this author"
/>
<div v-if="searchingMergeCandidates" class="text-xs text-muted-foreground">Searching...</div>
<div v-if="mergeCandidates.length > 0" class="max-h-48 space-y-1 overflow-y-auto rounded-md border border-border/70 bg-background/50 p-2">
<label v-for="candidate in mergeCandidates" :key="candidate.id" class="flex items-center gap-2 text-sm">
<input type="checkbox" :checked="selectedMergeIds.includes(candidate.id)" @change="onMergeCandidateToggle(candidate.id, $event)" />
<span class="min-w-0 flex-1 truncate">{{ candidate.name }}</span>
<span class="text-xs text-muted-foreground">{{ candidate.bookCount }} books</span>
</label>
</div>
<div v-if="selectedMergeIds.length > 0" class="text-xs text-muted-foreground">
Selected {{ selectedMergeIds.length }} author(s), approx. {{ selectedMergeBookCount }} books affected.
</div>
<div class="flex items-center justify-end">
<button
:disabled="merging || selectedMergeIds.length === 0"
class="h-8 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-60"
@click="promptRunMerge"
>
{{ merging ? 'Merging...' : 'Merge Selected' }}
</button>
</div>
</div>
</section>
<section class="mt-4 rounded-xl border border-border/70 bg-card/60 p-3">
<div class="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h2 class="text-sm font-semibold text-foreground">Books</h2>
<div class="flex items-center gap-2">
<select
v-model="sort"
class="h-8 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
>
<option value="addedAt">Recently Added</option>
<option value="title">Title</option>
<option value="publishedYear">Published Year</option>
</select>
<select
v-model="order"
class="h-8 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
>
<option value="desc">Descending</option>
<option value="asc">Ascending</option>
</select>
<select
:value="libraryId ?? ''"
class="h-8 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
@change="libraryId = ($event.target as HTMLSelectElement).value ? Number(($event.target as HTMLSelectElement).value) : null"
>
<option value="">All Libraries</option>
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option>
</select>
</div>
</div>
<div v-if="booksError" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{{ booksError }}
</div>
<div v-if="!loadingBooks && books.length === 0" class="flex flex-col items-center justify-center gap-2 py-16 text-center">
<p class="text-sm font-medium text-foreground">No books found for this author</p>
<p class="text-xs text-muted-foreground">Try changing sort/order or selecting another library.</p>
</div>
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="coverSize"
:grid-gap="gridGap"
@action="handleBookAction"
/>
<div v-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow v-for="book in books" :key="book.id" :book="book" @action="handleBookAction(book, $event)" />
</div>
<div ref="sentinel" class="mt-4 flex h-8 items-center justify-center">
<span v-if="loadingBooks" class="text-xs text-muted-foreground">Loading...</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">All {{ total.toLocaleString() }} books loaded</span>
</div>
</section>
</template>
</main>
<AuthorConfirmDialog
@@ -6,12 +6,18 @@ export function useBookDetail() {
const detail = ref<BookDetail | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const notFound = ref(false)
async function fetch(bookId: number) {
loading.value = true
error.value = null
notFound.value = false
try {
const res = await api(`/api/v1/books/${bookId}`)
if (res.status === 404) {
notFound.value = true
return
}
if (!res.ok) throw new Error(`HTTP ${res.status}`)
detail.value = await res.json()
} catch (e) {
@@ -21,5 +27,5 @@ export function useBookDetail() {
}
}
return { detail, loading, error, fetch }
return { detail, loading, error, notFound, fetch }
}
@@ -8,6 +8,7 @@ export function useBookQuery(libraryId: Ref<number | null>) {
const items = ref<BookCard[]>([])
const total = ref(0)
const loading = ref(false)
const initialized = ref(false)
const error = ref<string | null>(null)
const filter = ref<GroupRule | undefined>(undefined)
@@ -54,6 +55,7 @@ export function useBookQuery(libraryId: Ref<number | null>) {
error.value = e instanceof Error ? e.message : 'Failed to load books'
} finally {
loading.value = false
initialized.value = true
}
}
@@ -67,5 +69,5 @@ export function useBookQuery(libraryId: Ref<number | null>) {
if (newId !== oldId) load(true)
})
return { items, total, loading, error, filter, sort, pagination, hasMore, load, clear }
return { items, total, loading, initialized, error, filter, sort, pagination, hasMore, load, clear }
}
@@ -8,6 +8,7 @@ export function useCollectionBooks(collectionId: Ref<number>) {
const items = ref<BookCard[]>([])
const total = ref(0)
const loading = ref(false)
const initialized = ref(false)
const hasMore = ref(false)
let page = 0
@@ -29,8 +30,9 @@ export function useCollectionBooks(collectionId: Ref<number>) {
page++
} finally {
loading.value = false
initialized.value = true
}
}
return { items, total, loading, hasMore, load }
return { items, total, loading, initialized, hasMore, load }
}
@@ -8,6 +8,7 @@ export function useLens(lensId: Ref<number>) {
const items = ref<BookCard[]>([])
const total = ref(0)
const loading = ref(false)
const initialized = ref(false)
const page = ref(0)
const hasMore = computed(() => items.value.length < total.value)
@@ -36,8 +37,9 @@ export function useLens(lensId: Ref<number>) {
page.value++
} finally {
loading.value = false
initialized.value = true
}
}
return { items, total, loading, hasMore, load }
return { items, total, loading, initialized, hasMore, load }
}
@@ -4,12 +4,12 @@ import type { Library } from '@projectx/types'
const libraries = ref<Library[]>([])
const loading = ref(false)
let loaded = false
const loaded = ref(false)
let fetchPromise: Promise<void> | null = null
export function useLibraries() {
async function fetchLibraries(): Promise<void> {
if (loaded) return
if (loaded.value) return
return refreshLibraries()
}
@@ -20,7 +20,7 @@ export function useLibraries() {
.then(async (res) => {
if (!res.ok) return
libraries.value = await res.json()
loaded = true
loaded.value = true
})
.finally(() => {
fetchPromise = null
@@ -38,5 +38,5 @@ export function useLibraries() {
if (!res.ok) throw new Error('Failed to reorder libraries')
}
return { libraries, loading, fetchLibraries, refreshLibraries, reorderLibraries }
return { libraries, loading, loaded, fetchLibraries, refreshLibraries, reorderLibraries }
}
@@ -1,83 +0,0 @@
<script setup lang="ts">
import { BookOpen, Database, Github, Server } from 'lucide-vue-next'
import SettingsPageHeader from './SettingsPageHeader.vue'
const stack = [
{ label: 'Frontend', value: 'Vue 3 + TypeScript + Tailwind CSS v4' },
{ label: 'Backend', value: 'NestJS 11 + Fastify' },
{ label: 'Database', value: 'PostgreSQL + Drizzle ORM' },
{ label: 'EPUB Engine', value: 'Foliate (epub.js)' },
{ label: 'PDF Engine', value: 'PDF.js' },
]
</script>
<template>
<SettingsPageHeader title="About" subtitle="Information about this application." />
<!-- App identity -->
<div class="mb-6">
<div class="border border-border rounded-lg bg-card px-5 py-5 flex items-start gap-4">
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<BookOpen :size="22" class="text-primary" />
</div>
<div>
<p class="text-base font-semibold text-foreground font-serif">projectx</p>
<p class="settings-hint">Version 0.1.0 (early development)</p>
<p class="text-xs text-muted-foreground mt-2 leading-relaxed max-w-lg">
A self-hosted book and library management application with support for EPUB, PDF, and comic formats.
</p>
</div>
</div>
</div>
<!-- Tech stack -->
<div class="mb-6">
<p class="settings-group-label">Tech Stack</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div v-for="item in stack" :key="item.label" class="flex items-center justify-between px-5 py-3.5 bg-card">
<p class="text-sm text-muted-foreground">{{ item.label }}</p>
<p class="settings-label">{{ item.value }}</p>
</div>
</div>
</div>
<!-- Server info -->
<div class="mb-6">
<p class="settings-group-label">Server</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center gap-3 px-5 py-4 bg-card">
<Server :size="15" class="text-muted-foreground shrink-0" />
<div>
<p class="settings-label">API</p>
<p class="text-xs text-muted-foreground font-mono mt-0.5">/api</p>
</div>
</div>
<div class="flex items-center gap-3 px-5 py-4 bg-card">
<Database :size="15" class="text-muted-foreground shrink-0" />
<div>
<p class="settings-label">Database</p>
<p class="settings-hint">PostgreSQL</p>
</div>
</div>
</div>
</div>
<!-- Links -->
<div>
<p class="settings-group-label">Links</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<a
href="https://github.com/neonsolstice/projectx"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 px-5 py-4 bg-card hover:bg-muted/50 transition-colors group"
>
<Github :size="15" class="text-muted-foreground shrink-0" />
<div class="flex-1">
<p class="settings-label group-hover:text-primary transition-colors">GitHub</p>
<p class="settings-hint">Source code and issue tracker</p>
</div>
</a>
</div>
</div>
</template>
@@ -59,8 +59,6 @@ const sections = computed<Section[]>(() => {
result.push({ label: 'Audit Log', routeName: 'settings-admin-audit-log' })
}
result.push({ label: 'About', routeName: 'settings-about' })
return result
})
</script>
@@ -3,7 +3,7 @@ defineProps<{ title: string; subtitle: string }>()
</script>
<template>
<div class="flex items-start justify-between mb-8 gap-4">
<div class="flex items-center justify-between mb-8 gap-4">
<div class="min-w-0">
<h2 class="settings-title">{{ title }}</h2>
<p class="settings-subtitle">{{ subtitle }}</p>
+3 -6
View File
@@ -158,12 +158,7 @@ export const routes: RouteRecordRaw[] = [
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: ':pathMatch(.*)*', redirect: { name: 'settings-libraries' } },
],
},
{
@@ -229,6 +224,7 @@ export const routes: RouteRecordRaw[] = [
path: '/book/:bookId/edit',
redirect: (to) => ({ name: 'book-detail', params: to.params, query: { tab: 'edit' } }),
},
{ path: ':pathMatch(.*)*', component: () => import('@/views/NotFoundView.vue'), meta: { title: 'Not Found' } },
],
},
{
@@ -267,6 +263,7 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/features/auth/OidcCallbackPage.vue'),
meta: { public: true, title: 'Completing Sign In' },
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
]
const router = createRouter({
+6 -1
View File
@@ -14,6 +14,7 @@ import { normalizeBookDetailTab } from '@/features/book/lib/book-detail-tabs'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import EntityNotFound from '@/components/EntityNotFound.vue'
const route = useRoute()
const { hasPermission } = usePermissions()
@@ -31,7 +32,7 @@ provide(
const bookId = computed(() => Number(route.params.bookId))
const tab = computed(() => normalizeBookDetailTab(route.query.tab))
const { detail, loading, fetch } = useBookDetail()
const { detail, loading, notFound, fetch } = useBookDetail()
const pageTitle = computed(() => {
const title = detail.value?.title?.trim()
const base = title || (Number.isFinite(bookId.value) ? `Book #${bookId.value}` : 'Book')
@@ -116,5 +117,9 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
<div v-for="i in 3" :key="i" class="h-16 rounded-md bg-muted animate-pulse" />
</div>
</template>
<template v-else-if="notFound">
<EntityNotFound entity="Book" />
</template>
</BookDetailLayout>
</template>
+7 -3
View File
@@ -23,6 +23,7 @@ import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
import { usePageTitle } from '@/composables/usePageTitle'
import type { BookCard } from '@projectx/types'
import EntityNotFound from '@/components/EntityNotFound.vue'
const route = useRoute()
const router = useRouter()
@@ -31,6 +32,7 @@ const { viewMode } = useDisplaySettings()
const collectionId = computed(() => Number(route.params.id))
const { coverSize, gridGap } = useViewDisplaySettings('collection', collectionId)
const { collections, fetchCollections, removeBooksFromCollection } = useCollections()
const collectionNotFound = ref(false)
const collection = computed(() => collections.value.find((c) => c.id === collectionId.value))
const pageTitle = computed(() => {
if (collection.value?.name) return `Collection · ${collection.value.name}`
@@ -38,7 +40,7 @@ const pageTitle = computed(() => {
})
usePageTitle(pageTitle)
const { items: books, total, loading, hasMore, load } = useCollectionBooks(collectionId)
const { items: books, total, loading, initialized: booksInitialized, hasMore, load } = useCollectionBooks(collectionId)
const { setBookContext, registerLoadMore } = useBookNavigation()
watch(
[books, total],
@@ -139,7 +141,7 @@ let observer: IntersectionObserver | null = null
onMounted(async () => {
await fetchCollections()
if (!collection.value) {
router.push({ name: 'home' })
collectionNotFound.value = true
return
}
load(true)
@@ -229,7 +231,9 @@ watch(
</ViewHeader>
<main class="flex-1 min-h-0">
<div v-if="!loading && books.length === 0" class="flex flex-col items-center justify-center py-24 gap-3 text-center">
<EntityNotFound v-if="collectionNotFound" entity="Collection" />
<div v-else-if="booksInitialized && !loading && books.length === 0" class="flex flex-col items-center justify-center py-24 gap-3 text-center">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<FolderOpen :size="28" class="text-muted-foreground/70" />
</div>
+107 -98
View File
@@ -31,16 +31,18 @@ import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
import { usePageTitle } from '@/composables/usePageTitle'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import type { GroupRule, SortSpec } from '@projectx/types'
import EntityNotFound from '@/components/EntityNotFound.vue'
const route = useRoute()
const router = useRouter()
const { viewMode } = useDisplaySettings()
const { libraries } = useLibraries()
const { libraries, loaded: librariesLoaded } = useLibraries()
const libraryId = shallowRef<number | null>(route.params.id ? Number(route.params.id) : null)
const { coverSize, gridGap } = useViewDisplaySettings('library', libraryId)
const currentLibrary = computed(() => libraries.value.find((l) => l.id === libraryId.value))
const libraryNotFound = computed(() => librariesLoaded.value && libraryId.value !== null && !currentLibrary.value)
const title = computed(() => currentLibrary.value?.name ?? 'Library')
const libraryIcon = computed(() => currentLibrary.value?.icon ?? 'BookOpen')
const pageTitle = computed(() => {
@@ -54,7 +56,7 @@ provide(
computed(() => currentLibrary.value?.coverAspectRatio ?? DEFAULT_COVER_ASPECT_RATIO),
)
const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
const { items: books, total, loading, initialized: booksInitialized, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const { setBookContext, registerLoadMore } = useBookNavigation()
@@ -385,109 +387,116 @@ function handleBookAction(book: BookCard, action: BookActionType) {
</ViewHeader>
<main class="flex-1 min-h-0">
<div v-if="error" class="text-sm text-destructive mb-4">{{ error }}</div>
<EntityNotFound v-if="libraryNotFound" entity="Library" />
<!-- Filter builder panel -->
<div v-if="filterOpen" class="mb-4 p-3 rounded-md border border-border bg-card">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-muted-foreground">Filter rules</span>
<div class="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="activeFilterCount > 0"
@click="saveAsLensOpen = true"
class="flex items-center gap-1.5 h-7 px-3 rounded-md border border-input text-xs font-medium text-muted-foreground bg-background hover:text-foreground hover:bg-muted transition-colors"
>
<Telescope :size="13" />
Save as Lens
</button>
</TooltipTrigger>
<TooltipContent>Save this filter as a named lens</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="activeFilterCount > 0"
@click="saveFilter"
class="flex items-center gap-1.5 h-7 px-3 rounded-md border text-xs font-medium transition-colors"
:class="
isFilterSaved
? 'border-primary/40 text-primary bg-primary/8'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
>
<BookmarkCheck v-if="isFilterSaved" :size="13" />
<Bookmark v-else :size="13" />
{{ isFilterSaved ? 'Saved' : 'Save filter' }}
</button>
</TooltipTrigger>
<TooltipContent>{{ isFilterSaved ? 'Filter saved' : 'Save filter for this library' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="hasSavedFilter"
@click="forgetSavedFilter"
class="h-6 w-6 flex items-center justify-center rounded text-muted-foreground/70 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X :size="11" />
</button>
</TooltipTrigger>
<TooltipContent>Remove saved filter</TooltipContent>
</Tooltip>
<template v-else>
<div v-if="error" class="text-sm text-destructive mb-4">{{ error }}</div>
<!-- Filter builder panel -->
<div v-if="filterOpen" class="mb-4 p-3 rounded-md border border-border bg-card">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-muted-foreground">Filter rules</span>
<div class="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="activeFilterCount > 0"
@click="saveAsLensOpen = true"
class="flex items-center gap-1.5 h-7 px-3 rounded-md border border-input text-xs font-medium text-muted-foreground bg-background hover:text-foreground hover:bg-muted transition-colors"
>
<Telescope :size="13" />
Save as Lens
</button>
</TooltipTrigger>
<TooltipContent>Save this filter as a named lens</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="activeFilterCount > 0"
@click="saveFilter"
class="flex items-center gap-1.5 h-7 px-3 rounded-md border text-xs font-medium transition-colors"
:class="
isFilterSaved
? 'border-primary/40 text-primary bg-primary/8'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
>
<BookmarkCheck v-if="isFilterSaved" :size="13" />
<Bookmark v-else :size="13" />
{{ isFilterSaved ? 'Saved' : 'Save filter' }}
</button>
</TooltipTrigger>
<TooltipContent>{{ isFilterSaved ? 'Filter saved' : 'Save filter for this library' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="hasSavedFilter"
@click="forgetSavedFilter"
class="h-6 w-6 flex items-center justify-center rounded text-muted-foreground/70 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X :size="11" />
</button>
</TooltipTrigger>
<TooltipContent>Remove saved filter</TooltipContent>
</Tooltip>
</div>
</div>
<BookFilterBuilder v-model="filter" />
</div>
<!-- Empty state: no matches with filters -->
<div
v-if="booksInitialized && !loading && books.length === 0 && activeFilterCount > 0"
class="flex flex-col items-center justify-center py-24 gap-3 text-center"
>
<p class="text-sm font-medium text-foreground">No books match your filters</p>
<p class="text-xs text-muted-foreground">Try adjusting or clearing your filters to see more books.</p>
<button @click="clearFilters" class="text-xs text-primary hover:underline">Clear filters</button>
</div>
<!-- Empty state: no books in library -->
<div v-else-if="booksInitialized && !loading && books.length === 0" class="flex flex-col items-center justify-center py-24 gap-4 text-center">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<BookOpen :size="28" class="text-muted-foreground/70" />
</div>
<div class="flex flex-col gap-1">
<p class="text-sm font-medium text-foreground">Your library is empty</p>
<p class="text-xs text-muted-foreground max-w-xs">Once you add books to this library, they will appear here.</p>
</div>
</div>
<BookFilterBuilder v-model="filter" />
</div>
<!-- Empty state: no matches with filters -->
<div v-if="!loading && books.length === 0 && activeFilterCount > 0" class="flex flex-col items-center justify-center py-24 gap-3 text-center">
<p class="text-sm font-medium text-foreground">No books match your filters</p>
<p class="text-xs text-muted-foreground">Try adjusting or clearing your filters to see more books.</p>
<button @click="clearFilters" class="text-xs text-primary hover:underline">Clear filters</button>
</div>
<!-- Empty state: no books in library -->
<div v-else-if="!loading && books.length === 0" class="flex flex-col items-center justify-center py-24 gap-4 text-center">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<BookOpen :size="28" class="text-muted-foreground/70" />
</div>
<div class="flex flex-col gap-1">
<p class="text-sm font-medium text-foreground">Your library is empty</p>
<p class="text-xs text-muted-foreground max-w-xs">Once you add books to this library, they will appear here.</p>
</div>
</div>
<!-- Grid view -->
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="coverSize"
:grid-gap="gridGap"
:selection-mode="selectionMode"
:is-selected="isSelected"
@action="handleBookAction"
@select="handleSelect"
/>
<!-- List view -->
<div v-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
<!-- Grid view -->
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="coverSize"
:grid-gap="gridGap"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
:is-selected="isSelected"
@action="handleBookAction"
@select="handleSelect"
/>
</div>
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
<span v-if="loading" class="text-xs text-muted-foreground">Loading...</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">All {{ total.toLocaleString() }} books loaded</span>
</div>
<!-- List view -->
<div v-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
/>
</div>
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
<span v-if="loading" class="text-xs text-muted-foreground">Loading...</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">All {{ total.toLocaleString() }} books loaded</span>
</div>
</template>
</main>
</section>
+79 -67
View File
@@ -23,6 +23,7 @@ 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'
import EntityNotFound from '@/components/EntityNotFound.vue'
const route = useRoute()
const router = useRouter()
@@ -31,7 +32,7 @@ const { viewMode, lensFilterExpanded } = useDisplaySettings()
const lensId = computed(() => Number(route.params.id))
const { coverSize, gridGap } = useViewDisplaySettings('lens', lensId)
const { items: books, total, loading, hasMore, load } = useLens(lensId)
const { items: books, total, loading, initialized: booksInitialized, hasMore, load } = useLens(lensId)
const { setBookContext, registerLoadMore } = useBookNavigation()
watch(
[books, total],
@@ -53,6 +54,7 @@ onUnmounted(() => {
registerLoadMore(null)
})
const { lenses, fetchLenses, deleteLens } = useLenses()
const lensNotFound = ref(false)
const lens = computed(() => lenses.value.find((l) => l.id === lensId.value))
const pageTitle = computed(() => {
@@ -183,7 +185,7 @@ onMounted(async () => {
await fetchLenses()
if (!lens.value) {
router.push({ name: 'home' })
lensNotFound.value = true
return
}
load(true)
@@ -278,80 +280,90 @@ watch(
</ViewHeader>
<main class="flex-1 min-h-0">
<!-- Filter summary -->
<div
v-if="filterExpanded && (lens?.filter || sortChip)"
class="flex flex-wrap items-center gap-2 mb-4 cursor-pointer"
@click="editorOpen = true"
>
<FilterSummary v-if="lens?.filter" :node="lens.filter as GroupRule" />
<span v-if="sortChip" class="inline-flex items-center text-xs rounded-md border border-border/60 overflow-hidden">
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-muted text-muted-foreground border-r border-border/60">
<ArrowUpDown :size="10" class="shrink-0" />
<span class="font-semibold">Sort</span>
</span>
<span class="px-2 py-0.5 bg-muted/40 text-foreground font-medium">{{ sortChip }}</span>
</span>
</div>
<EntityNotFound v-if="lensNotFound" entity="Lens" />
<!-- Empty state: no rules configured -->
<div v-if="!loading && !lens?.filter && books.length === 0" class="flex flex-col items-center justify-center py-24 gap-4 text-center">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Settings2 :size="28" class="text-muted-foreground/70" />
</div>
<div class="flex flex-col gap-1">
<p class="text-sm font-medium text-foreground">No rules configured</p>
<p class="text-xs text-muted-foreground max-w-xs">
Open the editor to define which books appear in this lens using filters and sort rules.
</p>
</div>
<button
<template v-else>
<!-- Filter summary -->
<div
v-if="filterExpanded && (lens?.filter || sortChip)"
class="flex flex-wrap items-center gap-2 mb-4 cursor-pointer"
@click="editorOpen = true"
class="h-9 px-5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
Configure Lens
</button>
</div>
<!-- Empty state: rules set but no matches -->
<div v-else-if="!loading && books.length === 0 && lens?.filter" class="flex flex-col items-center justify-center py-24 gap-3 text-center">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Aperture :size="28" class="text-muted-foreground/70" />
<FilterSummary v-if="lens?.filter" :node="lens.filter as GroupRule" />
<span v-if="sortChip" class="inline-flex items-center text-xs rounded-md border border-border/60 overflow-hidden">
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-muted text-muted-foreground border-r border-border/60">
<ArrowUpDown :size="10" class="shrink-0" />
<span class="font-semibold">Sort</span>
</span>
<span class="px-2 py-0.5 bg-muted/40 text-foreground font-medium">{{ sortChip }}</span>
</span>
</div>
<p class="text-sm font-medium text-foreground">No books match this lens</p>
<p class="text-xs text-muted-foreground">Try adjusting the filter rules.</p>
<button @click="editorOpen = true" class="text-xs text-primary hover:underline">Edit Lens</button>
</div>
<!-- Grid view -->
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="coverSize"
:grid-gap="gridGap"
:selection-mode="selectionMode"
:is-selected="isSelected"
@action="handleBookAction"
@select="handleSelect"
/>
<!-- Empty state: no rules configured -->
<div
v-if="booksInitialized && !loading && !lens?.filter && books.length === 0"
class="flex flex-col items-center justify-center py-24 gap-4 text-center"
>
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Settings2 :size="28" class="text-muted-foreground/70" />
</div>
<div class="flex flex-col gap-1">
<p class="text-sm font-medium text-foreground">No rules configured</p>
<p class="text-xs text-muted-foreground max-w-xs">
Open the editor to define which books appear in this lens using filters and sort rules.
</p>
</div>
<button
@click="editorOpen = true"
class="h-9 px-5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
Configure Lens
</button>
</div>
<!-- List view -->
<div v-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
<!-- Empty state: rules set but no matches -->
<div
v-else-if="booksInitialized && !loading && books.length === 0 && lens?.filter"
class="flex flex-col items-center justify-center py-24 gap-3 text-center"
>
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Aperture :size="28" class="text-muted-foreground/70" />
</div>
<p class="text-sm font-medium text-foreground">No books match this lens</p>
<p class="text-xs text-muted-foreground">Try adjusting the filter rules.</p>
<button @click="editorOpen = true" class="text-xs text-primary hover:underline">Edit Lens</button>
</div>
<!-- Grid view -->
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="coverSize"
:grid-gap="gridGap"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@select="handleSelect(book.id, $event)"
@action="handleBookAction(book, $event)"
:is-selected="isSelected"
@action="handleBookAction"
@select="handleSelect"
/>
</div>
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
<span v-if="loading" class="text-xs text-muted-foreground">Loading...</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground"> All {{ total.toLocaleString() }} books loaded </span>
</div>
<!-- List view -->
<div v-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@select="handleSelect(book.id, $event)"
@action="handleBookAction(book, $event)"
/>
</div>
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
<span v-if="loading" class="text-xs text-muted-foreground">Loading...</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground"> All {{ total.toLocaleString() }} books loaded </span>
</div>
</template>
</main>
</section>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { House } from 'lucide-vue-next'
const router = useRouter()
function goHome() {
router.push('/')
}
</script>
<template>
<div class="flex flex-col items-center justify-center min-h-[60vh] gap-4 text-center px-4">
<p class="text-8xl font-serif font-semibold text-foreground/10 select-none leading-none">404</p>
<div class="space-y-1">
<h1 class="text-xl font-semibold text-foreground">Page not found</h1>
<p class="text-sm text-muted-foreground">The page you're looking for doesn't exist.</p>
</div>
<button
class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors mt-2"
@click="goHome"
>
<House :size="15" />
Go home
</button>
</div>
</template>