mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): support peeking books without tracking progress
Implement a 'peek mode' that allows users to open and read books without updating their reading progress or creating reading sessions. Add a 'Peeking' banner in the reader and disable API calls in 'useReaderProgress' and 'useReadingSession' when tracking is disabled. Closes #161
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
BookOpen,
|
||||
Check,
|
||||
Download,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
Image,
|
||||
@@ -181,14 +182,19 @@ function handleArtworkError() {
|
||||
coverImageRatio.value = null
|
||||
}
|
||||
|
||||
function openFile(file: BookFileRef) {
|
||||
function openFile(file: BookFileRef, mode?: 'peek') {
|
||||
router.push({
|
||||
name: 'reader',
|
||||
params: { bookId: props.book.id, fileId: file.id },
|
||||
query: { format: file.format ?? 'epub' },
|
||||
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
|
||||
})
|
||||
}
|
||||
|
||||
function peekPrimaryFile() {
|
||||
if (!primaryFile.value || isMissing.value) return
|
||||
openFile(primaryFile.value, 'peek')
|
||||
}
|
||||
|
||||
function openBookDetails() {
|
||||
void router.push({ name: 'book-detail', params: { bookId: props.book.id } })
|
||||
}
|
||||
@@ -534,12 +540,12 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem v-if="openableFiles.length <= 1 && primaryFile && !isMissing" @click="openFile(primaryFile)">
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
Read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub v-else-if="openableFiles.length > 1 && !isMissing">
|
||||
<DropdownMenuSubTrigger>
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
Read
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="openFile(file)">
|
||||
@@ -549,6 +555,10 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem v-if="primaryFile && !isMissing" @click="peekPrimaryFile">
|
||||
<Eye class="size-4 mr-2" />
|
||||
Peek
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Download submenu -->
|
||||
<DropdownMenuItem
|
||||
@@ -665,12 +675,12 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem v-if="openableFiles.length <= 1 && primaryFile && !isMissing" @click="openFile(primaryFile)">
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
Read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub v-else-if="openableFiles.length > 1 && !isMissing">
|
||||
<DropdownMenuSubTrigger>
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
Read
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="openFile(file)">
|
||||
@@ -680,6 +690,10 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem v-if="primaryFile && !isMissing" @click="peekPrimaryFile">
|
||||
<Eye class="size-4 mr-2" />
|
||||
Peek
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
v-if="hasPermission('library_download') && openableFiles.length === 1 && primaryFile"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useRouter } from 'vue-router'
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
Loader2,
|
||||
@@ -98,14 +99,19 @@ const coverSrc = computed(() => coverUrl(props.book.id))
|
||||
const { refreshing, refreshWithFeedback } = useRefreshMetadata()
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
|
||||
function openFile(file: BookFileRef) {
|
||||
function openFile(file: BookFileRef, mode?: 'peek') {
|
||||
router.push({
|
||||
name: 'reader',
|
||||
params: { bookId: props.book.id, fileId: file.id },
|
||||
query: { format: file.format ?? 'epub' },
|
||||
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
|
||||
})
|
||||
}
|
||||
|
||||
function peekPrimaryFile() {
|
||||
if (!primaryFile.value || isMissing.value) return
|
||||
openFile(primaryFile.value, 'peek')
|
||||
}
|
||||
|
||||
function openAuthorBrowse() {
|
||||
if (!authorQuery.value) return
|
||||
void router.push({ name: 'authors', query: { q: authorQuery.value } })
|
||||
@@ -229,7 +235,11 @@ function openAuthorBrowse() {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem :disabled="!primaryFile || isMissing" @click="primaryFile && !isMissing && openFile(primaryFile)">
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
Read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem :disabled="!primaryFile || isMissing" @click="peekPrimaryFile">
|
||||
<Eye class="size-4 mr-2" />
|
||||
Peek
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="emit('action', 'quick-view')">
|
||||
<PanelRight class="size-4 mr-2" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, ExternalLink, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from 'lucide-vue-next'
|
||||
import { BookOpen, ExternalLink, Eye, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from 'lucide-vue-next'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -195,16 +195,24 @@ function handleCoverClick() {
|
||||
if (coverLoaded.value && !coverFailed.value) coverLightboxOpen.value = true
|
||||
}
|
||||
|
||||
function openBook() {
|
||||
function openBookWithMode(mode?: 'peek') {
|
||||
if (!primaryFile.value || !detail.value) return
|
||||
router.push({
|
||||
name: 'reader',
|
||||
params: { bookId: detail.value.id, fileId: primaryFile.value.id },
|
||||
query: { format: primaryFile.value.format ?? 'epub' },
|
||||
query: mode === 'peek' ? { format: primaryFile.value.format ?? 'epub', mode } : { format: primaryFile.value.format ?? 'epub' },
|
||||
})
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function openBook() {
|
||||
openBookWithMode()
|
||||
}
|
||||
|
||||
function peekBook() {
|
||||
openBookWithMode('peek')
|
||||
}
|
||||
|
||||
function editMetadata() {
|
||||
if (!detail.value) return
|
||||
router.push({ name: 'book-detail', params: { bookId: detail.value.id }, query: { tab: 'edit' } })
|
||||
@@ -455,6 +463,15 @@ function handleDelete() {
|
||||
<BookOpen v-else class="size-4" />
|
||||
<span class="hidden sm:inline">{{ isPrimaryAudio ? 'Listen' : 'Read' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-2 h-9 rounded-md border border-input bg-background text-sm font-medium hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
aria-label="Peek"
|
||||
@click="peekBook"
|
||||
>
|
||||
<Eye class="size-4" />
|
||||
<span class="hidden sm:inline">Peek</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center text-primary-foreground gap-2 h-9 rounded-md bg-sky-600 text-sm font-medium hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-600 transition-colors"
|
||||
aria-label="Details"
|
||||
|
||||
@@ -272,6 +272,19 @@ describe('BookTableReadButtonCell', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the primary readable file in peek mode from the peek button', async () => {
|
||||
routerPush.mockReset()
|
||||
const wrapper = mountReadButton(makeBook([{ id: 1, format: 'epub', role: 'primary', sizeBytes: null }]))
|
||||
const peekButton = wrapper.findAll('button').find((button) => button.attributes('aria-label') === 'Peek EPUB')
|
||||
expect(peekButton).toBeTruthy()
|
||||
await peekButton!.trigger('click')
|
||||
expect(routerPush).toHaveBeenCalledWith({
|
||||
name: 'reader',
|
||||
params: { bookId: 42, fileId: 1 },
|
||||
query: { format: 'epub', mode: 'peek' },
|
||||
})
|
||||
})
|
||||
|
||||
it('shows play action for audio formats and allows selecting alternate format', async () => {
|
||||
routerPush.mockReset()
|
||||
const wrapper = mountReadButton(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
Library,
|
||||
Headphones,
|
||||
Lock,
|
||||
@@ -749,22 +750,30 @@ function openEditCover() {
|
||||
router.push({ name: 'book-detail', params: { bookId: props.book.id }, query: { tab: 'edit' } })
|
||||
}
|
||||
|
||||
function openBook() {
|
||||
function openBookWithMode(mode?: 'peek') {
|
||||
if (!primaryFile.value) return
|
||||
router.push({
|
||||
name: 'reader',
|
||||
params: { bookId: props.book.id, fileId: primaryFile.value.id },
|
||||
query: { format: primaryFile.value.format ?? 'epub' },
|
||||
query: mode === 'peek' ? { format: primaryFile.value.format ?? 'epub', mode } : { format: primaryFile.value.format ?? 'epub' },
|
||||
})
|
||||
}
|
||||
|
||||
function openBookFile(file: BookDetail['files'][number]) {
|
||||
function openBook() {
|
||||
openBookWithMode()
|
||||
}
|
||||
|
||||
function peekBook() {
|
||||
openBookWithMode('peek')
|
||||
}
|
||||
|
||||
function openBookFile(file: BookDetail['files'][number], mode?: 'peek') {
|
||||
readMenuOpen.value = false
|
||||
mobileReadMenuOpen.value = false
|
||||
router.push({
|
||||
name: 'reader',
|
||||
params: { bookId: props.book.id, fileId: file.id },
|
||||
query: { format: file.format ?? 'epub' },
|
||||
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1075,6 +1084,19 @@ watch(
|
||||
<BookOpen v-else class="size-4" />
|
||||
{{ isPrimaryAudio ? 'Listen' : 'Read' }}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center justify-center h-9 w-12 rounded-md border border-input bg-background hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
aria-label="Peek"
|
||||
@click="peekBook"
|
||||
>
|
||||
<Eye class="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Peek</TooltipContent>
|
||||
</Tooltip>
|
||||
<div v-if="hasPermission('library_download')" class="w-12 shrink-0">
|
||||
<BookDownloadButton :files="book.files" :book-id="book.id" />
|
||||
</div>
|
||||
@@ -1143,58 +1165,74 @@ watch(
|
||||
</BookCoverSurface>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<!-- Read/Play button: split when multiple files, plain when single -->
|
||||
<div v-if="hasMultipleFiles" class="flex w-full h-9 rounded-md overflow-hidden">
|
||||
<div class="flex gap-2">
|
||||
<!-- Read/Play button: split when multiple files, plain when single -->
|
||||
<div v-if="hasMultipleFiles" class="flex flex-1 h-9 rounded-md overflow-hidden">
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
@click="openBook"
|
||||
>
|
||||
<BookOpen v-if="isPrimaryAudio" class="size-4" />
|
||||
<BookOpen v-else class="size-4" />
|
||||
{{ isPrimaryAudio ? 'Listen' : 'Read' }}
|
||||
</button>
|
||||
<div class="w-px bg-primary-foreground/20 shrink-0" />
|
||||
<Popover :open="readMenuOpen" @update:open="(v) => (readMenuOpen = v)">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="w-8 shrink-0 flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="Choose format"
|
||||
>
|
||||
<ChevronDown class="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-52 p-1" align="end">
|
||||
<button
|
||||
v-for="file in openableFiles"
|
||||
:key="file.id"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
@click="openBookFile(file)"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
|
||||
:style="formatBadgeStyle(file.format ?? '?')"
|
||||
>{{ file.format ?? '?' }}</span
|
||||
>
|
||||
<span class="flex-1 text-left text-muted-foreground text-xs truncate">
|
||||
<template v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</template>
|
||||
<template v-else>{{ formatFileSize(file.sizeBytes) }}</template>
|
||||
</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary font-medium shrink-0">Primary</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
v-else
|
||||
class="flex flex-1 items-center justify-center gap-2 h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
@click="openBook"
|
||||
>
|
||||
<BookOpen v-if="isPrimaryAudio" class="size-4" />
|
||||
<Headphones v-if="isPrimaryAudio" class="size-4" />
|
||||
<BookOpen v-else class="size-4" />
|
||||
{{ isPrimaryAudio ? 'Listen' : 'Read' }}
|
||||
</button>
|
||||
<div class="w-px bg-primary-foreground/20 shrink-0" />
|
||||
<Popover :open="readMenuOpen" @update:open="(v) => (readMenuOpen = v)">
|
||||
<PopoverTrigger as-child>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-8 shrink-0 flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="Choose format"
|
||||
class="flex items-center justify-center h-9 w-12 shrink-0 rounded-md border border-input bg-background hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
aria-label="Peek"
|
||||
@click="peekBook"
|
||||
>
|
||||
<ChevronDown class="size-3.5" />
|
||||
<Eye class="size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-52 p-1" align="end">
|
||||
<button
|
||||
v-for="file in openableFiles"
|
||||
:key="file.id"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
@click="openBookFile(file)"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
|
||||
:style="formatBadgeStyle(file.format ?? '?')"
|
||||
>{{ file.format ?? '?' }}</span
|
||||
>
|
||||
<span class="flex-1 text-left text-muted-foreground text-xs truncate">
|
||||
<template v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</template>
|
||||
<template v-else>{{ formatFileSize(file.sizeBytes) }}</template>
|
||||
</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary font-medium shrink-0">Primary</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Peek</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="flex w-full items-center justify-center gap-2 h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
@click="openBook"
|
||||
>
|
||||
<Headphones v-if="isPrimaryAudio" class="size-4" />
|
||||
<BookOpen v-else class="size-4" />
|
||||
{{ isPrimaryAudio ? 'Listen' : 'Read' }}
|
||||
</button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div v-if="hasPermission('library_download')" class="flex-1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Download, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical } from 'lucide-vue-next'
|
||||
import { BookOpen, Download, Eye, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical } from 'lucide-vue-next'
|
||||
import type { BookDetail, BookDetailFile, WriteLogEntry } from '@bookorbit/types'
|
||||
import { READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -102,11 +102,11 @@ function formatRelative(iso: string): string {
|
||||
return `${d}d ago`
|
||||
}
|
||||
|
||||
function openFile(file: BookDetailFile) {
|
||||
function openFile(file: BookDetailFile, mode?: 'peek') {
|
||||
router.push({
|
||||
name: 'reader',
|
||||
params: { bookId: props.book.id, fileId: file.id },
|
||||
query: { format: file.format ?? 'epub' },
|
||||
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -299,6 +299,14 @@ async function toggleWriteLog() {
|
||||
<Headphones class="size-3.5" />
|
||||
Play
|
||||
</button>
|
||||
<button
|
||||
v-if="READER_OPENABLE_FORMATS.has(file.format ?? '')"
|
||||
class="flex items-center gap-1.5 h-7 px-2.5 rounded border border-input bg-background text-xs font-medium hover:bg-muted transition-colors"
|
||||
@click="openFile(file, 'peek')"
|
||||
>
|
||||
<Eye class="size-3.5" />
|
||||
Peek
|
||||
</button>
|
||||
<Tooltip v-if="hasPermission('library_download')">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
@@ -331,6 +339,10 @@ async function toggleWriteLog() {
|
||||
<Headphones class="mr-2 size-4" />
|
||||
Play
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="READER_OPENABLE_FORMATS.has(file.format ?? '')" @click="openFile(file, 'peek')">
|
||||
<Eye class="mr-2 size-4" />
|
||||
Peek
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="hasPermission('library_download')" @click="downloadFile(file)">
|
||||
<Download class="mr-2 size-4" />
|
||||
Download
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, ChevronDown, Play } from 'lucide-vue-next'
|
||||
import { BookOpen, ChevronDown, Eye, Play } from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import type { BookCard, BookFileRef } from '@bookorbit/types'
|
||||
@@ -87,14 +87,22 @@ function formatBadgeStyle(format: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function openFile(file: BookFileRef | null) {
|
||||
function openFile(file: BookFileRef | null, mode?: 'peek') {
|
||||
if (!file || props.book.status === 'missing') return
|
||||
router.push({
|
||||
name: 'reader',
|
||||
params: { bookId: props.book.id, fileId: file.id },
|
||||
query: { format: file.format ?? 'epub' },
|
||||
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
|
||||
})
|
||||
}
|
||||
|
||||
function openPrimaryFile() {
|
||||
openFile(primaryFile.value)
|
||||
}
|
||||
|
||||
function peekPrimaryFile() {
|
||||
openFile(primaryFile.value, 'peek')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -105,7 +113,7 @@ function openFile(file: BookFileRef | null) {
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-l-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
:aria-label="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
|
||||
:title="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
|
||||
@click.stop="openFile(primaryFile)"
|
||||
@click.stop="openPrimaryFile"
|
||||
>
|
||||
<Play v-if="primaryIsAudio" :size="13" class="text-sky-500" />
|
||||
<BookOpen v-else :size="13" class="text-emerald-500" />
|
||||
@@ -133,21 +141,36 @@ function openFile(file: BookFileRef | null) {
|
||||
<span class="flex-1 truncate text-xs">{{ actionVerb(file) }} {{ formatLabel(file) }}</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary">Primary</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-for="file in openableFiles" :key="`peek-${file.id}`" class="gap-2" @select="openFile(file, 'peek')">
|
||||
<Eye :size="13" class="text-primary" />
|
||||
<span class="flex-1 truncate text-xs">Peek {{ formatLabel(file) }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="mr-auto inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
:aria-label="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
|
||||
:title="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
|
||||
@click.stop="openFile(primaryFile)"
|
||||
>
|
||||
<Play v-if="isAudioFile(primaryFile)" :size="13" class="text-sky-500" />
|
||||
<BookOpen v-else :size="13" class="text-emerald-500" />
|
||||
</button>
|
||||
<div v-else class="mr-auto inline-flex h-7 items-center overflow-hidden rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-l-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
:aria-label="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
|
||||
:title="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
|
||||
@click.stop="openPrimaryFile"
|
||||
>
|
||||
<Play v-if="isAudioFile(primaryFile)" :size="13" class="text-sky-500" />
|
||||
<BookOpen v-else :size="13" class="text-emerald-500" />
|
||||
</button>
|
||||
<div class="w-px shrink-0 bg-border/80" />
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-r-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
:aria-label="`Peek ${formatLabel(primaryFile)}`"
|
||||
:title="`Peek ${formatLabel(primaryFile)}`"
|
||||
@click.stop="peekPrimaryFile"
|
||||
>
|
||||
<Eye :size="13" class="text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span v-else class="text-xs text-muted-foreground/40">-</span>
|
||||
|
||||
@@ -43,6 +43,11 @@ const fileFormat = (route.query.format as string) || 'epub'
|
||||
const isAudioFormat = getFormatGroup(fileFormat) === 'audio'
|
||||
const isPdfFormat = fileFormat === 'pdf'
|
||||
const isComicFormat = fileFormat === 'cbz' || fileFormat === 'cbr' || fileFormat === 'cb7'
|
||||
const isPeekMode = computed(() => {
|
||||
const mode = route.query.mode
|
||||
return (Array.isArray(mode) ? mode[0] : mode) === 'peek'
|
||||
})
|
||||
const trackingEnabled = computed(() => !isPeekMode.value)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const showSidebar = ref(false)
|
||||
@@ -82,13 +87,17 @@ const {
|
||||
|
||||
const customFonts = useCustomFonts()
|
||||
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(fileId, () => ({
|
||||
percentage: progress.percentage.value,
|
||||
cfi: progress.cfi.value,
|
||||
pageNumber: progress.pageNumber.value,
|
||||
}))
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(
|
||||
fileId,
|
||||
() => ({
|
||||
percentage: progress.percentage.value,
|
||||
cfi: progress.cfi.value,
|
||||
pageNumber: progress.pageNumber.value,
|
||||
}),
|
||||
{ trackingEnabled },
|
||||
)
|
||||
|
||||
const progress = useReaderProgress(bookId, fileId, elapsedMinutes)
|
||||
const progress = useReaderProgress(bookId, fileId, elapsedMinutes, 0, { trackingEnabled })
|
||||
const { cfi, chapterTitle, sectionIndex, totalSections, fraction, locationTotal, footerMode, cycleFooterMode, updateHeadsFeet } = progress
|
||||
|
||||
const visibility = useVisibility()
|
||||
@@ -140,6 +149,15 @@ function toggleHelpModal() {
|
||||
showHelpModal.value = !showHelpModal.value
|
||||
}
|
||||
|
||||
async function startTrackedReading() {
|
||||
const query = { ...route.query }
|
||||
delete query.mode
|
||||
await router.replace({ name: 'reader', params: route.params, query })
|
||||
await nextTick()
|
||||
await progress.save()
|
||||
onActivity()
|
||||
}
|
||||
|
||||
const chapterStartFraction = computed(() => {
|
||||
const fracs = sectionFractions.value
|
||||
const idx = sectionIndex.value
|
||||
@@ -498,9 +516,9 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PdfV4ReaderView v-if="isPdfFormat" :bookId="bookId" :fileId="fileId" />
|
||||
<CbzReaderView v-else-if="isComicFormat" :bookId="bookId" :fileId="fileId" />
|
||||
<AudiobookReaderView v-else-if="isAudioFormat" :bookId="bookId" :fileId="fileId" />
|
||||
<PdfV4ReaderView v-if="isPdfFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
|
||||
<CbzReaderView v-else-if="isComicFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
|
||||
<AudiobookReaderView v-else-if="isAudioFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
|
||||
<div
|
||||
v-else
|
||||
class="fixed inset-0 overflow-hidden"
|
||||
@@ -513,6 +531,7 @@ watch(
|
||||
:isBookmarked="bookmarks.isCurrentCfiBookmarked.value"
|
||||
:settings-open="showSettings"
|
||||
:footerMode="footerMode"
|
||||
:peek-mode="isPeekMode"
|
||||
class="transition-all duration-300"
|
||||
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
|
||||
@back="router.back()"
|
||||
@@ -523,6 +542,7 @@ watch(
|
||||
@toggleFullscreen="toggleFullscreen"
|
||||
@toggleHelp="toggleHelpModal"
|
||||
@cycleFooterMode="cycleFooterMode"
|
||||
@startReading="startTrackedReading"
|
||||
>
|
||||
<template #settingsPanel>
|
||||
<ReaderSettingsPanel :state="state" :customFonts="customFonts" @update="applyUpdate" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch, type WatchStopHandle } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type WatchStopHandle } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Bookmark,
|
||||
BookmarkCheck,
|
||||
@@ -33,8 +33,10 @@ import { useAudioSettings } from './composables/useAudioSettings'
|
||||
import { useAudioBookmarks, type AudioBookmark } from './composables/useAudioBookmarks'
|
||||
import { useReadingSession } from '../shared/composables/useReadingSession'
|
||||
|
||||
const props = defineProps<{ bookId: number; fileId: number }>()
|
||||
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const trackingEnabled = computed(() => !props.peekMode)
|
||||
|
||||
const detail = ref<BookDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
@@ -65,7 +67,7 @@ const audioFiles = computed<BookDetailFile[]>(() => {
|
||||
|
||||
// ── Progress ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const progress = useAudioProgress(props.bookId)
|
||||
const progress = useAudioProgress(props.bookId, { trackingEnabled })
|
||||
|
||||
// ── Queue (created lazily after files load) ───────────────────────────────────
|
||||
|
||||
@@ -139,7 +141,7 @@ const audioBookmarks = useAudioBookmarks(props.bookId)
|
||||
|
||||
// ── Reading session ───────────────────────────────────────────────────────────
|
||||
|
||||
const session = useReadingSession(props.fileId, () => ({ percentage: progressPct.value }))
|
||||
const session = useReadingSession(props.fileId, () => ({ percentage: progressPct.value }), { trackingEnabled })
|
||||
|
||||
// ── Ticker (updates position every 500ms while playing) ──────────────────────
|
||||
|
||||
@@ -560,6 +562,19 @@ function handleScrubberLeave() {
|
||||
if (!isDragging.value) scrubberHoverSeconds.value = null
|
||||
}
|
||||
|
||||
async function startTrackedReading() {
|
||||
const query = { ...route.query }
|
||||
delete query.mode
|
||||
await router.replace({ name: 'reader', params: route.params, query })
|
||||
await nextTick()
|
||||
const fileId = audioFiles.value[currentFileIndex.value]?.id
|
||||
if (fileId) {
|
||||
progress.update(fileId, currentPosition.value, progressPct.value)
|
||||
progress.flush()
|
||||
}
|
||||
session.onActivity()
|
||||
}
|
||||
|
||||
// ── Computed helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const absolutePositionSeconds = computed(() => {
|
||||
@@ -776,6 +791,12 @@ onMounted(async () => {
|
||||
{{ detail.audioMetadata.narrators.map((n) => n.name).join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="props.peekMode" class="flex h-8 items-center gap-1 rounded-md border border-white/20 bg-white/10 px-1.5 text-white">
|
||||
<span class="hidden text-[11px] font-medium sm:inline">Peeking</span>
|
||||
<button class="h-6 rounded-sm bg-white px-2 text-[11px] font-semibold text-black hover:bg-white/90" @click="startTrackedReading">
|
||||
Start reading
|
||||
</button>
|
||||
</div>
|
||||
<!-- Bookmark toggle -->
|
||||
<button
|
||||
class="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { onUnmounted, ref, unref, type MaybeRef } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const SAVE_THROTTLE_MS = 5_000
|
||||
|
||||
export function useAudioProgress(bookId: number) {
|
||||
export interface AudioProgressOptions {
|
||||
trackingEnabled?: MaybeRef<boolean>
|
||||
}
|
||||
|
||||
export function useAudioProgress(bookId: number, options: AudioProgressOptions = {}) {
|
||||
const resumeFileId = ref<number | null>(null)
|
||||
const resumePosition = ref(0)
|
||||
const loaded = ref(false)
|
||||
const trackingEnabled = options.trackingEnabled ?? true
|
||||
|
||||
let pendingFileId: number | null = null
|
||||
let pendingPosition = 0
|
||||
@@ -15,6 +20,10 @@ export function useAudioProgress(bookId: number) {
|
||||
let dirty = false
|
||||
|
||||
async function load() {
|
||||
if (!unref(trackingEnabled)) {
|
||||
loaded.value = true
|
||||
return
|
||||
}
|
||||
const res = await api(`/api/v1/books/${bookId}/audio-progress`)
|
||||
// Mark loaded regardless of response so callers can distinguish
|
||||
// "load attempted" from "load not yet called".
|
||||
@@ -28,6 +37,7 @@ export function useAudioProgress(bookId: number) {
|
||||
}
|
||||
|
||||
function update(fileId: number, positionSeconds: number, percentage: number) {
|
||||
if (!unref(trackingEnabled)) return
|
||||
pendingFileId = fileId
|
||||
pendingPosition = positionSeconds
|
||||
pendingPercentage = percentage
|
||||
@@ -42,6 +52,7 @@ export function useAudioProgress(bookId: number) {
|
||||
}
|
||||
|
||||
function flushIfDirty() {
|
||||
if (!unref(trackingEnabled)) return
|
||||
if (!dirty || pendingFileId === null) return
|
||||
const body = JSON.stringify({
|
||||
percentage: pendingPercentage,
|
||||
@@ -59,6 +70,7 @@ export function useAudioProgress(bookId: number) {
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!unref(trackingEnabled)) return
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
AlignJustify,
|
||||
ArrowDownUp,
|
||||
@@ -37,16 +37,22 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/compon
|
||||
|
||||
const TWO_PAGE_BREAKPOINT = 900
|
||||
|
||||
const props = defineProps<{ bookId: number; fileId: number }>()
|
||||
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const trackingEnabled = computed(() => !props.peekMode)
|
||||
|
||||
const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter, setVisibilityLock } = useVisibility()
|
||||
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(props.fileId, () => ({
|
||||
percentage: progress.percentage.value,
|
||||
pageNumber: progress.pageNumber.value,
|
||||
}))
|
||||
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes)
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(
|
||||
props.fileId,
|
||||
() => ({
|
||||
percentage: progress.percentage.value,
|
||||
pageNumber: progress.pageNumber.value,
|
||||
}),
|
||||
{ trackingEnabled },
|
||||
)
|
||||
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes, 0, { trackingEnabled })
|
||||
const { pageCount, bookTitle, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId)
|
||||
const { fitMode, viewMode, scrollMode, direction, spreadAlignment, forceTwoPage, widePageSingletonMode, bgColor, bgValue, imgFitClass } =
|
||||
useCbzSettings()
|
||||
@@ -537,6 +543,17 @@ function onResize() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
async function startTrackedReading() {
|
||||
const query = { ...route.query }
|
||||
delete query.mode
|
||||
await router.replace({ name: 'reader', params: route.params, query })
|
||||
await nextTick()
|
||||
progress.pageNumber.value = progressPageIndex.value + 1
|
||||
progress.percentage.value = progressPercent.value
|
||||
await progress.save()
|
||||
onActivity()
|
||||
}
|
||||
|
||||
// ── Mount / unmount ────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
viewportWidth.value = window.innerWidth
|
||||
@@ -595,6 +612,15 @@ onUnmounted(() => {
|
||||
<span v-if="bookTitle" class="text-sm font-serif text-foreground truncate leading-tight">{{ bookTitle }}</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ pageLabel }}</span>
|
||||
</div>
|
||||
<div v-if="props.peekMode" class="flex h-7 items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-1.5 text-primary">
|
||||
<span class="hidden text-[11px] font-medium sm:inline">Peeking</span>
|
||||
<button
|
||||
class="h-5 rounded-sm bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
@click="startTrackedReading"
|
||||
>
|
||||
Start reading
|
||||
</button>
|
||||
</div>
|
||||
<DropdownMenu v-model:open="showSettings">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="viewer-btn" :class="showSettings ? '!bg-muted !text-foreground' : ''">
|
||||
|
||||
@@ -22,6 +22,7 @@ const props = defineProps<{
|
||||
isBookmarked: boolean
|
||||
settingsOpen: boolean
|
||||
footerMode: 0 | 1 | 2
|
||||
peekMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -33,6 +34,7 @@ const emit = defineEmits<{
|
||||
toggleFullscreen: []
|
||||
toggleHelp: []
|
||||
cycleFooterMode: []
|
||||
startReading: []
|
||||
}>()
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
@@ -105,6 +107,16 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
|
||||
|
||||
<!-- Right button group -->
|
||||
<div class="flex items-center gap-1 shrink-0 ml-auto">
|
||||
<div v-if="props.peekMode" class="flex h-7 items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-1.5 text-primary">
|
||||
<span class="hidden text-[11px] font-medium sm:inline">Peeking</span>
|
||||
<button
|
||||
class="h-5 rounded-sm bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 sm:h-6 sm:px-2 sm:text-[11px]"
|
||||
@click="emit('startReading')"
|
||||
>
|
||||
Start reading
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" aria-label="Search" @click="emit('toggleSearch')">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ReaderSidebar from '../ReaderSidebar.vue'
|
||||
|
||||
describe('ReaderSidebar', () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
const scrollIntoView = vi.fn<() => void>()
|
||||
|
||||
beforeEach(() => {
|
||||
scrollIntoView.mockClear()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { PDFViewer, ScrollPlugin, ScrollStrategy, SpreadMode, ZoomMode } from '@embedpdf/vue-pdf-viewer'
|
||||
import type { PDFViewerConfig, PluginRegistry, ScrollCapability, EmbedPdfContainer } from '@embedpdf/vue-pdf-viewer'
|
||||
import { getAccessToken } from '@/lib/api'
|
||||
@@ -10,16 +11,23 @@ import { useThemeStore, ACCENT_OPTIONS } from '@/stores/theme'
|
||||
import type { PdfReaderSettings } from '@bookorbit/types'
|
||||
import { getIsDark, lookupAccentHex } from './pdf-viewer-utils'
|
||||
|
||||
const props = defineProps<{ bookId: number; fileId: number }>()
|
||||
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const trackingEnabled = computed(() => !props.peekMode)
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const bookSettings = useReaderSettings(props.fileId, 'pdf')
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(props.fileId, () => ({
|
||||
percentage: progress.percentage.value,
|
||||
pageNumber: progress.pageNumber.value,
|
||||
}))
|
||||
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes)
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(
|
||||
props.fileId,
|
||||
() => ({
|
||||
percentage: progress.percentage.value,
|
||||
pageNumber: progress.pageNumber.value,
|
||||
}),
|
||||
{ trackingEnabled },
|
||||
)
|
||||
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes, 0, { trackingEnabled })
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let unsubPageChange: (() => void) | null = null
|
||||
@@ -38,6 +46,8 @@ function buildThemeConfig() {
|
||||
}
|
||||
|
||||
function scheduleSave(pageNumber: number, totalPages: number) {
|
||||
currentPageNumber.value = pageNumber
|
||||
totalPageCount.value = totalPages
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
progress.pageNumber.value = pageNumber
|
||||
@@ -48,6 +58,8 @@ function scheduleSave(pageNumber: number, totalPages: number) {
|
||||
|
||||
const configReady = ref(false)
|
||||
const viewerConfig = ref<PDFViewerConfig>({})
|
||||
const currentPageNumber = ref<number | null>(null)
|
||||
const totalPageCount = ref<number | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await bookSettings.load()
|
||||
@@ -116,6 +128,8 @@ async function setupProgressTracking(registry: PluginRegistry) {
|
||||
unsubLayoutReady = scroll.onLayoutReady(({ isInitial, totalPages }) => {
|
||||
if (!isInitial) return
|
||||
const initialPage = progress.pageNumber.value ?? 1
|
||||
currentPageNumber.value = initialPage
|
||||
totalPageCount.value = totalPages
|
||||
if (initialPage > 1 && initialPage <= totalPages) {
|
||||
scroll.scrollToPage({ pageNumber: initialPage })
|
||||
}
|
||||
@@ -152,6 +166,19 @@ function setupThemeSync() {
|
||||
})
|
||||
}
|
||||
|
||||
async function startTrackedReading() {
|
||||
const query = { ...route.query }
|
||||
delete query.mode
|
||||
await router.replace({ name: 'reader', params: route.params, query })
|
||||
await nextTick()
|
||||
if (currentPageNumber.value !== null) {
|
||||
progress.pageNumber.value = currentPageNumber.value
|
||||
progress.percentage.value = totalPageCount.value ? (currentPageNumber.value / totalPageCount.value) * 100 : progress.percentage.value
|
||||
}
|
||||
await progress.save()
|
||||
onActivity()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubPageChange?.()
|
||||
unsubLayoutReady?.()
|
||||
@@ -162,6 +189,17 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<div v-if="props.peekMode" class="pointer-events-none absolute left-0 right-0 top-3 z-50 flex justify-center">
|
||||
<div class="pointer-events-auto flex h-8 items-center gap-2 rounded-md border border-primary/30 bg-background/95 px-2 text-primary shadow-sm">
|
||||
<span class="text-xs font-medium">Peeking</span>
|
||||
<button
|
||||
class="h-6 rounded-sm bg-primary px-2 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
@click="startTrackedReading"
|
||||
>
|
||||
Start reading
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PDFViewer v-if="configReady" :config="viewerConfig" style="flex: 1; min-height: 0" @init="handleInit" @ready="handleReady" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -193,4 +193,20 @@ describe('useReaderProgress', () => {
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps relocate state in memory without loading or saving when tracking is disabled', async () => {
|
||||
vi.useFakeTimers()
|
||||
const trackingEnabled = ref(false)
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes, 0, { trackingEnabled })
|
||||
|
||||
await progress.load()
|
||||
progress.onRelocate(makeDetail({ fraction: 0.42 }))
|
||||
await progress.save()
|
||||
await vi.advanceTimersByTimeAsync(2500)
|
||||
|
||||
expect(progress.percentage.value).toBe(42)
|
||||
expect(apiMock).not.toHaveBeenCalled()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useReadingSession } from '../useReadingSession'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<unknown>>())
|
||||
@@ -94,4 +95,16 @@ describe('useReadingSession - elapsedMinutes', () => {
|
||||
onActivity()
|
||||
expect(elapsedMinutes.value).toBe(0)
|
||||
})
|
||||
|
||||
it('does not track elapsed time or save sessions when tracking is disabled', async () => {
|
||||
const trackingEnabled = ref(false)
|
||||
const { onActivity, elapsedMinutes, endSession } = useReadingSession(1, () => ({ percentage: 10 }), { trackingEnabled })
|
||||
|
||||
onActivity()
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 1000)
|
||||
endSession()
|
||||
|
||||
expect(elapsedMinutes.value).toBe(0)
|
||||
expect(apiMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { onUnmounted, ref, type Ref } from 'vue'
|
||||
import { onUnmounted, ref, unref, type MaybeRef, type Ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { FoliateRenderer, RelocateDetail } from '../../epub/composables/useFoliate'
|
||||
|
||||
export type FooterDisplayMode = 0 | 1 | 2
|
||||
|
||||
export interface ReaderProgressOptions {
|
||||
trackingEnabled?: MaybeRef<boolean>
|
||||
}
|
||||
|
||||
export function formatTimeRemaining(minutes: number): string {
|
||||
if (!Number.isFinite(minutes) || minutes < 0) return ''
|
||||
if (minutes < 1) return '< 1 min'
|
||||
@@ -14,7 +18,13 @@ export function formatTimeRemaining(minutes: number): string {
|
||||
return `${hours} hr ${remainder} min`
|
||||
}
|
||||
|
||||
export function useReaderProgress(bookId: number, fileId: number, elapsedMinutes: Ref<number>, initialFooterMode: FooterDisplayMode = 0) {
|
||||
export function useReaderProgress(
|
||||
bookId: number,
|
||||
fileId: number,
|
||||
elapsedMinutes: Ref<number>,
|
||||
initialFooterMode: FooterDisplayMode = 0,
|
||||
options: ReaderProgressOptions = {},
|
||||
) {
|
||||
const cfi = ref<string | null>(null)
|
||||
const pageNumber = ref<number | null>(null)
|
||||
const percentage = ref(0)
|
||||
@@ -31,6 +41,7 @@ export function useReaderProgress(bookId: number, fileId: number, elapsedMinutes
|
||||
const timeTotal = ref(0)
|
||||
|
||||
const footerMode = ref<FooterDisplayMode>(initialFooterMode)
|
||||
const trackingEnabled = options.trackingEnabled ?? true
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -39,6 +50,7 @@ export function useReaderProgress(bookId: number, fileId: number, elapsedMinutes
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!unref(trackingEnabled)) return
|
||||
const res = await api(`/api/v1/books/files/${fileId}/progress`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
@@ -63,10 +75,12 @@ export function useReaderProgress(bookId: number, fileId: number, elapsedMinutes
|
||||
timeTotal.value = detail?.time?.total ?? 0
|
||||
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
if (!unref(trackingEnabled)) return
|
||||
saveTimer = setTimeout(() => save(), 2000)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!unref(trackingEnabled)) return
|
||||
await api(`/api/v1/books/files/${fileId}/progress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { onUnmounted, ref, unref, type MaybeRef } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export interface ProgressSnapshot {
|
||||
@@ -11,13 +11,17 @@ const IDLE_TIMEOUT_MS = 5 * 60 * 1000
|
||||
const MIN_SESSION_MS = 10 * 1000
|
||||
const ELAPSED_UPDATE_INTERVAL_MS = 30 * 1000
|
||||
|
||||
export interface ReadingSessionOptions {
|
||||
trackingEnabled?: MaybeRef<boolean>
|
||||
}
|
||||
|
||||
function generateSessionId(): string {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` // codeql[js/insecure-randomness] - session IDs are non-security deduplication keys
|
||||
}
|
||||
|
||||
export function useReadingSession(bookFileId: number, getProgress: () => ProgressSnapshot) {
|
||||
export function useReadingSession(bookFileId: number, getProgress: () => ProgressSnapshot, options: ReadingSessionOptions = {}) {
|
||||
let sessionId = generateSessionId()
|
||||
let startedAt: Date | null = null
|
||||
let activeMs = 0
|
||||
@@ -28,6 +32,11 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
|
||||
|
||||
const elapsedMinutes = ref(0)
|
||||
let elapsedInterval: ReturnType<typeof setInterval> | null = null
|
||||
const trackingEnabled = options.trackingEnabled ?? true
|
||||
|
||||
function canTrack(): boolean {
|
||||
return unref(trackingEnabled)
|
||||
}
|
||||
|
||||
function getActiveMs(): number {
|
||||
if (activeStart === null) return activeMs
|
||||
@@ -92,6 +101,7 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
|
||||
}
|
||||
|
||||
function onActivity() {
|
||||
if (!canTrack()) return
|
||||
// No active session or previous session ended (e.g. after idle timeout) - start fresh.
|
||||
if (!startedAt || ended) {
|
||||
sessionId = generateSessionId()
|
||||
@@ -103,6 +113,16 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
|
||||
}
|
||||
|
||||
function endSession(useBeacon = false) {
|
||||
if (!canTrack()) {
|
||||
clearIdleTimer()
|
||||
stopElapsedInterval()
|
||||
startedAt = null
|
||||
activeStart = null
|
||||
activeMs = 0
|
||||
ended = true
|
||||
elapsedMinutes.value = 0
|
||||
return
|
||||
}
|
||||
if (ended || !startedAt) return
|
||||
ended = true
|
||||
clearIdleTimer()
|
||||
|
||||
Reference in New Issue
Block a user