mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(library): add deletion with cover cleanup and shared state sync
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "scan_jobs" DROP CONSTRAINT "scan_jobs_library_id_libraries_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "scan_jobs" ADD CONSTRAINT "scan_jobs_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,16 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1768596999296,
|
||||
"when": 1768599599999,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1768599599999,
|
||||
"tag": "0001_add_scan_jobs_cascade",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const scanJobs = pgTable('scan_jobs', {
|
||||
id: serial('id').primaryKey(),
|
||||
libraryId: integer('library_id')
|
||||
.notNull()
|
||||
.references(() => libraries.id),
|
||||
.references(() => libraries.id, { onDelete: 'cascade' }),
|
||||
status: varchar('status', { length: 20 }).notNull().default('running'),
|
||||
triggeredBy: varchar('triggered_by', { length: 20 }).notNull(),
|
||||
addedCount: integer('added_count').notNull().default(0),
|
||||
|
||||
@@ -75,6 +75,10 @@ export class LibraryRepository {
|
||||
return this.db.insert(libraryFolders).values(data).returning();
|
||||
}
|
||||
|
||||
findBookIdsByLibrary(libraryId: number) {
|
||||
return this.db.select({ id: books.id }).from(books).where(eq(books.libraryId, libraryId));
|
||||
}
|
||||
|
||||
delete(id: number) {
|
||||
return this.db.delete(libraries).where(eq(libraries.id, id));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { access, readdir, stat } from 'fs/promises';
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { access, readdir, rm, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
@@ -13,7 +14,15 @@ import { LibraryRepository } from './library.repository';
|
||||
|
||||
@Injectable()
|
||||
export class LibraryService {
|
||||
constructor(private readonly libraryRepo: LibraryRepository) {}
|
||||
private readonly logger = new Logger(LibraryService.name);
|
||||
private readonly booksPath: string;
|
||||
|
||||
constructor(
|
||||
private readonly libraryRepo: LibraryRepository,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.booksPath = this.config.get<string>('storage.booksPath')!;
|
||||
}
|
||||
|
||||
async verifyUserAccess(userId: number, libraryId: number, isSuperuser: boolean): Promise<void> {
|
||||
if (isSuperuser) return;
|
||||
@@ -81,7 +90,15 @@ export class LibraryService {
|
||||
async remove(id: number) {
|
||||
const [existing] = await this.libraryRepo.findById(id);
|
||||
if (!existing) throw new NotFoundException('Library not found');
|
||||
|
||||
const bookRows = await this.libraryRepo.findBookIdsByLibrary(id);
|
||||
await this.libraryRepo.delete(id);
|
||||
|
||||
// Clean up cover/thumbnail directories for all deleted books (best-effort, non-blocking)
|
||||
for (const { id: bookId } of bookRows) {
|
||||
const coverDir = join(this.booksPath, 'covers', String(bookId));
|
||||
rm(coverDir, { recursive: true, force: true }).catch((err) => this.logger.warn(`Failed to delete cover dir ${coverDir}: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async prescan(dto: PrescanLibraryDto) {
|
||||
|
||||
Reference in New Issue
Block a user