mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add empty states and improve sidebar badge styling
This commit is contained in:
@@ -33,6 +33,13 @@ export default defineConfigWithVueTs(
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['src/**/*.vue'],
|
||||
rules: {
|
||||
'vue/v-on-handler-style': ['warn', ['method', 'inline-function']],
|
||||
},
|
||||
},
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
|
||||
@@ -57,4 +57,3 @@
|
||||
--shadow-xl: var(--elevation-xl);
|
||||
--shadow-2xl: var(--elevation-2xl);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,4 +10,3 @@ html.radius-rounded {
|
||||
html.radius-pill {
|
||||
--radius: 2.5rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import * as Icons from 'lucide-vue-next'
|
||||
import { Aperture, FolderOpen, LayoutDashboard, PackageOpen, Users } from 'lucide-vue-next'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -171,7 +170,7 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
<SidebarNavItem
|
||||
:is-active="isDashboardActive"
|
||||
tooltip="Dashboard"
|
||||
:icon="LayoutDashboard"
|
||||
:icon="Icons.LayoutDashboard"
|
||||
label="Dashboard"
|
||||
@click="router.push({ name: 'dashboard' })"
|
||||
/>
|
||||
@@ -179,20 +178,26 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
v-if="hasPermission('staging_access')"
|
||||
:is-active="isStagingActive"
|
||||
tooltip="Staging"
|
||||
:icon="PackageOpen"
|
||||
:icon="Icons.PackageOpen"
|
||||
label="Staging"
|
||||
@click="router.push({ name: 'staging' })"
|
||||
>
|
||||
<template #badge>
|
||||
<span
|
||||
v-if="stagingSummary.total > 0"
|
||||
class="ml-auto shrink-0 rounded-md bg-sidebar-foreground/10 px-1.5 py-px text-[10px] font-medium tabular-nums text-sidebar-foreground/60 transition-colors group-data-[active=true]/item:bg-primary/20 group-data-[active=true]/item:text-primary group-data-[collapsible=icon]:hidden"
|
||||
class="ml-auto shrink-0 rounded-md bg-sidebar-foreground/15 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-sidebar-foreground/80 transition-colors group-data-[active=true]/item:bg-primary/20 group-data-[active=true]/item:text-primary group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{{ stagingSummary.total }}
|
||||
</span>
|
||||
</template>
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem :is-active="isAuthorsActive" tooltip="Authors" :icon="Users" label="Authors" @click="router.push({ name: 'authors' })" />
|
||||
<SidebarNavItem
|
||||
:is-active="isAuthorsActive"
|
||||
tooltip="Authors"
|
||||
:icon="Icons.Users"
|
||||
label="Authors"
|
||||
@click="router.push({ name: 'authors' })"
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -226,13 +231,13 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
<template #badge>
|
||||
<span
|
||||
v-if="getProgress(lib.id)?.status === 'running'"
|
||||
class="ml-auto shrink-0 rounded-md bg-primary/20 px-1.5 py-px text-[10px] font-medium tabular-nums text-primary group-data-[collapsible=icon]:hidden"
|
||||
class="ml-auto shrink-0 rounded-md bg-primary/20 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-primary group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{{ scanPct(lib.id) }}%
|
||||
</span>
|
||||
<span
|
||||
v-else-if="lib.bookCount !== undefined"
|
||||
class="ml-auto shrink-0 rounded-md bg-sidebar-foreground/10 px-1.5 py-px text-[10px] font-medium tabular-nums text-sidebar-foreground/60 transition-colors group-data-[active=true]/item:bg-primary/20 group-data-[active=true]/item:text-primary group-data-[collapsible=icon]:hidden"
|
||||
class="ml-auto shrink-0 rounded-md bg-sidebar-foreground/15 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-sidebar-foreground/80 transition-colors group-data-[active=true]/item:bg-primary/20 group-data-[active=true]/item:text-primary group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{{ lib.bookCount.toLocaleString() }}
|
||||
</span>
|
||||
@@ -282,14 +287,14 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
:key="lens.id"
|
||||
:is-active="activeLensId === lens.id"
|
||||
:tooltip="lens.name"
|
||||
:icon="resolveIcon(lens.icon, Aperture)"
|
||||
:icon="resolveIcon(lens.icon, Icons.Aperture)"
|
||||
:label="lens.name"
|
||||
@click="router.push({ name: 'lens', params: { id: lens.id } })"
|
||||
>
|
||||
<template #badge>
|
||||
<span
|
||||
v-if="lens.bookCount != null && lens.bookCount > 0"
|
||||
class="ml-auto shrink-0 rounded-md bg-sidebar-foreground/10 px-1.5 py-px text-[10px] font-medium tabular-nums text-sidebar-foreground/60 transition-colors group-data-[active=true]/item:bg-primary/20 group-data-[active=true]/item:text-primary group-data-[collapsible=icon]:hidden"
|
||||
class="ml-auto shrink-0 rounded-md bg-sidebar-foreground/15 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-sidebar-foreground/80 transition-colors group-data-[active=true]/item:bg-primary/20 group-data-[active=true]/item:text-primary group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{{ lens.bookCount.toLocaleString() }}
|
||||
</span>
|
||||
@@ -324,14 +329,14 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
:key="collection.id"
|
||||
:is-active="activeCollectionId === collection.id"
|
||||
:tooltip="collection.name"
|
||||
:icon="resolveIcon(collection.icon, FolderOpen)"
|
||||
:icon="resolveIcon(collection.icon, Icons.FolderOpen)"
|
||||
:label="collection.name"
|
||||
@click="router.push({ name: 'collection', params: { id: collection.id } })"
|
||||
>
|
||||
<template #badge>
|
||||
<span
|
||||
v-if="collection.bookCount > 0"
|
||||
class="ml-auto shrink-0 rounded-md bg-sidebar-foreground/10 px-1.5 py-px text-[10px] font-medium tabular-nums text-sidebar-foreground/60 transition-colors group-data-[active=true]/item:bg-primary/20 group-data-[active=true]/item:text-primary group-data-[collapsible=icon]:hidden"
|
||||
class="ml-auto shrink-0 rounded-md bg-sidebar-foreground/15 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-sidebar-foreground/80 transition-colors group-data-[active=true]/item:bg-primary/20 group-data-[active=true]/item:text-primary group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{{ collection.bookCount.toLocaleString() }}
|
||||
</span>
|
||||
|
||||
@@ -16,7 +16,14 @@ const { toggleSidebar } = useSidebar()
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button data-sidebar="trigger" data-slot="sidebar-trigger" variant="ghost" size="icon" :class="cn('h-7 w-7', props.class)" @click="toggleSidebar">
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="cn('h-7 w-7', props.class)"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<PanelLeft />
|
||||
<span class="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
|
||||
@@ -61,7 +61,7 @@ function handleDelete() {
|
||||
@click="handleClick($event as MouseEvent)"
|
||||
>
|
||||
<div
|
||||
class="relative w-full rounded-sm overflow-hidden shadow-md transition-all duration-150"
|
||||
class="relative w-full rounded-sm overflow-hidden shadow-md transition-[box-shadow,transform,ring] duration-150 will-change-transform"
|
||||
:class="[selectionMode ? '' : 'group-hover:shadow-xl group-hover:scale-[1.02]', selected ? 'ring-2 ring-primary' : '']"
|
||||
style="aspect-ratio: 2/3"
|
||||
:style="hasImage ? undefined : fallbackStyle"
|
||||
@@ -141,7 +141,7 @@ function handleDelete() {
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="refreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/30 backdrop-blur-[1.5px]">
|
||||
<div v-if="refreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/50">
|
||||
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -129,7 +129,7 @@ function openAuthorBrowse() {
|
||||
>
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="relative w-full rounded-sm overflow-hidden shadow-md transition-all duration-150"
|
||||
class="relative w-full rounded-sm overflow-hidden shadow-md transition-[box-shadow,transform,ring] duration-150 will-change-transform"
|
||||
:class="[
|
||||
isMissing ? 'ring-2 ring-amber-500' : selectionMode ? '' : 'group-hover:shadow-xl group-hover:scale-[1.02]',
|
||||
selected ? 'ring-2 ring-primary' : '',
|
||||
@@ -143,6 +143,7 @@ function openAuthorBrowse() {
|
||||
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-200"
|
||||
:class="coverLoaded ? (isMissing ? 'opacity-100 brightness-50' : 'opacity-100') : 'opacity-0'"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
:alt="book.title ?? ''"
|
||||
@load="coverLoaded = true"
|
||||
@error="coverFailed = true"
|
||||
@@ -151,7 +152,7 @@ function openAuthorBrowse() {
|
||||
<!-- Series badge -->
|
||||
<div v-if="showSeriesOverlay" class="absolute top-1.5 left-1.5 right-1.5 z-10 pointer-events-none">
|
||||
<span
|
||||
class="text-[8px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-black/30 backdrop-blur-sm line-clamp-1"
|
||||
class="text-[8px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-black/60 line-clamp-1"
|
||||
:style="{ color: coverStyle.color }"
|
||||
>
|
||||
{{ seriesLine }}
|
||||
@@ -172,11 +173,11 @@ function openAuthorBrowse() {
|
||||
<div v-if="showProgressPill">
|
||||
<span
|
||||
v-if="book.readingProgress === 100"
|
||||
class="flex items-center gap-0.5 text-[8px] font-semibold px-1.5 py-0.5 rounded bg-green-500/70 text-white backdrop-blur-sm"
|
||||
class="flex items-center gap-0.5 text-[8px] font-semibold px-1.5 py-0.5 rounded bg-green-600/90 text-white"
|
||||
>
|
||||
<Check :size="8" />
|
||||
</span>
|
||||
<span v-else class="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/30 backdrop-blur-sm" :style="{ color: coverStyle.color }">
|
||||
<span v-else class="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/60" :style="{ color: coverStyle.color }">
|
||||
{{ Math.floor(book.readingProgress!) }}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -215,9 +216,7 @@ function openAuthorBrowse() {
|
||||
|
||||
<!-- Missing badge -->
|
||||
<div v-if="isMissing" class="absolute top-1.5 right-1.5 z-20">
|
||||
<span
|
||||
class="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-amber-500/90 text-white backdrop-blur-sm"
|
||||
>
|
||||
<span class="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-amber-600/95 text-white">
|
||||
<TriangleAlert class="size-2.5 shrink-0" />
|
||||
Missing
|
||||
</span>
|
||||
@@ -240,7 +239,7 @@ function openAuthorBrowse() {
|
||||
|
||||
<!-- Refresh spinner overlay -->
|
||||
<Transition name="fade">
|
||||
<div v-if="anyRefreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/30 backdrop-blur-[1.5px]">
|
||||
<div v-if="anyRefreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/50">
|
||||
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -248,7 +247,7 @@ function openAuthorBrowse() {
|
||||
<!-- Hover overlay -->
|
||||
<div
|
||||
v-if="!selectionMode"
|
||||
class="absolute inset-0 flex flex-col p-2 bg-black/60 backdrop-blur-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
class="absolute inset-0 flex flex-col p-2 bg-black/80 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Center: primary action buttons -->
|
||||
|
||||
@@ -42,4 +42,3 @@ const emit = defineEmits<{ confirm: []; cancel: [] }>()
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -23,25 +23,18 @@ defineProps<{
|
||||
</span>
|
||||
|
||||
<!-- Leaf rule: color-coded field / operator / value -->
|
||||
<span
|
||||
v-if="rule.type === 'rule'"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border bg-muted/40 border-border/60"
|
||||
>
|
||||
<span v-if="rule.type === 'rule'" class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border bg-muted/40 border-border/60">
|
||||
<span class="font-semibold text-foreground">{{ ruleToParts(rule as Rule).field }}</span>
|
||||
<span
|
||||
class="font-normal"
|
||||
:class="ruleToParts(rule as Rule).value === null ? 'text-primary font-semibold' : 'text-muted-foreground'"
|
||||
>{{ ruleToParts(rule as Rule).operator }}</span>
|
||||
<span class="font-normal" :class="ruleToParts(rule as Rule).value === null ? 'text-primary font-semibold' : 'text-muted-foreground'">{{
|
||||
ruleToParts(rule as Rule).operator
|
||||
}}</span>
|
||||
<span v-if="ruleToParts(rule as Rule).value !== null" class="font-semibold text-primary">
|
||||
{{ ruleToParts(rule as Rule).value }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Nested group -->
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex flex-wrap items-center gap-1 px-2 py-0.5 rounded-lg border border-border/50 bg-muted/30"
|
||||
>
|
||||
<span v-else class="inline-flex flex-wrap items-center gap-1 px-2 py-0.5 rounded-lg border border-border/50 bg-muted/30">
|
||||
<FilterSummary :node="rule as GroupRule" :depth="(depth ?? 0) + 1" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('BookCoverCard — missing state', () => {
|
||||
|
||||
it('renders the amber missing badge with TriangleAlert icon', () => {
|
||||
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
|
||||
const badge = wrapper.find('[class*="bg-amber-500"]')
|
||||
const badge = wrapper.find('[class*="bg-amber-600"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text().toLowerCase()).toContain('missing')
|
||||
})
|
||||
@@ -97,7 +97,7 @@ describe('BookCoverCard — present state', () => {
|
||||
|
||||
it('does not render the missing badge', () => {
|
||||
const wrapper = mount(BookCoverCard, { props: { book: presentBook }, global: globalStubs })
|
||||
expect(wrapper.find('[class*="bg-amber-500"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[class*="bg-amber-600"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies hover-scale to cover container when present', () => {
|
||||
|
||||
@@ -39,6 +39,8 @@ const globalStubs = {
|
||||
DialogOverlay: { template: '<div />' },
|
||||
DialogContent: { template: '<div><slot /></div>' },
|
||||
DialogClose: { template: '<button><slot /></button>' },
|
||||
AddToCollectionSheet: true,
|
||||
DeleteBookDialog: true,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ const index = computed(() => currentIndex(props.bookId))
|
||||
watch(
|
||||
[() => props.bookId, bookIds],
|
||||
async ([id]) => {
|
||||
prevId.value = getPrevId(id)
|
||||
nextId.value = await getNextId(id)
|
||||
prevId.value = getPrevId(id) ?? null
|
||||
nextId.value = (await getNextId(id)) ?? null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
@@ -41,6 +41,11 @@ const activeSrc = computed(() => previewSrc.value ?? coverUrl(props.book.id, 'co
|
||||
const hasPending = computed(() => !!pendingFile.value || !!pendingUrl.value)
|
||||
const isAudiobook = computed(() => props.book.files.some((f) => f.format === 'audiobook' || f.role === 'audiobook'))
|
||||
|
||||
function cancelPending() {
|
||||
clearPending()
|
||||
urlInput.value = ''
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) selectFile(file)
|
||||
@@ -185,7 +190,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
v-if="hasPending"
|
||||
class="w-full h-8 rounded-lg border border-input bg-background text-xs hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="uploading"
|
||||
@click="clearPending(); urlInput = ''"
|
||||
@click="cancelPending"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -52,9 +52,7 @@ const {
|
||||
|
||||
const view = ref<'search' | 'diff'>('search')
|
||||
const selectedCandidate = ref<MetadataCandidate | null>(null)
|
||||
const currentProviderId = computed(() =>
|
||||
selectedCandidate.value ? (props.book.providerIds[selectedCandidate.value.provider] ?? null) : null,
|
||||
)
|
||||
const currentProviderId = computed(() => (selectedCandidate.value ? (props.book.providerIds[selectedCandidate.value.provider] ?? null) : null))
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
|
||||
@@ -4,10 +4,7 @@ import { toast } from 'vue-sonner'
|
||||
import { useCoverVersions } from './useCoverVersions'
|
||||
import { useRefreshingBooks } from './useRefreshingBooks'
|
||||
|
||||
export function useBookBulkActions(
|
||||
selectedIds: Ref<Set<number>>,
|
||||
onDeleted: (ids: number[]) => void,
|
||||
) {
|
||||
export function useBookBulkActions(selectedIds: Ref<Set<number>>, onDeleted: (ids: number[]) => void) {
|
||||
const { bumpVersion } = useCoverVersions()
|
||||
const { markRefreshing, clearRefreshing } = useRefreshingBooks()
|
||||
|
||||
@@ -37,9 +34,17 @@ export function useBookBulkActions(
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
|
||||
if (data.done) { processed = data.processed; failed = data.failed }
|
||||
} catch { /* ignore malformed SSE line */ }
|
||||
if (data.bookId !== undefined) {
|
||||
bumpVersion(data.bookId)
|
||||
clearRefreshing([data.bookId])
|
||||
}
|
||||
if (data.done) {
|
||||
processed = data.processed
|
||||
failed = data.failed
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed SSE line */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -78,9 +83,17 @@ export function useBookBulkActions(
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
|
||||
if (data.done) { processed = data.processed; updated = data.updated }
|
||||
} catch { /* ignore malformed SSE line */ }
|
||||
if (data.bookId !== undefined) {
|
||||
bumpVersion(data.bookId)
|
||||
clearRefreshing([data.bookId])
|
||||
}
|
||||
if (data.done) {
|
||||
processed = data.processed
|
||||
updated = data.updated
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed SSE line */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -135,4 +148,3 @@ export function useBookBulkActions(
|
||||
handleDeleteSelected,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export function useBookQuery(libraryId: Ref<number | null>) {
|
||||
if (pagination.page === 0) {
|
||||
items.value = page.items
|
||||
} else {
|
||||
items.value.push(...page.items)
|
||||
items.value = [...items.value, ...page.items]
|
||||
}
|
||||
total.value = page.total
|
||||
pagination.page++
|
||||
|
||||
@@ -38,4 +38,3 @@ export function useDeleteBook(onDeleted: (id: number) => void) {
|
||||
|
||||
return { pendingId, deleting, promptDelete, cancelDelete, confirmDelete }
|
||||
}
|
||||
|
||||
|
||||
@@ -87,12 +87,7 @@ const PROVIDER_ID_LABEL: Record<MetadataProviderKey, string> = {
|
||||
openLibrary: 'Open Library ID',
|
||||
}
|
||||
|
||||
export function useMetadataDiff(
|
||||
current: MetadataSource,
|
||||
candidate: MetadataCandidate,
|
||||
currentCoverUrl?: string,
|
||||
currentProviderId?: string | null,
|
||||
) {
|
||||
export function useMetadataDiff(current: MetadataSource, candidate: MetadataCandidate, currentCoverUrl?: string, currentProviderId?: string | null) {
|
||||
const copiedFields = reactive(new Set<DiffFieldKey>())
|
||||
|
||||
function getBookValue(key: DiffFieldKey): string {
|
||||
@@ -111,67 +106,68 @@ export function useMetadataDiff(
|
||||
return val != null ? String(val) : ''
|
||||
}
|
||||
|
||||
const fields = computed<DiffField[]>(() =>
|
||||
[
|
||||
...FIELD_DEFS.flatMap((def) => {
|
||||
const candidateVal = getCandidateValue(def.key)
|
||||
const bookVal = getBookValue(def.key)
|
||||
if (def.key === 'coverUrl') {
|
||||
if (!candidateVal && !bookVal) return []
|
||||
} else {
|
||||
if (!candidateVal) return []
|
||||
}
|
||||
const isCopied = copiedFields.has(def.key)
|
||||
return [
|
||||
{
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
bookValue: bookVal,
|
||||
currentDisplay: isCopied ? candidateVal : bookVal,
|
||||
candidateDisplay: candidateVal,
|
||||
hasDiff: bookVal !== candidateVal,
|
||||
isCopied,
|
||||
isCover: def.key === 'coverUrl',
|
||||
isCopyable: true,
|
||||
},
|
||||
]
|
||||
}),
|
||||
...(candidate.providerId || currentProviderId
|
||||
? [
|
||||
(() => {
|
||||
const providerIsCopied = copiedFields.has('providerId')
|
||||
const candidateProviderId = candidate.providerId ?? ''
|
||||
const existingProviderId = currentProviderId ?? ''
|
||||
return {
|
||||
key: 'providerId' as const,
|
||||
label: PROVIDER_ID_LABEL[candidate.provider] ?? 'Provider ID',
|
||||
bookValue: existingProviderId,
|
||||
currentDisplay: providerIsCopied ? candidateProviderId : existingProviderId,
|
||||
candidateDisplay: candidateProviderId,
|
||||
hasDiff: existingProviderId !== candidateProviderId,
|
||||
isCopied: providerIsCopied,
|
||||
isCover: false,
|
||||
isCopyable: true,
|
||||
}
|
||||
})(),
|
||||
]
|
||||
: []),
|
||||
...(candidate.sourceUrl
|
||||
? [
|
||||
const fields = computed<DiffField[]>(
|
||||
() =>
|
||||
[
|
||||
...FIELD_DEFS.flatMap((def) => {
|
||||
const candidateVal = getCandidateValue(def.key)
|
||||
const bookVal = getBookValue(def.key)
|
||||
if (def.key === 'coverUrl') {
|
||||
if (!candidateVal && !bookVal) return []
|
||||
} else {
|
||||
if (!candidateVal) return []
|
||||
}
|
||||
const isCopied = copiedFields.has(def.key)
|
||||
return [
|
||||
{
|
||||
key: 'sourceUrl' as const,
|
||||
label: 'Source URL',
|
||||
bookValue: '',
|
||||
currentDisplay: '',
|
||||
candidateDisplay: candidate.sourceUrl,
|
||||
hasDiff: true,
|
||||
isCopied: false,
|
||||
isCover: false,
|
||||
isCopyable: false,
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
bookValue: bookVal,
|
||||
currentDisplay: isCopied ? candidateVal : bookVal,
|
||||
candidateDisplay: candidateVal,
|
||||
hasDiff: bookVal !== candidateVal,
|
||||
isCopied,
|
||||
isCover: def.key === 'coverUrl',
|
||||
isCopyable: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
] as DiffField[],
|
||||
}),
|
||||
...(candidate.providerId || currentProviderId
|
||||
? [
|
||||
(() => {
|
||||
const providerIsCopied = copiedFields.has('providerId')
|
||||
const candidateProviderId = candidate.providerId ?? ''
|
||||
const existingProviderId = currentProviderId ?? ''
|
||||
return {
|
||||
key: 'providerId' as const,
|
||||
label: PROVIDER_ID_LABEL[candidate.provider] ?? 'Provider ID',
|
||||
bookValue: existingProviderId,
|
||||
currentDisplay: providerIsCopied ? candidateProviderId : existingProviderId,
|
||||
candidateDisplay: candidateProviderId,
|
||||
hasDiff: existingProviderId !== candidateProviderId,
|
||||
isCopied: providerIsCopied,
|
||||
isCover: false,
|
||||
isCopyable: true,
|
||||
}
|
||||
})(),
|
||||
]
|
||||
: []),
|
||||
...(candidate.sourceUrl
|
||||
? [
|
||||
{
|
||||
key: 'sourceUrl' as const,
|
||||
label: 'Source URL',
|
||||
bookValue: '',
|
||||
currentDisplay: '',
|
||||
candidateDisplay: candidate.sourceUrl,
|
||||
hasDiff: true,
|
||||
isCopied: false,
|
||||
isCover: false,
|
||||
isCopyable: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
] as DiffField[],
|
||||
)
|
||||
|
||||
function toggleField(key: DiffFieldKey) {
|
||||
|
||||
@@ -70,4 +70,3 @@ export function ruleToParts(rule: Rule): { field: string; operator: string; valu
|
||||
const val = Array.isArray(rule.value) ? (rule.value as string[]).join(', ') : String(rule.value ?? '')
|
||||
return { field, operator, value: rule.valueTo !== undefined ? `${val} - ${rule.valueTo}` : val }
|
||||
}
|
||||
|
||||
|
||||
@@ -118,12 +118,15 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="books.length === 0" class="px-5 pb-4 pt-1">
|
||||
<div v-else-if="books.length === 0" class="flex flex-col items-center justify-center py-10 gap-3 text-center">
|
||||
<div class="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<component :is="typeIcon" :size="20" class="text-muted-foreground/40" />
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<template v-if="type === 'continue-reading'">No books in progress yet. Start reading one to see it here.</template>
|
||||
<template v-else-if="type === 'recently-added'">No books in your library yet.</template>
|
||||
<template v-else-if="type === 'lens'">No books match this lens.</template>
|
||||
<template v-else>No unread books found.</template>
|
||||
<template v-else>No books found.</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -77,14 +77,24 @@ function removeScroller(index: number) {
|
||||
function moveUp(index: number) {
|
||||
if (index === 0) return
|
||||
const items = [...draft.value]
|
||||
;[items[index - 1], items[index]] = [items[index], items[index - 1]]
|
||||
const current = items[index]
|
||||
const prev = items[index - 1]
|
||||
if (current && prev) {
|
||||
items[index - 1] = current
|
||||
items[index] = prev
|
||||
}
|
||||
draft.value = items
|
||||
}
|
||||
|
||||
function moveDown(index: number) {
|
||||
if (index === draft.value.length - 1) return
|
||||
const items = [...draft.value]
|
||||
;[items[index], items[index + 1]] = [items[index + 1], items[index]]
|
||||
const current = items[index]
|
||||
const next = items[index + 1]
|
||||
if (current && next) {
|
||||
items[index + 1] = current
|
||||
items[index] = next
|
||||
}
|
||||
draft.value = items
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ async function remove(g: EmailGroup) {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelCreate() {
|
||||
showCreate.value = false
|
||||
newGroupName.value = ''
|
||||
}
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedGroupId.value = expandedGroupId.value === id ? null : id
|
||||
if (expandedGroupId.value !== id) addingToGroupId.value = null
|
||||
@@ -120,7 +125,7 @@ function availableRecipients(group: EmailGroup) {
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-medium rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors"
|
||||
@click="showCreate = false; newGroupName = ''"
|
||||
@click="cancelCreate"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useLens(lensId: Ref<number>) {
|
||||
if (page.value === 0) {
|
||||
items.value = data.items
|
||||
} else {
|
||||
items.value.push(...data.items)
|
||||
items.value = [...items.value, ...data.items]
|
||||
}
|
||||
total.value = data.total
|
||||
page.value++
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { CheckCircle2, FileUp, Loader2, Upload, X, XCircle } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CheckCircle2, FileUp, Loader2, Plus, RotateCcw, Upload, X, XCircle } from 'lucide-vue-next'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Library } from '@projectx/types'
|
||||
import { SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT, useBookUpload } from '../composables/useBookUpload'
|
||||
import { SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT, useBookUpload, type FileUploadStatus } from '../composables/useBookUpload'
|
||||
import { emitLibraryUploadCompleted } from '../composables/useLibraryUploadEvents'
|
||||
import { useLibraries } from '../composables/useLibraries'
|
||||
|
||||
@@ -16,19 +17,42 @@ const emit = defineEmits<{
|
||||
uploaded: [bookIds: number[]]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
|
||||
const selectedLibraryId = ref<number | undefined>(props.libraryId)
|
||||
const folders = ref<Library['folders']>([])
|
||||
const selectedFolderId = ref<number | undefined>(undefined)
|
||||
const libraryName = ref<string | null>(null)
|
||||
const isDragging = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const { files, pendingCount, isUploading, doneCount, errorCount, uploadedBookIds, addFiles, removeFile, reset, startUpload } = useBookUpload()
|
||||
const { files, pendingCount, isUploading, doneCount, errorCount, uploadedBookIds, addFiles, removeFile, retryFile, reset, startUpload } =
|
||||
useBookUpload()
|
||||
|
||||
const canUpload = computed(() => pendingCount.value > 0 && !isUploading.value && selectedLibraryId.value !== undefined)
|
||||
const hasFiles = computed(() => files.value.length > 0)
|
||||
const allDone = computed(() => hasFiles.value && files.value.every((f) => f.status === 'done' || f.status === 'error'))
|
||||
const allSuccess = computed(() => allDone.value && errorCount.value === 0)
|
||||
|
||||
const headerTitle = computed(() => {
|
||||
if (isUploading.value) return 'Uploading...'
|
||||
if (allDone.value) return 'Upload complete'
|
||||
if (libraryName.value) return `Upload to ${libraryName.value}`
|
||||
return 'Upload Books'
|
||||
})
|
||||
|
||||
const fileSummary = computed(() => {
|
||||
if (!hasFiles.value) return null
|
||||
const totalBytes = files.value.reduce((sum, f) => sum + f.file.size, 0)
|
||||
const byFormat: Record<string, number> = {}
|
||||
for (const f of files.value) {
|
||||
const ext = f.file.name.split('.').pop()?.toUpperCase() ?? '?'
|
||||
byFormat[ext] = (byFormat[ext] ?? 0) + 1
|
||||
}
|
||||
const formatParts = Object.entries(byFormat).map(([ext, count]) => `${count} ${ext}`)
|
||||
return { total: files.value.length, totalBytes, formatParts }
|
||||
})
|
||||
|
||||
async function loadFolders(libraryId: number) {
|
||||
const res = await api(`/api/v1/libraries/${libraryId}`)
|
||||
@@ -36,12 +60,14 @@ async function loadFolders(libraryId: number) {
|
||||
const data: Library = await res.json()
|
||||
folders.value = data.folders ?? []
|
||||
selectedFolderId.value = folders.value[0]?.id
|
||||
libraryName.value = data.name
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedLibraryId, (id) => {
|
||||
folders.value = []
|
||||
selectedFolderId.value = undefined
|
||||
libraryName.value = null
|
||||
if (id !== undefined) loadFolders(id)
|
||||
})
|
||||
|
||||
@@ -65,6 +91,13 @@ function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function goToLibrary() {
|
||||
if (selectedLibraryId.value) {
|
||||
router.push({ name: 'library', params: { id: selectedLibraryId.value } })
|
||||
}
|
||||
handleClose()
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
isDragging.value = true
|
||||
@@ -106,13 +139,13 @@ async function handleUpload() {
|
||||
await startUpload(libraryId, folderId)
|
||||
|
||||
const uploadedAfter = files.value.filter((f) => f.status === 'done' && f.bookId !== undefined).map((f) => f.bookId!)
|
||||
const uploadedBookIds = uploadedAfter.filter((id) => !uploadedBefore.has(id))
|
||||
const newlyUploaded = uploadedAfter.filter((id) => !uploadedBefore.has(id))
|
||||
const errorCountAfter = files.value.filter((f) => f.status === 'error').length
|
||||
|
||||
emitLibraryUploadCompleted({
|
||||
libraryId,
|
||||
uploadedBookIds,
|
||||
uploadedCount: uploadedBookIds.length,
|
||||
uploadedBookIds: newlyUploaded,
|
||||
uploadedCount: newlyUploaded.length,
|
||||
failedCount: Math.max(0, errorCountAfter - errorCountBefore),
|
||||
attemptedCount: pendingAtStart,
|
||||
})
|
||||
@@ -123,20 +156,54 @@ function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function fileRowClass(status: FileUploadStatus): string {
|
||||
if (status === 'done') return 'bg-emerald-500/5 border-emerald-500/20'
|
||||
if (status === 'error') return 'bg-destructive/5 border-destructive/20'
|
||||
if (status === 'uploading') return 'bg-primary/5 border-primary/20'
|
||||
return 'bg-muted/40 border-border/40'
|
||||
}
|
||||
|
||||
function getFormat(filename: string): string {
|
||||
return filename.split('.').pop()?.toUpperCase() ?? '?'
|
||||
}
|
||||
|
||||
function formatPillClass(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
const map: Record<string, string> = {
|
||||
epub: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
|
||||
pdf: 'bg-red-500/15 text-red-600 dark:text-red-400',
|
||||
mobi: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
|
||||
azw3: 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
|
||||
cbz: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
|
||||
cbr: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
|
||||
cb7: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
|
||||
fb2: 'bg-teal-500/15 text-teal-600 dark:text-teal-400',
|
||||
}
|
||||
return map[ext] ?? 'bg-muted text-muted-foreground/70'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center p-4">
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center p-2 sm:p-4">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]" @click="handleClose" />
|
||||
|
||||
<div
|
||||
class="relative flex flex-col w-full max-w-lg bg-background rounded-xl shadow-2xl border border-border overflow-hidden"
|
||||
style="max-height: min(90vh, 640px)"
|
||||
class="relative flex flex-col w-full max-w-2xl bg-background rounded-xl shadow-2xl border border-border overflow-hidden"
|
||||
style="max-height: min(92vh, 760px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
|
||||
<span class="text-sm font-semibold text-foreground">Upload Books</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-foreground">{{ headerTitle }}</span>
|
||||
<span
|
||||
v-if="hasFiles"
|
||||
class="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/15 text-primary text-xs font-semibold tabular-nums"
|
||||
>
|
||||
{{ files.length }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center justify-center w-7 h-7 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleClose"
|
||||
@@ -169,77 +236,142 @@ function formatBytes(bytes: number): string {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Dropzone -->
|
||||
<!-- Shared file input -->
|
||||
<input ref="fileInputRef" type="file" multiple :accept="SUPPORTED_FORMATS_ACCEPT" class="sr-only" @change="onFileInputChange" />
|
||||
|
||||
<!-- Full dropzone (no files queued yet) -->
|
||||
<div
|
||||
class="relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors cursor-pointer"
|
||||
v-if="!hasFiles"
|
||||
class="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors cursor-pointer"
|
||||
:class="isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-border/80 hover:bg-muted/30'"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-muted">
|
||||
<FileUp :size="20" class="text-muted-foreground" />
|
||||
<div class="flex items-center justify-center w-11 h-11 rounded-xl bg-primary/10">
|
||||
<FileUp :size="20" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Drop files here or click to browse</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">{{ SUPPORTED_FORMATS.join(', ') }} - up to 500 MB each</p>
|
||||
</div>
|
||||
<input ref="fileInputRef" type="file" multiple :accept="SUPPORTED_FORMATS_ACCEPT" class="sr-only" @change="onFileInputChange" />
|
||||
</div>
|
||||
|
||||
<!-- Compact strip (files queued) -->
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center gap-2 rounded-lg border-2 border-dashed py-2.5 px-4 transition-colors cursor-pointer text-xs font-medium"
|
||||
:class="
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border text-muted-foreground hover:border-primary/40 hover:text-foreground hover:bg-muted/30'
|
||||
"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<Plus :size="13" />
|
||||
Add more files
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<div v-if="hasFiles" class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">{{ files.length }} file{{ files.length === 1 ? '' : 's' }}</span>
|
||||
<button v-if="!isUploading" class="text-xs text-muted-foreground hover:text-foreground transition-colors" @click="reset">
|
||||
Clear all
|
||||
</button>
|
||||
<div v-if="hasFiles" class="flex flex-col gap-2">
|
||||
<!-- Summary line -->
|
||||
<div v-if="fileSummary" class="flex items-center gap-1.5 flex-wrap text-xs text-muted-foreground">
|
||||
<span class="font-medium text-foreground tabular-nums">{{ fileSummary.total }} file{{ fileSummary.total === 1 ? '' : 's' }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatBytes(fileSummary.totalBytes) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ fileSummary.formatParts.join(', ') }}</span>
|
||||
<div class="flex-1" />
|
||||
<button v-if="!isUploading" class="hover:text-foreground transition-colors" @click="reset">Clear all</button>
|
||||
</div>
|
||||
|
||||
<div v-for="item in files" :key="item.id" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-muted/40 border border-border/40">
|
||||
<!-- Status icon -->
|
||||
<div class="shrink-0">
|
||||
<Loader2 v-if="item.status === 'uploading'" :size="16" class="text-primary animate-spin" />
|
||||
<CheckCircle2 v-else-if="item.status === 'done'" :size="16" class="text-green-500" />
|
||||
<XCircle v-else-if="item.status === 'error'" :size="16" class="text-destructive" />
|
||||
<div v-else class="w-4 h-4 rounded-full border-2 border-border" />
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium text-foreground truncate">{{ item.file.name }}</p>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span class="text-[11px] text-muted-foreground">{{ formatBytes(item.file.size) }}</span>
|
||||
<span v-if="item.status === 'error'" class="text-[11px] text-destructive truncate">{{ item.error }}</span>
|
||||
<span v-else-if="item.status === 'uploading'" class="text-[11px] text-primary">{{ item.progress }}%</span>
|
||||
<span v-else-if="item.status === 'done'" class="text-[11px] text-green-500">Done</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div v-if="item.status === 'uploading'" class="mt-1.5 h-0.5 rounded-full bg-muted overflow-hidden">
|
||||
<div class="h-full bg-primary rounded-full transition-all duration-150" :style="{ width: `${item.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="item.status !== 'uploading'"
|
||||
class="shrink-0 flex items-center justify-center w-5 h-5 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="removeFile(item.id)"
|
||||
<TransitionGroup name="file-row" tag="div" class="relative flex flex-col gap-1">
|
||||
<div
|
||||
v-for="item in files"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors"
|
||||
:class="fileRowClass(item.status)"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Status icon -->
|
||||
<div class="shrink-0">
|
||||
<Loader2 v-if="item.status === 'uploading'" :size="16" class="text-primary animate-spin" />
|
||||
<CheckCircle2 v-else-if="item.status === 'done'" :size="16" class="text-emerald-500" />
|
||||
<XCircle v-else-if="item.status === 'error'" :size="16" class="text-destructive" />
|
||||
<div v-else class="w-4 h-4 rounded-full border-2 border-border" />
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium text-foreground truncate">{{ item.file.name }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||
<span class="text-[11px] text-muted-foreground">{{ formatBytes(item.file.size) }}</span>
|
||||
<span class="text-[10px] font-semibold px-1.5 py-px rounded uppercase tracking-wide" :class="formatPillClass(item.file.name)">
|
||||
{{ getFormat(item.file.name) }}
|
||||
</span>
|
||||
<span v-if="item.status === 'error'" class="text-[11px] text-destructive truncate">{{ item.error }}</span>
|
||||
<span v-else-if="item.status === 'uploading'" class="text-[11px] text-primary tabular-nums">{{ item.progress }}%</span>
|
||||
<span v-else-if="item.status === 'done'" class="text-[11px] text-emerald-600 dark:text-emerald-400">Done</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div v-if="item.status === 'uploading'" class="mt-1.5 h-1 rounded-full bg-primary/20 overflow-hidden">
|
||||
<div class="h-full bg-primary rounded-full transition-all duration-150" :style="{ width: `${item.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="shrink-0 flex items-center gap-0.5">
|
||||
<button
|
||||
v-if="item.status === 'error'"
|
||||
class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/70 hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Retry"
|
||||
@click="retryFile(item.id)"
|
||||
>
|
||||
<RotateCcw :size="11" />
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status !== 'uploading'"
|
||||
class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="removeFile(item.id)"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="shrink-0 px-5 py-4 border-t border-border flex items-center justify-between gap-3">
|
||||
<!-- Summary -->
|
||||
<span v-if="allDone" class="text-xs text-muted-foreground">
|
||||
{{ doneCount }} uploaded<template v-if="errorCount > 0">, {{ errorCount }} failed</template>
|
||||
</span>
|
||||
<!-- Footer: all succeeded -->
|
||||
<div v-if="allSuccess" class="shrink-0 px-5 py-4 border-t border-border flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle2 class="size-4 text-emerald-500 shrink-0" />
|
||||
<span class="text-sm font-medium text-foreground">{{ doneCount }} book{{ doneCount === 1 ? '' : 's' }} added</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleClose"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
@click="goToLibrary"
|
||||
>
|
||||
View in Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: normal / partial error state -->
|
||||
<div v-else class="shrink-0 px-5 py-4 border-t border-border flex items-center justify-between gap-3">
|
||||
<span v-if="allDone && errorCount > 0" class="text-xs text-muted-foreground"> {{ doneCount }} uploaded, {{ errorCount }} failed </span>
|
||||
<span v-else-if="isUploading" class="text-xs text-muted-foreground tabular-nums"> {{ doneCount }} of {{ files.length }} uploading... </span>
|
||||
<span v-else class="text-xs text-muted-foreground" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -263,3 +395,25 @@ function formatBytes(bytes: number): string {
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-row-enter-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.file-row-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
.file-row-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
.file-row-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(6px);
|
||||
}
|
||||
.file-row-move {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,7 +33,7 @@ function useDragList(getList: () => string[], onUpdate: (v: string[]) => void) {
|
||||
if (dragFrom.value === null || dragOver.value === null || dragFrom.value === dragOver.value) return
|
||||
const list = [...getList()]
|
||||
const [item] = list.splice(dragFrom.value, 1)
|
||||
list.splice(dragOver.value, 0, item)
|
||||
if (item !== undefined) list.splice(dragOver.value, 0, item)
|
||||
onUpdate(list)
|
||||
dragFrom.value = null
|
||||
dragOver.value = null
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { emitLibraryUploadCompleted, useLibraryUploadEvents } from '../useLibraryUploadEvents'
|
||||
import { emitLibraryUploadCompleted, useLibraryUploadEvents, type LibraryUploadCompletedEvent } from '../useLibraryUploadEvents'
|
||||
|
||||
describe('useLibraryUploadEvents', () => {
|
||||
it('notifies subscribers with the emitted event payload', () => {
|
||||
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
||||
const cb = vi.fn()
|
||||
const cb = vi.fn<(event: LibraryUploadCompletedEvent) => void>()
|
||||
const off = onLibraryUploadCompleted(cb)
|
||||
|
||||
const event = {
|
||||
@@ -26,7 +26,7 @@ describe('useLibraryUploadEvents', () => {
|
||||
|
||||
it('stops notifying once unsubscribed', () => {
|
||||
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
||||
const cb = vi.fn()
|
||||
const cb = vi.fn<(event: LibraryUploadCompletedEvent) => void>()
|
||||
const off = onLibraryUploadCompleted(cb)
|
||||
|
||||
off()
|
||||
|
||||
@@ -105,6 +105,14 @@ export function useBookUpload() {
|
||||
files.value = files.value.filter((f) => f.id !== id)
|
||||
}
|
||||
|
||||
function retryFile(id: string) {
|
||||
const item = files.value.find((f) => f.id === id)
|
||||
if (!item || item.status !== 'error') return
|
||||
item.status = 'pending'
|
||||
item.error = undefined
|
||||
item.progress = 0
|
||||
}
|
||||
|
||||
function reset() {
|
||||
files.value = []
|
||||
}
|
||||
@@ -138,6 +146,7 @@ export function useBookUpload() {
|
||||
uploadedBookIds,
|
||||
addFiles,
|
||||
removeFile,
|
||||
retryFile,
|
||||
reset,
|
||||
startUpload,
|
||||
}
|
||||
|
||||
@@ -92,11 +92,13 @@ function handleImageClick(e: MouseEvent) {
|
||||
let touchStartX = 0
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
touchStartX = e.touches[0].clientX
|
||||
if (e.touches[0]) touchStartX = e.touches[0].clientX
|
||||
}
|
||||
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX
|
||||
const touch = e.changedTouches[0]
|
||||
if (!touch) return
|
||||
const dx = touch.clientX - touchStartX
|
||||
if (Math.abs(dx) < 50) return
|
||||
if (dx < 0) {
|
||||
if (direction.value === 'rtl') prevPage()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ selectedText: string; modelValue: string }>()
|
||||
const emit = defineEmits<{ save: [note: string]; cancel: [] }>()
|
||||
const emit = defineEmits<{ save: [note: string]; cancel: []; 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Bookmark, BookOpen, ChevronDown, ChevronRight, Highlighter, Trash2, X } from 'lucide-vue-next'
|
||||
import { Bookmark, BookOpen, Highlighter, Trash2, X } from 'lucide-vue-next'
|
||||
import type { TocItem } from '../composables/useToc'
|
||||
import type { Bookmark as BookmarkType } from '../composables/useBookmarks'
|
||||
import type { Annotation } from '../composables/useAnnotations'
|
||||
@@ -124,24 +124,30 @@ const activeTab = ref<Tab>('chapters')
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { stripFragment } from '../utils'
|
||||
import { defineComponent, h, type VNode, type Component } from 'vue'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
interface LocalTocItem {
|
||||
label: string
|
||||
href: string
|
||||
subitems?: LocalTocItem[]
|
||||
}
|
||||
|
||||
const TocList = defineComponent({
|
||||
name: 'TocList',
|
||||
props: {
|
||||
items: { type: Array as () => TocItem[], required: true },
|
||||
items: { type: Array as () => LocalTocItem[], required: true },
|
||||
activeHref: { type: String, required: true },
|
||||
expandedHrefs: { type: Object as () => Set<string>, required: true },
|
||||
depth: { type: Number, default: 0 },
|
||||
},
|
||||
emits: ['navigate', 'toggleExpand'],
|
||||
setup(props, { emit }) {
|
||||
function isActive(href: string) {
|
||||
function isActive(href: string): boolean {
|
||||
return stripFragment(props.activeHref) === stripFragment(href)
|
||||
}
|
||||
|
||||
return () =>
|
||||
return (): VNode =>
|
||||
h(
|
||||
'ul',
|
||||
{ class: 'py-1' },
|
||||
@@ -174,14 +180,14 @@ const TocList = defineComponent({
|
||||
emit('toggleExpand', item.href)
|
||||
},
|
||||
},
|
||||
[expanded ? h(ChevronDown, { size: 14 }) : h(ChevronRight, { size: 14 })],
|
||||
[expanded ? h(ChevronDown as Component, { size: 14 }) : h(ChevronRight as Component, { size: 14 })],
|
||||
)
|
||||
: h('span', { class: 'w-3.5 shrink-0' }),
|
||||
h('span', { class: 'truncate' }, item.label),
|
||||
],
|
||||
),
|
||||
hasChildren && expanded
|
||||
? h(TocList, {
|
||||
? h(TocList as Component, {
|
||||
items: item.subitems!,
|
||||
activeHref: props.activeHref,
|
||||
expandedHrefs: props.expandedHrefs,
|
||||
|
||||
@@ -109,7 +109,8 @@ export function useFoliate(
|
||||
const infoRes = await api(`/api/v1/epub/${bookId}/info`)
|
||||
if (!infoRes.ok) throw new Error(`Failed to fetch EPUB info: ${infoRes.status}`)
|
||||
const bookInfo = await infoRes.json()
|
||||
const makeStreamingBook = (window as Record<string, unknown>).makeStreamingBook as
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const makeStreamingBook = (window as any).makeStreamingBook as
|
||||
| ((id: number, base: string, info: unknown, token: string | null) => Promise<unknown>)
|
||||
| undefined
|
||||
if (!makeStreamingBook) throw new Error('makeStreamingBook not available')
|
||||
|
||||
@@ -78,25 +78,29 @@ export function useFoliateAnnotations() {
|
||||
|
||||
function addAnnotation(view: unknown, cfi: string, color = '#FACC15', style = 'highlight') {
|
||||
annotationStyleMap.set(cfi, { color, style })
|
||||
;(view as Record<string, unknown> | null)?.addAnnotation?.({ value: cfi })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(view as any)?.addAnnotation?.({ value: cfi })
|
||||
}
|
||||
|
||||
function addAnnotations(view: unknown, anns: { cfi: string; color: string; style: string }[]) {
|
||||
for (const ann of anns) {
|
||||
annotationStyleMap.set(ann.cfi, { color: ann.color, style: ann.style })
|
||||
;(view as Record<string, unknown> | null)?.addAnnotation?.({ value: ann.cfi })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(view as any)?.addAnnotation?.({ value: ann.cfi })
|
||||
}
|
||||
}
|
||||
|
||||
function deleteAnnotation(view: unknown, cfi: string) {
|
||||
annotationStyleMap.delete(cfi)
|
||||
;(view as Record<string, unknown> | null)?.deleteAnnotation?.({ value: cfi })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(view as any)?.deleteAnnotation?.({ value: cfi })
|
||||
}
|
||||
|
||||
function reAddAll(view: unknown) {
|
||||
setTimeout(() => {
|
||||
for (const [cfi] of annotationStyleMap) {
|
||||
;(view as Record<string, unknown> | null)?.addAnnotation?.({ value: cfi })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(view as any)?.addAnnotation?.({ value: cfi })
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types'
|
||||
import { getAccessToken } from '@/lib/api'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
@@ -22,6 +23,9 @@ function getSocket(): Socket {
|
||||
progressMap.value.set(event.libraryId, event)
|
||||
progressMap.value = new Map(progressMap.value)
|
||||
if (event.status !== 'running') {
|
||||
if (event.status === 'completed' && event.added > 0) {
|
||||
toast.success(`${event.added} new ${event.added === 1 ? 'book' : 'books'} added`)
|
||||
}
|
||||
// Keep the final event for a short time so UI can show completion, then clear.
|
||||
setTimeout(() => {
|
||||
progressMap.value.delete(event.libraryId)
|
||||
|
||||
@@ -298,4 +298,4 @@ function toggleCbxFormat(fmt: 'cbz' | 'cb7') {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -222,4 +222,4 @@ async function testConnection() {
|
||||
<p v-if="saveError" class="text-sm text-destructive">{{ saveError }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
DEFAULT_DOWNLOAD_PATTERN,
|
||||
EXAMPLE_PATTERN_METADATA,
|
||||
resolveDownloadFilename,
|
||||
resolveUploadPath,
|
||||
validatePattern,
|
||||
} from '@projectx/types'
|
||||
import { DEFAULT_DOWNLOAD_PATTERN, EXAMPLE_PATTERN_METADATA, resolveDownloadFilename, resolveUploadPath, validatePattern } from '@projectx/types'
|
||||
import type { Library } from '@projectx/types'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
|
||||
|
||||
@@ -93,11 +93,10 @@ async function fetchNamePreview(libId: number | null) {
|
||||
const rows: { fileId: number; fileName: string; newName: string }[] = await res.json()
|
||||
if (reqId !== previewReqSeq) return
|
||||
namePreview.value = rows
|
||||
previewLoading.value = false
|
||||
} catch {
|
||||
if (reqId !== previewReqSeq) return
|
||||
namePreview.value = []
|
||||
} finally {
|
||||
if (reqId !== previewReqSeq) return
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Upload, RotateCw, Trash2, PenLine, FileText, Search, X, Wand2, RefreshCw, FolderPlus } from 'lucide-vue-next'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { Upload, RotateCw, Trash2, PenLine, FileText, Search, X, Wand2, RefreshCw, FolderPlus, Loader2 } from 'lucide-vue-next'
|
||||
import type { StagingFileStatus } from '@projectx/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { SUPPORTED_FORMATS_ACCEPT, useStagingUpload } from '../composables/useStagingUpload'
|
||||
@@ -28,11 +28,35 @@ const emit = defineEmits<{
|
||||
applyFetched: []
|
||||
}>()
|
||||
|
||||
const { addFiles } = useStagingUpload()
|
||||
const { files: uploadFiles, isUploading, addFiles, clearCompleted } = useStagingUpload()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const rescanning = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const showSearch = ref(false)
|
||||
const showUploadPopover = ref(false)
|
||||
|
||||
const uploadTotal = computed(() => uploadFiles.value.length)
|
||||
const uploadDone = computed(() => uploadFiles.value.filter((f) => f.status === 'done').length)
|
||||
const uploadError = computed(() => uploadFiles.value.filter((f) => f.status === 'error').length)
|
||||
const uploadProgress = computed(() => (uploadTotal.value > 0 ? Math.round((uploadDone.value / uploadTotal.value) * 100) : 0))
|
||||
|
||||
let popoverTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(isUploading, (uploading) => {
|
||||
if (uploading) {
|
||||
showUploadPopover.value = true
|
||||
if (popoverTimer) clearTimeout(popoverTimer)
|
||||
} else if (showUploadPopover.value) {
|
||||
if (popoverTimer) clearTimeout(popoverTimer)
|
||||
popoverTimer = setTimeout(() => {
|
||||
showUploadPopover.value = false
|
||||
}, 3000)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (popoverTimer) clearTimeout(popoverTimer)
|
||||
})
|
||||
|
||||
const tabs: { label: string; value: StagingFileStatus | undefined }[] = [
|
||||
{ label: 'All', value: undefined },
|
||||
@@ -42,6 +66,7 @@ const tabs: { label: string; value: StagingFileStatus | undefined }[] = [
|
||||
]
|
||||
|
||||
function openFilePicker() {
|
||||
clearCompleted()
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
@@ -130,13 +155,40 @@ function clearSearch() {
|
||||
Rescan
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-all active:scale-95"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<Upload class="size-3.5" />
|
||||
Upload
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-all active:scale-95"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<Loader2 v-if="isUploading" class="size-3.5 animate-spin" />
|
||||
<Upload v-else class="size-3.5" />
|
||||
Upload
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showUploadPopover"
|
||||
class="absolute right-0 top-full mt-1.5 z-20 w-52 rounded-xl border border-border bg-card shadow-lg p-3 space-y-2.5"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-foreground">{{ isUploading ? 'Uploading...' : 'Done' }}</span>
|
||||
<button class="text-muted-foreground hover:text-foreground transition-colors" @click="showUploadPopover = false">
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="uploadError > 0 && !isUploading ? 'bg-destructive' : 'bg-primary'"
|
||||
:style="{ width: `${uploadProgress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-[11px]">
|
||||
<span class="text-emerald-600 dark:text-emerald-400 tabular-nums">{{ uploadDone }} done</span>
|
||||
<span v-if="uploadError > 0" class="text-destructive tabular-nums">{{ uploadError }} failed</span>
|
||||
<span class="text-muted-foreground tabular-nums ml-auto">{{ uploadTotal }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSelection" class="flex flex-wrap items-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
|
||||
|
||||
@@ -44,10 +44,12 @@ export function useStagingFiles() {
|
||||
if (reqId !== fetchReqSeq) return
|
||||
items.value = data.items
|
||||
total.value = data.total
|
||||
loading.value = false
|
||||
}
|
||||
} catch {
|
||||
if (reqId === fetchReqSeq) {
|
||||
loading.value = false
|
||||
}
|
||||
} finally {
|
||||
if (reqId !== fetchReqSeq) return
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ watch(
|
||||
newTotal,
|
||||
)
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ArrowUpDown, Bookmark, BookmarkCheck, Filter, Telescope, X } from 'lucide-vue-next'
|
||||
import { ArrowUpDown, Bookmark, BookmarkCheck, BookOpen, Filter, Telescope, X } from 'lucide-vue-next'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||
import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
||||
@@ -51,7 +51,7 @@ watch(
|
||||
newTotal,
|
||||
)
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
{ immediate: true },
|
||||
)
|
||||
onMounted(() => {
|
||||
registerLoadMore(async () => {
|
||||
@@ -417,9 +417,27 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
<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/50" />
|
||||
</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 -->
|
||||
<div
|
||||
v-show="viewMode === 'grid'"
|
||||
v-show="viewMode === 'grid' && books.length > 0"
|
||||
class="grid"
|
||||
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
|
||||
>
|
||||
@@ -435,7 +453,7 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<div v-show="viewMode === 'list'" class="flex flex-col divide-y divide-border">
|
||||
<div v-show="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
|
||||
<BookListRow
|
||||
v-for="book in books"
|
||||
:key="book.id"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Settings2, Trash2, ChevronDown, ChevronUp, ArrowUpDown } from 'lucide-vue-next'
|
||||
import { Settings2, Trash2, ChevronDown, ChevronUp, ArrowUpDown, Aperture } from 'lucide-vue-next'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||
import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
||||
@@ -38,7 +38,7 @@ watch(
|
||||
newTotal,
|
||||
)
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -291,6 +291,9 @@ watch(
|
||||
|
||||
<!-- 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/50" />
|
||||
</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>
|
||||
|
||||
@@ -443,10 +443,16 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Drag-and-drop overlay -->
|
||||
<div v-if="dragOver" class="fixed inset-0 z-40 flex items-center justify-center bg-primary/5 backdrop-blur-sm pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-primary/40 bg-card/80 px-12 py-10 shadow-lg">
|
||||
<PackageOpen class="size-10 text-primary" />
|
||||
<p class="text-sm font-medium text-foreground">Drop files to stage</p>
|
||||
<p class="text-xs text-muted-foreground">Supported: {{ SUPPORTED_FORMATS.join(', ') }}</p>
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 rounded-2xl border-2 border-dashed border-primary/50 bg-card/90 px-12 py-10 shadow-xl max-w-sm w-full"
|
||||
>
|
||||
<div class="flex items-center justify-center size-16 rounded-2xl bg-primary/10 animate-pulse">
|
||||
<PackageOpen class="size-8 text-primary" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-foreground">Drop files to stage</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">Supported: {{ SUPPORTED_FORMATS.join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,53 +1,32 @@
|
||||
# Client typecheck baseline
|
||||
# Auto-generated by: pnpm run typecheck:client:baseline:update
|
||||
# One diagnostic per line. Do not edit manually unless necessary.
|
||||
src/features/book/components/detail/BookDetailHeader.vue(22,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
|
||||
src/features/book/components/detail/BookDetailHeader.vue(23,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(80,23): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(80,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(87,19): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(87,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/library/components/LibraryCreatorScanner.vue(36,36): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/components/CbzReaderView.vue(95,17): error TS2532: Object is possibly 'undefined'.
|
||||
src/features/reader/components/CbzReaderView.vue(99,14): error TS2532: Object is possibly 'undefined'.
|
||||
src/features/reader/components/NoteDialog.vue(14,25): error TS2345: Argument of type '"update:modelValue"' is not assignable to parameter of type '"save"'.
|
||||
src/features/reader/components/PdfToolbar.vue(216,93): error TS2769: No overload matches this call.
|
||||
src/features/reader/components/ReaderSidebar.vue(128,10): error TS2300: Duplicate identifier 'stripFragment'.
|
||||
src/features/reader/components/ReaderSidebar.vue(130,7): error TS7022: 'TocList' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
||||
src/features/reader/components/ReaderSidebar.vue(144,12): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
|
||||
src/features/reader/components/ReaderSidebar.vue(148,25): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
|
||||
src/features/reader/components/ReaderSidebar.vue(7,10): error TS2300: Duplicate identifier 'stripFragment'.
|
||||
src/features/reader/composables/useFoliate.ts(112,36): error TS2352: Conversion of type 'Window & typeof globalThis' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
|
||||
src/features/reader/composables/useFoliateAnnotations.ts(81,48): error TS2349: This expression is not callable.
|
||||
src/features/reader/composables/useFoliateAnnotations.ts(87,50): error TS2349: This expression is not callable.
|
||||
src/features/reader/composables/useFoliateAnnotations.ts(93,48): error TS2349: This expression is not callable.
|
||||
src/features/reader/composables/useFoliateAnnotations.ts(99,52): error TS2349: This expression is not callable.
|
||||
src/features/reader/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/composables/usePdfRenderer.ts(96,17): error TS18048: 'e' is possibly 'undefined'.
|
||||
src/features/reader/composables/usePdfRenderer.ts(97,34): error TS18048: 'f' is possibly 'undefined'.
|
||||
src/features/reader/composables/usePdfRenderer.ts(98,31): error TS18048: 'a' is possibly 'undefined'.
|
||||
src/features/reader/composables/usePdfRenderer.ts(98,50): error TS18048: 'b' is possibly 'undefined'.
|
||||
src/features/reader/composables/usePdfThumbnails.ts(23,25): error TS2345: Argument of type '{ canvasContext: CanvasRenderingContext2D; viewport: PageViewport; }' is not assignable to parameter of type 'RenderParameters'.
|
||||
src/features/reader/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
|
||||
src/features/reader/pdf/components/PdfToolbar.vue(205,93): error TS2769: No overload matches this call.
|
||||
src/features/reader/pdf/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/pdf/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' is possibly 'undefined'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'.
|
||||
src/features/reader/ReaderView.vue(149,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'.
|
||||
src/features/reader/ReaderView.vue(241,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'.
|
||||
src/features/reader/ReaderView.vue(253,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'.
|
||||
src/features/reader/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
|
||||
> client@0.0.0 type-check /Users/developer/Workspace/projectx/client
|
||||
> vue-tsc --build
|
||||
|
||||
src/features/book/components/detail/BookDetailHeader.vue(22,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
|
||||
Type 'undefined' is not assignable to type 'number | null'.
|
||||
src/features/book/components/detail/BookDetailHeader.vue(23,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
|
||||
Type 'undefined' is not assignable to type 'number | null'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(80,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(80,23): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(87,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(87,19): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/library/components/LibraryCreatorScanner.vue(36,36): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
Type 'undefined' is not assignable to type 'string'.
|
||||
src/features/library/composables/useBookUpload.ts(114,5): error TS2304: Cannot find name 'processQueue'.
|
||||
src/features/reader/cbz/CbzReaderView.vue(95,17): error TS2532: Object is possibly 'undefined'.
|
||||
src/features/reader/cbz/CbzReaderView.vue(99,14): error TS2532: Object is possibly 'undefined'.
|
||||
src/features/reader/epub/components/NoteDialog.vue(14,25): error TS2345: Argument of type '"update:modelValue"' is not assignable to parameter of type '"save"'.
|
||||
src/features/reader/epub/components/ReaderSidebar.vue(7,10): error TS2300: Duplicate identifier 'stripFragment'.
|
||||
src/features/reader/epub/components/ReaderSidebar.vue(128,10): error TS2300: Duplicate identifier 'stripFragment'.
|
||||
src/features/reader/epub/components/ReaderSidebar.vue(130,7): error TS7022: 'TocList' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
||||
src/features/reader/epub/components/ReaderSidebar.vue(144,12): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
|
||||
src/features/reader/epub/components/ReaderSidebar.vue(148,25): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
|
||||
src/features/reader/epub/composables/useFoliate.ts(112,36): error TS2352: Conversion of type 'Window & typeof globalThis' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
|
||||
Index signature for type 'string' is missing in type 'Window & typeof globalThis'.
|
||||
src/features/reader/epub/composables/useFoliateAnnotations.ts(81,48): error TS2349: This expression is not callable.
|
||||
Type '{}' has no call signatures.
|
||||
src/features/reader/epub/composables/useFoliateAnnotations.ts(87,50): error TS2349: This expression is not callable.
|
||||
Type '{}' has no call signatures.
|
||||
src/features/reader/epub/composables/useFoliateAnnotations.ts(93,48): error TS2349: This expression is not callable.
|
||||
Type '{}' has no call signatures.
|
||||
src/features/reader/epub/composables/useFoliateAnnotations.ts(99,52): error TS2349: This expression is not callable.
|
||||
Type '{}' has no call signatures.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/epub/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
|
||||
Type 'undefined' is not assignable to type 'string'.
|
||||
src/features/reader/pdf/components/PdfToolbar.vue(205,93): error TS2769: No overload matches this call.
|
||||
The last overload gave the following error.
|
||||
Argument of type '"applyZoomPreset"' is not assignable to parameter of type '"update:cursorTool"'.
|
||||
src/features/reader/pdf/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
Type 'undefined' is not assignable to type 'string'.
|
||||
src/features/reader/pdf/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
Type 'undefined' is not assignable to type 'string'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' is possibly 'undefined'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'.
|
||||
src/features/reader/ReaderView.vue(149,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'.
|
||||
Type 'unknown' is not assignable to type 'RawTocItem'.
|
||||
src/features/reader/ReaderView.vue(241,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'.
|
||||
Property 'search' is missing in type '{}' but required in type 'FoliateView'.
|
||||
src/features/reader/ReaderView.vue(253,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
|
||||
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
|
||||
src/features/book/components/detail/BookDetailHeader.vue(22,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
|
||||
Type 'undefined' is not assignable to type 'number | null'.
|
||||
src/features/book/components/detail/BookDetailHeader.vue(23,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
|
||||
Type 'undefined' is not assignable to type 'number | null'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(80,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(80,23): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(87,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/dashboard/components/DashboardSettingsSheet.vue(87,19): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
|
||||
src/features/library/components/LibraryCreatorScanner.vue(36,36): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
Type 'undefined' is not assignable to type 'string'.
|
||||
src/features/library/composables/useBookUpload.ts(114,5): error TS2304: Cannot find name 'processQueue'.
|
||||
src/features/reader/cbz/CbzReaderView.vue(95,17): error TS2532: Object is possibly 'undefined'.
|
||||
src/features/reader/cbz/CbzReaderView.vue(99,14): error TS2532: Object is possibly 'undefined'.
|
||||
src/features/reader/epub/components/NoteDialog.vue(14,25): error TS2345: Argument of type '"update:modelValue"' is not assignable to parameter of type '"save"'.
|
||||
src/features/reader/epub/components/ReaderSidebar.vue(7,10): error TS2300: Duplicate identifier 'stripFragment'.
|
||||
src/features/reader/epub/components/ReaderSidebar.vue(128,10): error TS2300: Duplicate identifier 'stripFragment'.
|
||||
src/features/reader/epub/components/ReaderSidebar.vue(130,7): error TS7022: 'TocList' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
||||
src/features/reader/epub/components/ReaderSidebar.vue(144,12): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
|
||||
src/features/reader/epub/components/ReaderSidebar.vue(148,25): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
|
||||
src/features/reader/epub/composables/useFoliate.ts(112,36): error TS2352: Conversion of type 'Window & typeof globalThis' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
|
||||
Index signature for type 'string' is missing in type 'Window & typeof globalThis'.
|
||||
src/features/reader/epub/composables/useFoliateAnnotations.ts(81,48): error TS2349: This expression is not callable.
|
||||
Type '{}' has no call signatures.
|
||||
src/features/reader/epub/composables/useFoliateAnnotations.ts(87,50): error TS2349: This expression is not callable.
|
||||
Type '{}' has no call signatures.
|
||||
src/features/reader/epub/composables/useFoliateAnnotations.ts(93,48): error TS2349: This expression is not callable.
|
||||
Type '{}' has no call signatures.
|
||||
src/features/reader/epub/composables/useFoliateAnnotations.ts(99,52): error TS2349: This expression is not callable.
|
||||
Type '{}' has no call signatures.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
|
||||
src/features/reader/epub/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
|
||||
src/features/reader/epub/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
|
||||
Type 'undefined' is not assignable to type 'string'.
|
||||
src/features/reader/pdf/components/PdfToolbar.vue(205,93): error TS2769: No overload matches this call.
|
||||
The last overload gave the following error.
|
||||
Argument of type '"applyZoomPreset"' is not assignable to parameter of type '"update:cursorTool"'.
|
||||
src/features/reader/pdf/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
Type 'undefined' is not assignable to type 'string'.
|
||||
src/features/reader/pdf/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
Type 'undefined' is not assignable to type 'string'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' is possibly 'undefined'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'.
|
||||
src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'.
|
||||
src/features/reader/ReaderView.vue(149,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'.
|
||||
Type 'unknown' is not assignable to type 'RawTocItem'.
|
||||
src/features/reader/ReaderView.vue(241,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'.
|
||||
Property 'search' is missing in type '{}' but required in type 'FoliateView'.
|
||||
src/features/reader/ReaderView.vue(253,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
|
||||
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
|
||||
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
|
||||
ELIFECYCLE Command failed with exit code 2.
|
||||
+2
-2
@@ -51,7 +51,7 @@
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.3.2",
|
||||
"lint-staged": "^16.3.3",
|
||||
"prettier": "^3.8.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
@@ -59,7 +59,7 @@
|
||||
"pnpm exec prettier --write"
|
||||
],
|
||||
"client/src/**/*.{ts,vue}": [
|
||||
"pnpm --dir client exec eslint --fix",
|
||||
"pnpm --dir client exec eslint --fix --max-warnings 0",
|
||||
"pnpm exec prettier --write"
|
||||
],
|
||||
"server/{src,test}/**/*.ts": [
|
||||
|
||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs', 'dist/**'],
|
||||
ignores: ['eslint.config.mjs', 'dist/**', 'coverage/**'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
|
||||
@@ -556,6 +556,7 @@ ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_provider_id_email_pr
|
||||
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_template_id_email_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."email_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "books_library_id_folder_path_idx" ON "books" USING btree ("library_id","folder_path");--> statement-breakpoint
|
||||
CREATE INDEX "authors_name_trgm_idx" ON "authors" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "book_authors_author_id_idx" ON "book_authors" USING btree ("author_id");--> statement-breakpoint
|
||||
CREATE INDEX "bm_title_trgm_idx" ON "book_metadata" USING gin ("title" gin_trgm_ops);--> statement-breakpoint
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "f70633be-a75c-405f-9764-e9770dde00e2",
|
||||
"id": "2ab89874-42c9-4e63-8fd4-376a059ca13c",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -786,7 +786,29 @@
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"books_library_id_folder_path_idx": {
|
||||
"name": "books_library_id_folder_path_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "library_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "folder_path",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"books_library_id_libraries_id_fk": {
|
||||
"name": "books_library_id_libraries_id_fk",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1773131962579,
|
||||
"when": 1773283191294,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { bigint, integer, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import { bigint, integer, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { libraryFolders, libraries } from './libraries';
|
||||
|
||||
export const books = pgTable('books', {
|
||||
id: serial('id').primaryKey(),
|
||||
libraryId: integer('library_id')
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: 'cascade' }),
|
||||
libraryFolderId: integer('library_folder_id')
|
||||
.notNull()
|
||||
.references(() => libraryFolders.id, { onDelete: 'cascade' }),
|
||||
folderPath: varchar('folder_path', { length: 4096 }).notNull(),
|
||||
status: varchar('status', { length: 20 }).notNull().default('present'),
|
||||
addedAt: timestamp('added_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
export const books = pgTable(
|
||||
'books',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
libraryId: integer('library_id')
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: 'cascade' }),
|
||||
libraryFolderId: integer('library_folder_id')
|
||||
.notNull()
|
||||
.references(() => libraryFolders.id, { onDelete: 'cascade' }),
|
||||
folderPath: varchar('folder_path', { length: 4096 }).notNull(),
|
||||
status: varchar('status', { length: 20 }).notNull().default('present'),
|
||||
addedAt: timestamp('added_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [uniqueIndex('books_library_id_folder_path_idx').on(t.libraryId, t.folderPath)],
|
||||
);
|
||||
|
||||
export const bookFiles = pgTable('book_files', {
|
||||
id: serial('id').primaryKey(),
|
||||
|
||||
@@ -554,7 +554,7 @@ describe('BookService', () => {
|
||||
expect(onProgress).toHaveBeenNthCalledWith(2, 2);
|
||||
});
|
||||
|
||||
it('deleteBooks verifies access and removes cover directories without failing on rm errors', async () => {
|
||||
it('deleteBooks verifies access, removes book files, and removes cover directories without failing on rm errors', async () => {
|
||||
const { service, bookRepo, libraryService } = makeService();
|
||||
const user = makeUser();
|
||||
const warnSpy = jest.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
|
||||
@@ -563,6 +563,10 @@ describe('BookService', () => {
|
||||
{ id: 3, libraryId: 7 },
|
||||
{ id: 4, libraryId: 9 },
|
||||
]);
|
||||
bookRepo.findAllFilesByBookIds.mockResolvedValue([
|
||||
{ bookId: 3, absolutePath: '/tmp/library/book3.epub', format: 'epub' },
|
||||
{ bookId: 4, absolutePath: '/tmp/library/book4.pdf', format: 'pdf' },
|
||||
]);
|
||||
bookRepo.deleteByIds.mockResolvedValue(undefined);
|
||||
mockRm.mockRejectedValue(new Error('cannot delete'));
|
||||
|
||||
@@ -573,6 +577,8 @@ describe('BookService', () => {
|
||||
expect(bookRepo.deleteByIds).toHaveBeenCalledWith([3, 4]);
|
||||
expect(mockRm).toHaveBeenCalledWith('/tmp/books/covers/3', { recursive: true, force: true });
|
||||
expect(mockRm).toHaveBeenCalledWith('/tmp/books/covers/4', { recursive: true, force: true });
|
||||
expect(mockRm).toHaveBeenCalledWith('/tmp/library/book3.epub', { force: true });
|
||||
expect(mockRm).toHaveBeenCalledWith('/tmp/library/book4.pdf', { force: true });
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,6 +272,7 @@ export class BookService {
|
||||
const uniqueLibraryIds = [...new Set(rows.map((r) => r.libraryId))];
|
||||
const isSuperuser = this.isSuperuser(user);
|
||||
await Promise.all(uniqueLibraryIds.map((libId) => this.libraryService.verifyUserAccess(user.id, libId, isSuperuser)));
|
||||
const files = await this.bookRepo.findAllFilesByBookIds(bookIds);
|
||||
await this.bookRepo.deleteByIds(bookIds);
|
||||
for (const { id: bookId } of rows) {
|
||||
const coverDir = join(this.booksPath, 'covers', String(bookId));
|
||||
@@ -279,6 +280,9 @@ export class BookService {
|
||||
this.logger.warn(`Failed to delete cover dir ${coverDir}: ${err.message}`),
|
||||
);
|
||||
}
|
||||
for (const { absolutePath } of files) {
|
||||
rm(absolutePath, { force: true }).catch((err: Error) => this.logger.warn(`Failed to delete book file ${absolutePath}: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async updateMetadata(id: number, dto: UpdateBookMetadataDto, user: RequestUser): Promise<BookDetailDto> {
|
||||
|
||||
@@ -207,8 +207,8 @@ export class MetadataService {
|
||||
title: parsed.title,
|
||||
subtitle: parsed.subtitle,
|
||||
description: parsed.description,
|
||||
isbn10: parsed.isbn10,
|
||||
isbn13: parsed.isbn13,
|
||||
isbn10: parsed.isbn10 ? parsed.isbn10.replace(/[^0-9Xx]/g, '') : parsed.isbn10,
|
||||
isbn13: parsed.isbn13 ? parsed.isbn13.replace(/[^0-9]/g, '') : parsed.isbn13,
|
||||
publisher: parsed.publisher,
|
||||
publishedYear: parsed.publishedYear,
|
||||
language: parsed.language,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FileWatcherService } from './file-watcher.service';
|
||||
import { FileEventProcessorService } from './file-event-processor.service';
|
||||
import { ScanGateway } from './scan.gateway';
|
||||
import { ScannerService } from './scanner.service';
|
||||
|
||||
function makeService() {
|
||||
const processor = {
|
||||
@@ -15,9 +16,13 @@ function makeService() {
|
||||
emitBookRestored: jest.fn(),
|
||||
} as unknown as ScanGateway;
|
||||
|
||||
const scannerService = {
|
||||
startScanAsync: jest.fn(),
|
||||
} as unknown as ScannerService;
|
||||
|
||||
const db = {} as any;
|
||||
const service = new FileWatcherService(db, processor, gateway);
|
||||
return { service, processor, gateway };
|
||||
const service = new FileWatcherService(db, processor, gateway, scannerService);
|
||||
return { service, processor, gateway, scannerService };
|
||||
}
|
||||
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
@@ -31,7 +36,7 @@ describe('process()', () => {
|
||||
const missing = { type: 'book-missing', libraryId: 1, bookIds: [10, 11] };
|
||||
processor.handleUnlink = jest.fn().mockResolvedValue(missing);
|
||||
|
||||
await (service as any).process('delete', '/books/Author/book.epub');
|
||||
await (service as any).process('delete', '/books/Author/book.epub', 1);
|
||||
|
||||
expect(gateway.emitBookMissing).toHaveBeenCalledWith({ libraryId: 1, bookIds: [10, 11] });
|
||||
});
|
||||
@@ -42,7 +47,7 @@ describe('process()', () => {
|
||||
processor.handleUnlink = jest.fn().mockResolvedValue({ type: 'noop' });
|
||||
processor.handleUnlinkDir = jest.fn().mockResolvedValue(missing);
|
||||
|
||||
await (service as any).process('delete', '/books/Author');
|
||||
await (service as any).process('delete', '/books/Author', 3);
|
||||
|
||||
expect(processor.handleUnlink).toHaveBeenCalledWith('/books/Author');
|
||||
expect(processor.handleUnlinkDir).toHaveBeenCalledWith('/books/Author');
|
||||
@@ -54,17 +59,27 @@ describe('process()', () => {
|
||||
const restored = { type: 'book-restored', libraryId: 1, bookIds: [7] };
|
||||
processor.handleCreate = jest.fn().mockResolvedValue(restored);
|
||||
|
||||
await (service as any).process('create', '/books/Author/book.epub');
|
||||
await (service as any).process('create', '/books/Author/book.epub', 1);
|
||||
|
||||
expect(processor.handleCreate).toHaveBeenCalledWith('/books/Author/book.epub');
|
||||
expect((gateway as any).emitBookRestored).toHaveBeenCalledWith({ libraryId: 1, bookIds: [7] });
|
||||
expect(gateway.emitBookMissing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('schedules a scan when handleCreate returns noop (genuinely new file)', async () => {
|
||||
const { service, scannerService } = makeService();
|
||||
const scheduleScanSpy = jest.spyOn(service as any, 'scheduleScan');
|
||||
|
||||
await (service as any).process('create', '/books/new.epub', 5);
|
||||
|
||||
expect(scheduleScanSpy).toHaveBeenCalledWith(5);
|
||||
expect(scannerService.startScanAsync).not.toHaveBeenCalled(); // called after debounce, not immediately
|
||||
});
|
||||
|
||||
it('emits nothing when both handlers return noop', async () => {
|
||||
const { service, gateway } = makeService();
|
||||
|
||||
await (service as any).process('delete', '/nowhere/file.epub');
|
||||
await (service as any).process('delete', '/nowhere/file.epub', 1);
|
||||
|
||||
expect(gateway.emitBookMissing).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -77,37 +92,37 @@ describe('schedule() debounce', () => {
|
||||
const { service } = makeService();
|
||||
const processSpy = jest.spyOn(service as any, 'process').mockResolvedValue(undefined);
|
||||
|
||||
(service as any).schedule('delete', '/books/file.epub');
|
||||
(service as any).schedule('delete', '/books/file.epub');
|
||||
(service as any).schedule('delete', '/books/file.epub');
|
||||
(service as any).schedule('delete', '/books/file.epub', 1);
|
||||
(service as any).schedule('delete', '/books/file.epub', 1);
|
||||
(service as any).schedule('delete', '/books/file.epub', 1);
|
||||
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(processSpy).toHaveBeenCalledTimes(1);
|
||||
expect(processSpy).toHaveBeenCalledWith('delete', '/books/file.epub');
|
||||
expect(processSpy).toHaveBeenCalledWith('delete', '/books/file.epub', 1);
|
||||
});
|
||||
|
||||
it('last event type wins when delete and create race for the same path', async () => {
|
||||
const { service } = makeService();
|
||||
const processSpy = jest.spyOn(service as any, 'process').mockResolvedValue(undefined);
|
||||
|
||||
(service as any).schedule('delete', '/books/file.epub');
|
||||
(service as any).schedule('create', '/books/file.epub'); // overrides delete
|
||||
(service as any).schedule('delete', '/books/file.epub', 1);
|
||||
(service as any).schedule('create', '/books/file.epub', 1); // overrides delete
|
||||
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(processSpy).toHaveBeenCalledTimes(1);
|
||||
expect(processSpy).toHaveBeenCalledWith('create', '/books/file.epub');
|
||||
expect(processSpy).toHaveBeenCalledWith('create', '/books/file.epub', 1);
|
||||
});
|
||||
|
||||
it('does not debounce events for different paths', async () => {
|
||||
const { service } = makeService();
|
||||
const processSpy = jest.spyOn(service as any, 'process').mockResolvedValue(undefined);
|
||||
|
||||
(service as any).schedule('delete', '/books/file-a.epub');
|
||||
(service as any).schedule('delete', '/books/file-b.epub');
|
||||
(service as any).schedule('delete', '/books/file-a.epub', 1);
|
||||
(service as any).schedule('delete', '/books/file-b.epub', 1);
|
||||
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -7,25 +7,29 @@ import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { libraries, libraryFolders } from '../../db/schema';
|
||||
import { ScanGateway } from './scan.gateway';
|
||||
import { ScannerService } from './scanner.service';
|
||||
import { FileEventProcessorService, type FileEventResult } from './file-event-processor.service';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type EventType = 'delete' | 'create';
|
||||
|
||||
const DEBOUNCE_MS = 500;
|
||||
const SCAN_DEBOUNCE_MS = 3_000;
|
||||
const RECONCILE_MS = 30_000;
|
||||
|
||||
@Injectable()
|
||||
export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly logger = new Logger(FileWatcherService.name);
|
||||
private readonly subscriptions = new Map<number, AsyncSubscription[]>();
|
||||
private readonly pendingTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; type: EventType }>();
|
||||
private readonly pendingTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; type: EventType; libraryId: number }>();
|
||||
private readonly pendingScanTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly processor: FileEventProcessorService,
|
||||
private readonly gateway: ScanGateway,
|
||||
private readonly scannerService: ScannerService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
@@ -47,6 +51,8 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
if (this.reconcileTimer) clearInterval(this.reconcileTimer);
|
||||
for (const entry of this.pendingTimers.values()) clearTimeout(entry.timer);
|
||||
this.pendingTimers.clear();
|
||||
for (const timer of this.pendingScanTimers.values()) clearTimeout(timer);
|
||||
this.pendingScanTimers.clear();
|
||||
for (const subs of this.subscriptions.values()) {
|
||||
for (const sub of subs) await sub.unsubscribe();
|
||||
}
|
||||
@@ -80,7 +86,7 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
}
|
||||
for (const event of events) {
|
||||
if (event.type === 'delete' || event.type === 'create') {
|
||||
this.schedule(event.type, event.path);
|
||||
this.schedule(event.type, event.path, libraryId);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -98,20 +104,34 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
this.subscriptions.delete(libraryId);
|
||||
}
|
||||
|
||||
private schedule(type: EventType, path: string): void {
|
||||
private scheduleScan(libraryId: number): void {
|
||||
const existing = this.pendingScanTimers.get(libraryId);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingScanTimers.delete(libraryId);
|
||||
this.scannerService.startScanAsync(libraryId);
|
||||
}, SCAN_DEBOUNCE_MS);
|
||||
this.pendingScanTimers.set(libraryId, timer);
|
||||
}
|
||||
|
||||
private schedule(type: EventType, path: string, libraryId: number): void {
|
||||
const existing = this.pendingTimers.get(path);
|
||||
if (existing) clearTimeout(existing.timer);
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingTimers.delete(path);
|
||||
this.process(type, path).catch((err) => this.logger.error(`Failed to process ${type} for ${path}: ${(err as Error).message}`));
|
||||
this.process(type, path, libraryId).catch((err) => this.logger.error(`Failed to process ${type} for ${path}: ${(err as Error).message}`));
|
||||
}, DEBOUNCE_MS);
|
||||
this.pendingTimers.set(path, { timer, type });
|
||||
this.pendingTimers.set(path, { timer, type, libraryId });
|
||||
}
|
||||
|
||||
private async process(type: EventType, path: string): Promise<void> {
|
||||
private async process(type: EventType, path: string, libraryId: number): Promise<void> {
|
||||
let result: FileEventResult;
|
||||
if (type === 'create') {
|
||||
result = await this.processor.handleCreate(path);
|
||||
if (result.type === 'noop') {
|
||||
this.scheduleScan(libraryId);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
result = await this.processor.handleUnlink(path);
|
||||
if (result.type === 'noop') {
|
||||
|
||||
@@ -70,9 +70,16 @@ export class ScannerRepository {
|
||||
}
|
||||
|
||||
async createBook(data: typeof books.$inferInsert) {
|
||||
const [book] = await this.db.insert(books).values(data).returning();
|
||||
const [book] = await this.db
|
||||
.insert(books)
|
||||
.values(data)
|
||||
.onConflictDoUpdate({
|
||||
target: [books.libraryId, books.folderPath],
|
||||
set: { status: 'present', updatedAt: new Date() },
|
||||
})
|
||||
.returning();
|
||||
// Always create an empty metadata row so joins never return null.
|
||||
await this.db.insert(bookMetadata).values({ bookId: book.id });
|
||||
await this.db.insert(bookMetadata).values({ bookId: book.id }).onConflictDoNothing();
|
||||
return book;
|
||||
}
|
||||
|
||||
@@ -91,8 +98,12 @@ export class ScannerRepository {
|
||||
return this.db.select().from(bookFiles).where(eq(bookFiles.libraryFolderId, libraryFolderId));
|
||||
}
|
||||
|
||||
async findBookFileByHash(hash: string) {
|
||||
const [file] = await this.db.select().from(bookFiles).where(eq(bookFiles.hash, hash)).limit(1);
|
||||
async findBookFileByHash(hash: string, libraryFolderId: number) {
|
||||
const [file] = await this.db
|
||||
.select()
|
||||
.from(bookFiles)
|
||||
.where(and(eq(bookFiles.hash, hash), eq(bookFiles.libraryFolderId, libraryFolderId)))
|
||||
.limit(1);
|
||||
return file ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
|
||||
await this.scannerRepo.completeScanJob(jobId, totals);
|
||||
this.logger.log(`Scan job ${jobId} completed — ${JSON.stringify(totals)}`);
|
||||
this.scanJobStore.increment(libraryId, { added: totals.addedCount, updated: totals.updatedCount });
|
||||
this.emitFromStore(libraryId, jobId, 'completed');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -344,7 +345,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
|
||||
// 3. Hash match — cross-filesystem copy (expensive, last resort).
|
||||
const hash = await fingerprintFile(fileStat.absolutePath);
|
||||
const byHash = await this.scannerRepo.findBookFileByHash(hash);
|
||||
const byHash = await this.scannerRepo.findBookFileByHash(hash, libraryFolderId);
|
||||
if (byHash) {
|
||||
await this.scannerRepo.updateBookFile(byHash.id, {
|
||||
absolutePath: fileStat.absolutePath,
|
||||
|
||||
Reference in New Issue
Block a user