mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix: remove KoreaderBookSyncCard, fix missing initial library load, add mediaSrc CSP, return null from getBookProgress instead of 404
This commit is contained in:
@@ -15,7 +15,6 @@ import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/fe
|
||||
import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue'
|
||||
import DiscoverRow from '@/features/book/components/detail/DiscoverRow.vue'
|
||||
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
|
||||
import KoreaderBookSyncCard from '@/features/koreader/components/KoreaderBookSyncCard.vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
@@ -1404,13 +1403,6 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KoreaderBookSyncCard
|
||||
v-if="canViewKoreader && koreaderBookProgress"
|
||||
class="mt-6"
|
||||
:book-id="props.book.id"
|
||||
:book-progress="koreaderBookProgress"
|
||||
/>
|
||||
|
||||
<!-- Synopsis -->
|
||||
<div class="mt-6 pt-5 border-t border-border">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground mb-3">Synopsis</p>
|
||||
|
||||
@@ -293,10 +293,11 @@ describe('useLibraryBooks', () => {
|
||||
|
||||
it('includes filter, collapseSeries, and trimmed q in the request body', async () => {
|
||||
const { useLibraryBooks } = await import('../useLibraryBooks')
|
||||
const libraryId = ref<number | null>(7)
|
||||
const libraryId = ref<number | null>(null)
|
||||
const collapseEnabled = ref(true)
|
||||
const q = ref(' needle ')
|
||||
const { filter, sort, load } = useLibraryBooks(libraryId, collapseEnabled, q)
|
||||
const { filter, sort } = useLibraryBooks(libraryId, collapseEnabled, q)
|
||||
|
||||
filter.value = {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
@@ -305,7 +306,8 @@ describe('useLibraryBooks', () => {
|
||||
sort.value = [{ field: 'author', dir: 'desc' }]
|
||||
|
||||
mockOkResponse(makePage([], 0))
|
||||
await load(true)
|
||||
libraryId.value = 7
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('/api/v1/libraries/7/books')
|
||||
|
||||
@@ -141,12 +141,8 @@ export function useLibraryBooks(libraryId: Ref<number | null>, collapseEnabled:
|
||||
pagination.page = 0
|
||||
}
|
||||
|
||||
watch(libraryId, (newId, oldId) => {
|
||||
if (newId !== oldId) load(true)
|
||||
})
|
||||
|
||||
watch(
|
||||
sort,
|
||||
[libraryId, sort] as const,
|
||||
() => {
|
||||
void load(true)
|
||||
},
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { Smartphone, AlertTriangle } from 'lucide-vue-next'
|
||||
import type { KoreaderBookSyncInfo } from '@bookorbit/types'
|
||||
import { useKoreaderBookProgress } from '@/features/koreader/composables/useKoreaderBookProgress'
|
||||
|
||||
const props = defineProps<{ bookId: number; bookProgress?: KoreaderBookSyncInfo | null }>()
|
||||
const { bookProgress: fetchedProgress, loading, fetchBookProgress } = useKoreaderBookProgress()
|
||||
const displayProgress = computed(() => props.bookProgress ?? fetchedProgress.value)
|
||||
const isLoading = computed(() => (props.bookProgress === undefined ? loading.value : false))
|
||||
|
||||
onMounted(() => {
|
||||
if (props.bookProgress === undefined) {
|
||||
fetchBookProgress(props.bookId)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.bookId,
|
||||
(newId) => {
|
||||
if (props.bookProgress === undefined) {
|
||||
fetchBookProgress(newId)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function formatPercentage(pct: number): string {
|
||||
return `${Math.round(pct)}%`
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!isLoading && displayProgress" class="border border-border rounded-lg bg-card shadow-xs overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Smartphone :size="14" class="text-muted-foreground" />
|
||||
<span class="text-xs font-medium text-foreground">KOReader Sync</span>
|
||||
</div>
|
||||
<div v-if="displayProgress.fileModifiedSinceLastSync" class="flex items-center gap-1 text-amber-500">
|
||||
<AlertTriangle :size="12" />
|
||||
<span class="text-[10px] font-medium">File modified</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">Progress</span>
|
||||
<span class="text-xs font-medium text-foreground">{{ formatPercentage(displayProgress.canonicalPercentage) }}</span>
|
||||
</div>
|
||||
<div class="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary rounded-full transition-all" :style="{ width: `${displayProgress.canonicalPercentage}%` }" />
|
||||
</div>
|
||||
<div v-if="displayProgress.canonicalChapterTitle" class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">Chapter</span>
|
||||
<span class="text-xs text-foreground truncate max-w-[60%]">{{ displayProgress.canonicalChapterTitle }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">Source</span>
|
||||
<span class="text-xs text-foreground">{{ displayProgress.canonicalSource === 'koreader' ? 'KOReader' : 'Web Reader' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">Updated</span>
|
||||
<span class="text-xs text-foreground">{{ formatDate(displayProgress.canonicalUpdatedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Per-device breakdown -->
|
||||
<div v-if="displayProgress.devices.length > 1" class="pt-2 border-t border-border space-y-1.5">
|
||||
<p class="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Devices</p>
|
||||
<div v-for="device in displayProgress.devices" :key="device.deviceId" class="flex items-center justify-between text-xs">
|
||||
<span class="text-muted-foreground truncate max-w-[40%]">{{ device.device }}</span>
|
||||
<span class="text-foreground">{{ formatPercentage(device.percentage) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -80,8 +80,20 @@ describe('useKoreaderBookProgress', () => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('fetchBookProgress sets bookProgress to null on 404', async () => {
|
||||
apiMock.mockResolvedValueOnce(makeResponse({}, { ok: false, status: 404 }))
|
||||
it('fetchBookProgress sets bookProgress to null when server returns null (no koreader data)', async () => {
|
||||
apiMock.mockResolvedValueOnce(makeResponse(null))
|
||||
|
||||
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
|
||||
const { bookProgress, fetchBookProgress } = useKoreaderBookProgress()
|
||||
bookProgress.value = makeBookProgress()
|
||||
|
||||
await fetchBookProgress(42)
|
||||
|
||||
expect(bookProgress.value).toBeNull()
|
||||
})
|
||||
|
||||
it('fetchBookProgress sets bookProgress to null on error response', async () => {
|
||||
apiMock.mockResolvedValueOnce(makeResponse({}, { ok: false, status: 500 }))
|
||||
|
||||
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
|
||||
const { bookProgress, fetchBookProgress } = useKoreaderBookProgress()
|
||||
|
||||
@@ -10,10 +10,6 @@ export function useKoreaderBookProgress() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api(`/api/v1/koreader/books/${bookId}/progress`)
|
||||
if (res.status === 404) {
|
||||
bookProgress.value = null
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error('Failed to fetch book progress')
|
||||
bookProgress.value = await res.json()
|
||||
} catch {
|
||||
|
||||
@@ -98,21 +98,21 @@ describe('useFoliate.open', () => {
|
||||
expect(mockGoToFraction).toHaveBeenCalledWith(0.42)
|
||||
})
|
||||
|
||||
it('does not navigate when cfi is null and fraction is 0', async () => {
|
||||
it('navigates to position 0 when cfi is null and fraction is 0', async () => {
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 1, 'epub', null, 0)
|
||||
|
||||
expect(mockGoTo).not.toHaveBeenCalled()
|
||||
expect(mockGoTo).toHaveBeenCalledWith(0)
|
||||
expect(mockGoToFraction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not navigate when cfi is null and fraction is undefined', async () => {
|
||||
it('navigates to position 0 when cfi is null and fraction is undefined', async () => {
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 1, 'epub', null, undefined)
|
||||
|
||||
expect(mockGoTo).not.toHaveBeenCalled()
|
||||
expect(mockGoTo).toHaveBeenCalledWith(0)
|
||||
expect(mockGoToFraction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -136,6 +136,8 @@ export function useFoliate(
|
||||
await view.goTo(cfi).catch(() => {})
|
||||
} else if (fallbackFraction !== undefined && fallbackFraction > 0) {
|
||||
view.goToFraction?.(fallbackFraction)
|
||||
} else {
|
||||
await view.goTo(0).catch(() => {})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[useFoliate]', e)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, provide, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, onUnmounted, provide, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowUpDown,
|
||||
@@ -339,10 +339,6 @@ const { sentinel } = useInfiniteScrollSentinel({
|
||||
loadMore: () => load(),
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
load(true)
|
||||
})
|
||||
|
||||
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
|
||||
if (event.uploadedCount === 0) return
|
||||
if (libraryId.value === event.libraryId) {
|
||||
|
||||
@@ -103,6 +103,7 @@ async function bootstrap() {
|
||||
styleSrc: ["'self'", "'unsafe-inline'", 'blob:'],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
connectSrc: cspConnectSrc,
|
||||
mediaSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:', 'blob:'],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'self'", 'blob:'],
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Headers,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, ParseIntPipe, Patch, Post, Put, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
@@ -100,10 +86,8 @@ export class KoreaderController {
|
||||
|
||||
@RequirePermission(Permission.KoreaderSync)
|
||||
@Get('books/:bookId/progress')
|
||||
async getBookProgress(@CurrentUser() user: RequestUser, @Param('bookId', ParseIntPipe) bookId: number) {
|
||||
const progress = await this.koreaderService.getBookProgress(user.id, bookId);
|
||||
if (!progress) throw new NotFoundException('No KOReader sync data found for this book');
|
||||
return progress;
|
||||
getBookProgress(@CurrentUser() user: RequestUser, @Param('bookId', ParseIntPipe) bookId: number) {
|
||||
return this.koreaderService.getBookProgress(user.id, bookId);
|
||||
}
|
||||
|
||||
@RequirePermission(Permission.KoreaderSync)
|
||||
|
||||
Reference in New Issue
Block a user