feat: add empty states and improve sidebar badge styling

This commit is contained in:
neon
2026-03-11 09:47:42 -06:00
parent 2102d0f9d8
commit 173d7585bf
58 changed files with 855 additions and 340 deletions
+7
View File
@@ -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,
-1
View File
@@ -57,4 +57,3 @@
--shadow-xl: var(--elevation-xl);
--shadow-2xl: var(--elevation-2xl);
}
-1
View File
@@ -10,4 +10,3 @@ html.radius-rounded {
html.radius-pill {
--radius: 2.5rem;
}
+16 -11
View File
@@ -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
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ watch(
newTotal,
)
},
{ deep: true, immediate: true },
{ immediate: true },
)
onMounted(() => {
+22 -4
View File
@@ -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"
+5 -2
View File
@@ -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>
+10 -4
View File
@@ -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>
+22 -43
View File
@@ -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'.
+161
View File
@@ -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
View File
@@ -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": [
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1773131962579,
"when": 1773283191294,
"tag": "0000_baseline",
"breakpoints": true
}
+21 -17
View File
@@ -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(),
+7 -1
View File
@@ -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();
});
});
+4
View File
@@ -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,