mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user