feat(library): add deletion with cover cleanup and shared state sync

This commit is contained in:
neon
2026-01-16 02:43:59 -06:00
parent 244d38afe8
commit 2033a3d719
12 changed files with 2284 additions and 23 deletions
@@ -83,9 +83,15 @@ export function useBooks(libraryId: Ref<number | null>) {
}
}
function clear() {
books.value = []
total.value = 0
page.value = 0
}
function onSearch() {
load(true)
}
return { books, total, loading, error, search, hasMore, load, onSearch }
return { books, total, loading, error, search, hasMore, load, onSearch, clear }
}
+8 -2
View File
@@ -135,9 +135,15 @@ onMounted(async () => {
if (isEpubFormat) {
await bookSettings.load()
const effective = bookSettings.effective.value as EpubReaderSettings
shouldApplyStyles.value = effective.overrideBookFormatting || bookSettings.isCustomized.value
if (shouldApplyStyles.value) {
if (effective.overrideBookFormatting) {
shouldApplyStyles.value = true
seedState(effective)
} else if (bookSettings.isCustomized.value) {
// Only apply the per-book delta - don't bleed global defaults like dark theme into the book
shouldApplyStyles.value = true
seedState(bookSettings.bookDelta.value as Partial<ReaderState>)
} else {
shouldApplyStyles.value = false
}
}
@@ -134,6 +134,7 @@ export function useReaderSettings(bookFileId: number, format: string) {
return {
effective,
bookDelta,
isCustomized,
load,
updateBookSettings,
@@ -28,7 +28,7 @@ const defaults: ReaderState = {
maxBlockSize: 1440,
justify: true,
hyphenate: true,
isDark: true,
isDark: false,
themeName: 'default',
flow: 'paginated',
}
@@ -1,25 +1,25 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { FolderOpen, Plus, RefreshCw, Pencil, Library } from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import { FolderOpen, Plus, RefreshCw, Pencil, Trash2, Library } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import type { Library as LibraryType, LibraryStats } from '@projectx/types'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
import { useLibraries } from '@/features/library/composables/useLibraries'
const route = useRoute()
const router = useRouter()
const { libraries, fetchLibraries } = useLibraries()
const libraries = ref<LibraryType[]>([])
const stats = ref<Record<number, LibraryStats>>({})
const scanning = ref<Record<number, boolean>>({})
const scanningAll = ref(false)
const creatorOpen = ref(false)
const editingLibrary = ref<LibraryType | null>(null)
async function loadLibraries() {
const res = await api('/api/libraries')
if (res.ok) {
libraries.value = await res.json()
loadAllStats()
}
}
const deletingLibrary = ref<LibraryType | null>(null)
const deleteConfirmName = ref('')
const deleting = ref(false)
async function loadAllStats() {
await Promise.all(
@@ -30,7 +30,10 @@ async function loadAllStats() {
)
}
onMounted(loadLibraries)
onMounted(async () => {
await fetchLibraries()
loadAllStats()
})
async function scan(lib: LibraryType) {
scanning.value[lib.id] = true
@@ -78,7 +81,44 @@ function openEdit(lib: LibraryType) {
async function onSaved(library: LibraryType) {
creatorOpen.value = false
editingLibrary.value = null
await loadLibraries()
await fetchLibraries()
loadAllStats()
}
function openDelete(lib: LibraryType) {
deletingLibrary.value = lib
deleteConfirmName.value = ''
}
async function confirmDelete() {
if (!deletingLibrary.value) return
deleting.value = true
const deletedId = deletingLibrary.value.id
const deletedName = deletingLibrary.value.name
try {
const res = await api(`/api/libraries/${deletedId}`, { method: 'DELETE' })
if (res.ok) {
toast.success(`"${deletedName}" deleted`)
deletingLibrary.value = null
await fetchLibraries()
loadAllStats()
// If currently viewing the deleted library, navigate to the first remaining one
if (route.name === 'library' && Number(route.params.id) === deletedId) {
const next = libraries.value[0]
if (next) {
router.replace({ name: 'library', params: { id: next.id } })
} else {
router.replace('/')
}
}
} else {
toast.error('Failed to delete library')
}
} catch {
toast.error('Failed to delete library')
} finally {
deleting.value = false
}
}
function formatBytes(bytes: number): string {
@@ -150,6 +190,13 @@ function iconComponent(iconName: string | null | undefined) {
>
<Pencil :size="13" />
</button>
<button
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Delete library"
@click="openDelete(lib)"
>
<Trash2 :size="13" />
</button>
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
:disabled="scanning[lib.id]"
@@ -176,5 +223,39 @@ function iconComponent(iconName: string | null | undefined) {
@close="creatorOpen = false; editingLibrary = null"
@saved="onSaved"
/>
<!-- Delete confirmation dialog -->
<div v-if="deletingLibrary" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div class="bg-card border border-border rounded-xl shadow-xl w-full max-w-sm p-6">
<h3 class="text-base font-semibold text-foreground mb-1">Delete "{{ deletingLibrary.name }}"?</h3>
<p class="text-sm text-muted-foreground mb-4">
This will permanently remove all books, metadata, reading progress, bookmarks, and annotations in this library. This cannot be undone.
</p>
<p class="text-sm text-foreground mb-2">Type the library name to confirm:</p>
<input
v-model="deleteConfirmName"
type="text"
:placeholder="deletingLibrary.name"
class="w-full text-sm border border-border rounded-md px-3 py-2 bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-destructive mb-4"
@keydown.enter="deleteConfirmName === deletingLibrary.name && !deleting ? confirmDelete() : null"
@keydown.escape="deletingLibrary = null"
/>
<div class="flex justify-end gap-2">
<button
class="px-4 py-2 text-sm font-medium rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground"
@click="deletingLibrary = null"
>
Cancel
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-md bg-destructive text-destructive-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="deleteConfirmName !== deletingLibrary.name || deleting"
@click="confirmDelete"
>
{{ deleting ? 'Deleting...' : 'Delete Library' }}
</button>
</div>
</div>
</div>
</div>
</template>
+5 -2
View File
@@ -27,7 +27,7 @@ const libraryId = computed<number | null>(() => {
const title = computed(() => libraries.value.find((l) => l.id === libraryId.value)?.name ?? 'Library')
const { books, total, loading, error, search, hasMore, load, onSearch } = useBooks(libraryId)
const { books, total, loading, error, search, hasMore, load, onSearch, clear } = useBooks(libraryId)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
@@ -61,7 +61,10 @@ onMounted(async () => {
onUnmounted(() => observer?.disconnect())
watch(libraryId, (newId, oldId) => {
if (newId !== null && newId !== oldId) {
if (newId === oldId) return
if (newId === null) {
clear()
} else {
search.value = ''
load(true)
}