mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add inline not-found states for all entity views and 404 routing
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user