feat: add next/previous book navigation to book detail views

- create useBookNavigation composable for shared book list context
- refactor BookDetailHeader to include navigation buttons and index display
- sync book context and register loadMore callback in Home, Lens, and Collection views
- apply BookDetailHeader to Details, Files, and Edit Metadata tabs
This commit is contained in:
neon
2026-03-06 00:58:27 -06:00
parent 0686ae6477
commit 6df8872bd3
8 changed files with 186 additions and 24 deletions
@@ -1,16 +1,61 @@
<script setup lang="ts">
import { ChevronLeft } from 'lucide-vue-next'
import { Skeleton } from '@/components/ui/skeleton'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import BookDetailTabs from './BookDetailTabs.vue'
import { useBookNavigation } from '../../composables/useBookNavigation'
defineProps<{ libraryName: string; loading: boolean }>()
defineEmits<{ back: [] }>()
const props = defineProps<{ bookId: number }>()
const route = useRoute()
const router = useRouter()
const { bookIds, getNextId, getPrevId, hasContext, currentIndex, total } = useBookNavigation()
const nextId = ref<number | null>(null)
const prevId = ref<number | null>(null)
const index = computed(() => currentIndex(props.bookId))
watch(
[() => props.bookId, bookIds],
async ([id]) => {
prevId.value = getPrevId(id)
nextId.value = await getNextId(id)
},
{ immediate: true },
)
async function navigateToBook(id: number) {
await router.push({ name: route.name!, params: { ...route.params, bookId: id } })
}
</script>
<template>
<button class="flex items-center gap-1 shrink-0 px-3 text-sm text-muted-foreground hover:text-foreground transition-colors" @click="$emit('back')">
<ChevronLeft class="size-4" />
<span v-if="!loading">{{ libraryName }}</span>
<Skeleton v-else class="h-3.5 w-20 rounded" />
</button>
<div class="w-px h-4 bg-border mx-1 shrink-0" />
<div class="flex items-stretch border-b shrink-0 h-11 px-3">
<BookDetailTabs :book-id="bookId" />
<div v-if="hasContext" class="flex items-center gap-1 ml-4 border-l pl-4">
<span v-if="index !== -1" class="text-xs text-muted-foreground mr-2 font-medium tabular-nums">
{{ index + 1 }} <span class="opacity-50">/</span> {{ total }}
</span>
<button
@click="prevId && navigateToBook(prevId)"
:disabled="!prevId"
class="h-7 w-7 flex items-center justify-center rounded-md transition-colors"
:class="prevId ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/30 cursor-not-allowed'"
title="Previous book"
>
<ChevronLeft :size="16" />
</button>
<button
@click="nextId && navigateToBook(nextId)"
:disabled="!nextId"
class="h-7 w-7 flex items-center justify-center rounded-md transition-colors"
:class="nextId ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/30 cursor-not-allowed'"
title="Next book"
>
<ChevronRight :size="16" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,61 @@
import { computed, ref, type Ref } from 'vue'
const bookIds = ref<number[]>([])
const total = ref(0)
let loadMoreCallback: (() => Promise<void>) | null = null
export function useBookNavigation() {
const setBookContext = (ids: number[], totalCount: number) => {
bookIds.value = ids
total.value = totalCount
}
const registerLoadMore = (cb: (() => Promise<void>) | null) => {
loadMoreCallback = cb
}
const getNextId = async (currentId: number) => {
const idx = bookIds.value.indexOf(currentId)
if (idx !== -1) {
if (idx < bookIds.value.length - 1) {
return bookIds.value[idx + 1]
}
if (bookIds.value.length < total.value && loadMoreCallback) {
const beforeLength = bookIds.value.length
await loadMoreCallback()
if (bookIds.value.length === beforeLength) {
// No more books were loaded, avoid infinite loop
return null
}
// After loading more, check again
const newIdx = bookIds.value.indexOf(currentId)
if (newIdx !== -1 && newIdx < bookIds.value.length - 1) {
return bookIds.value[newIdx + 1]
}
}
}
return null
}
const getPrevId = (currentId: number) => {
const idx = bookIds.value.indexOf(currentId)
if (idx !== -1 && idx > 0) {
return bookIds.value[idx - 1]
}
return null
}
const hasContext = computed(() => bookIds.value.length > 0)
const currentIndex = (currentId: number) => bookIds.value.indexOf(currentId)
return {
bookIds: computed(() => bookIds.value),
total: computed(() => total.value),
setBookContext,
registerLoadMore,
getNextId,
getPrevId,
hasContext,
currentIndex,
}
}
+2 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue'
import BookDetailHeader from '@/features/book/components/detail/BookDetailHeader.vue'
import DetailsTab from '@/features/book/components/detail/tabs/DetailsTab.vue'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
import { useBookEvents } from '@/features/book/composables/useBookEvents'
@@ -38,9 +38,7 @@ watch(bookId, (id) => fetch(id), { immediate: true })
</script>
<template>
<div class="flex items-stretch border-b shrink-0 h-11 px-3">
<BookDetailTabs :book-id="bookId" />
</div>
<BookDetailHeader :book-id="bookId" />
<main class="flex-none overflow-x-hidden px-6 py-6">
<DetailsTab v-if="detail" :book="detail" />
+2 -4
View File
@@ -2,7 +2,7 @@
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { BookDetail } from '@projectx/types'
import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue'
import BookDetailHeader from '@/features/book/components/detail/BookDetailHeader.vue'
import EditMetadataTab from '@/features/book/components/detail/tabs/EditMetadataTab.vue'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
@@ -24,9 +24,7 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
</script>
<template>
<div class="flex items-stretch border-b shrink-0 h-11 px-3">
<BookDetailTabs :book-id="bookId" />
</div>
<BookDetailHeader :book-id="bookId" />
<main class="flex-1 overflow-y-auto overflow-x-hidden px-6 py-6">
<EditMetadataTab v-if="detail" :book="detail" @saved="onMetadataSaved" @cover-changed="onCoverChanged" />
+2 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue'
import BookDetailHeader from '@/features/book/components/detail/BookDetailHeader.vue'
import FilesTab from '@/features/book/components/detail/tabs/FilesTab.vue'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
@@ -15,9 +15,7 @@ watch(bookId, (id) => fetch(id), { immediate: true })
</script>
<template>
<div class="flex items-stretch border-b shrink-0 h-11 px-3">
<BookDetailTabs :book-id="bookId" />
</div>
<BookDetailHeader :book-id="bookId" />
<main class="flex-1 overflow-y-auto overflow-x-hidden px-6 py-6">
<FilesTab v-if="detail" :book="detail" />
+21
View File
@@ -15,6 +15,7 @@ import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import { toast } from 'vue-sonner'
import { useCollections } from '@/features/collection/composables/useCollections'
import { useCollectionBooks } from '@/features/collection/composables/useCollectionBooks'
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
@@ -30,6 +31,26 @@ const { collections, fetchCollections, removeBooksFromCollection } = useCollecti
const collection = computed(() => collections.value.find((c) => c.id === collectionId.value))
const { items: books, total, loading, hasMore, load } = useCollectionBooks(collectionId)
const { setBookContext, registerLoadMore } = useBookNavigation()
watch(
[books, total],
([newBooks, newTotal]) => {
setBookContext(
newBooks.map((b) => b.id),
newTotal,
)
},
{ deep: true, immediate: true },
)
onMounted(() => {
registerLoadMore(async () => {
await load()
})
})
onUnmounted(() => {
registerLoadMore(null)
})
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, rangeSelectTo, isSelected } = useBookSelection()
+22 -1
View File
@@ -14,12 +14,13 @@ import AddToCollectionSheet from '@/features/collection/components/AddToCollecti
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import SaveAsLensDialog from '@/features/lens/components/SaveAsLensDialog.vue'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import { toast } from 'vue-sonner'
import { useBookQuery, type BookCard } from '@/features/book/composables/useBookQuery'
import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
@@ -37,6 +38,26 @@ const title = computed(() => libraries.value.find((l) => l.id === libraryId.valu
const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
const { setBookContext, registerLoadMore } = useBookNavigation()
watch(
[books, total],
([newBooks, newTotal]) => {
setBookContext(
newBooks.map((b) => b.id),
newTotal,
)
},
{ deep: true, immediate: true },
)
onMounted(() => {
registerLoadMore(async () => {
await load()
})
})
onUnmounted(() => {
registerLoadMore(null)
})
const FILTER_STORAGE_PREFIX = 'projectx:filter:library:'
function getFilterKey(id: number) {
return `${FILTER_STORAGE_PREFIX}${id}`
+21 -1
View File
@@ -11,9 +11,9 @@ import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import { toast } from 'vue-sonner'
import { useLens } from '@/features/lens/composables/useLens'
import { useLenses } from '@/features/lens/composables/useLenses'
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
@@ -29,6 +29,26 @@ const { coverSize, gridGap, viewMode, lensFilterExpanded } = useDisplaySettings(
const lensId = computed(() => Number(route.params.id))
const { items: books, total, loading, hasMore, load } = useLens(lensId)
const { setBookContext, registerLoadMore } = useBookNavigation()
watch(
[books, total],
([newBooks, newTotal]) => {
setBookContext(
newBooks.map((b) => b.id),
newTotal,
)
},
{ deep: true, immediate: true },
)
onMounted(() => {
registerLoadMore(async () => {
await load()
})
})
onUnmounted(() => {
registerLoadMore(null)
})
const { lenses, fetchLenses, deleteLens } = useLenses()
const lens = computed(() => lenses.value.find((l) => l.id === lensId.value))