feat(collections): add full CRUD with multi-collection assignment

This commit is contained in:
neon
2026-02-01 20:25:16 -06:00
parent 33208254d9
commit 676ca4c1a7
29 changed files with 1486 additions and 35 deletions
+57 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import * as Icons from 'lucide-vue-next' import * as Icons from 'lucide-vue-next'
import { Aperture, BookMarked, Clock, BookOpen, Plus } from 'lucide-vue-next' import { Aperture, BookMarked, Clock, BookOpen, FolderOpen, Plus } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { import {
@@ -19,14 +19,18 @@ import {
} from '@/components/ui/sidebar' } from '@/components/ui/sidebar'
import { useLibraries } from '@/features/library/composables/useLibraries' import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLenses } from '@/features/lens/composables/useLenses' import { useLenses } from '@/features/lens/composables/useLenses'
import { useCollections } from '@/features/collection/composables/useCollections'
import CreateLensDialog from '@/features/lens/components/CreateLensDialog.vue' import CreateLensDialog from '@/features/lens/components/CreateLensDialog.vue'
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { libraries, fetchLibraries } = useLibraries() const { libraries, fetchLibraries } = useLibraries()
const { lenses, fetchLenses } = useLenses() const { lenses, fetchLenses } = useLenses()
const { collections, fetchCollections } = useCollections()
const createLensOpen = ref(false) const createLensOpen = ref(false)
const createCollectionOpen = ref(false)
const activeLibraryId = computed(() => { const activeLibraryId = computed(() => {
const id = route.params.id const id = route.params.id
@@ -48,14 +52,21 @@ function getLensIcon(name: string | null | undefined) {
return Aperture return Aperture
} }
const activeCollectionId = computed(() => {
const id = route.params.id
return route.name === 'collection' && id ? Number(id) : null
})
onMounted(() => { onMounted(() => {
fetchLibraries() fetchLibraries()
fetchLenses() fetchLenses()
fetchCollections()
}) })
</script> </script>
<template> <template>
<CreateLensDialog :open="createLensOpen" @close="createLensOpen = false" /> <CreateLensDialog :open="createLensOpen" @close="createLensOpen = false" />
<CreateCollectionDialog :open="createCollectionOpen" @close="createCollectionOpen = false" />
<Sidebar <Sidebar
collapsible="icon" collapsible="icon"
@@ -113,7 +124,10 @@ onMounted(() => {
<span :class="activeLibraryId === lib.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'"> <span :class="activeLibraryId === lib.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
{{ lib.name }} {{ lib.name }}
</span> </span>
<span v-if="lib.bookCount !== undefined" class="ml-auto shrink-0 rounded-full bg-sidebar-foreground/10 px-2 py-0.5 text-xs tabular-nums text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden"> <span
v-if="lib.bookCount !== undefined"
class="ml-auto shrink-0 rounded-full bg-sidebar-foreground/10 px-2 py-0.5 text-xs tabular-nums text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden"
>
{{ lib.bookCount.toLocaleString() }} {{ lib.bookCount.toLocaleString() }}
</span> </span>
</SidebarMenuButton> </SidebarMenuButton>
@@ -149,7 +163,10 @@ onMounted(() => {
<span :class="activeLensId === lens.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'"> <span :class="activeLensId === lens.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
{{ lens.name }} {{ lens.name }}
</span> </span>
<span v-if="lens.bookCount !== undefined" class="ml-auto shrink-0 rounded-full bg-sidebar-foreground/10 px-2 py-0.5 text-xs tabular-nums text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden"> <span
v-if="lens.bookCount !== undefined"
class="ml-auto shrink-0 rounded-full bg-sidebar-foreground/10 px-2 py-0.5 text-xs tabular-nums text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden"
>
{{ lens.bookCount.toLocaleString() }} {{ lens.bookCount.toLocaleString() }}
</span> </span>
</SidebarMenuButton> </SidebarMenuButton>
@@ -163,6 +180,43 @@ onMounted(() => {
<SidebarSeparator class="group-data-[collapsible=icon]:hidden" /> <SidebarSeparator class="group-data-[collapsible=icon]:hidden" />
<!-- Collections -->
<SidebarGroup>
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
Collections
</SidebarGroupLabel>
<SidebarGroupAction tooltip="New Collection" @click="createCollectionOpen = true">
<Plus />
</SidebarGroupAction>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="collection in collections" :key="collection.id">
<SidebarMenuButton
:is-active="activeCollectionId === collection.id"
:tooltip="collection.name"
class="gap-2.5"
@click="router.push({ name: 'collection', params: { id: collection.id } })"
>
<FolderOpen :size="15" :class="activeCollectionId === collection.id ? 'text-sidebar-primary' : 'text-sidebar-foreground/50'" />
<span :class="activeCollectionId === collection.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
{{ collection.name }}
</span>
<span
class="ml-auto shrink-0 rounded-full bg-sidebar-foreground/10 px-2 py-0.5 text-xs tabular-nums text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden"
>
{{ collection.bookCount.toLocaleString() }}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="collections.length === 0">
<span class="px-2 py-1 text-xs text-sidebar-foreground/35 group-data-[collapsible=icon]:hidden">No collections yet</span>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator class="group-data-[collapsible=icon]:hidden" />
<!-- Browse --> <!-- Browse -->
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden"> <SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { FolderMinus, FolderPlus, Trash2, X } from 'lucide-vue-next'
defineProps<{
count: number
visible: boolean
inCollection?: boolean
}>()
const emit = defineEmits<{
'add-to-collection': []
'remove-from-collection': []
delete: []
exit: []
}>()
</script>
<template>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-4"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-4"
>
<div
v-if="visible"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 px-2 py-1.5 rounded-full bg-card border border-border shadow-xl"
>
<!-- Count -->
<span class="px-3 text-sm font-semibold text-foreground tabular-nums whitespace-nowrap"> {{ count }} selected </span>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<!-- Add to Collection -->
<button
:disabled="count === 0"
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/40 cursor-not-allowed'"
title="Add to Collection"
@click="emit('add-to-collection')"
>
<FolderPlus :size="14" />
<span class="hidden sm:inline">Add to Collection</span>
</button>
<!-- Remove from Collection (only inside a collection view) -->
<button
v-if="inCollection"
:disabled="count === 0"
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium transition-colors"
:class="count > 0 ? 'text-destructive hover:bg-destructive hover:text-destructive-foreground' : 'text-muted-foreground/40 cursor-not-allowed'"
title="Remove from Collection"
@click="emit('remove-from-collection')"
>
<FolderMinus :size="14" />
<span class="hidden sm:inline">Remove</span>
</button>
<!-- Delete -->
<button
:disabled="count === 0"
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium transition-colors"
:class="count > 0 ? 'text-destructive hover:bg-destructive hover:text-destructive-foreground' : 'text-muted-foreground/40 cursor-not-allowed'"
title="Delete selected"
@click="emit('delete')"
>
<Trash2 :size="14" />
<span class="hidden sm:inline">Delete</span>
</button>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<!-- Exit selection mode -->
<button
class="h-8 w-8 flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Exit selection"
@click="emit('exit')"
>
<X :size="14" />
</button>
</div>
</Transition>
</template>
+24 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { LayoutGrid, List, MoreHorizontal, SlidersHorizontal } from 'lucide-vue-next' import { CheckSquare, LayoutGrid, List, MoreHorizontal, SlidersHorizontal, Square } from 'lucide-vue-next'
import * as LucideIcons from 'lucide-vue-next' import * as LucideIcons from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -24,12 +24,14 @@ const props = defineProps<{
coverSize: number coverSize: number
gridGap: number gridGap: number
viewMode: 'grid' | 'list' viewMode: 'grid' | 'list'
selectionMode?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:coverSize': [value: number] 'update:coverSize': [value: number]
'update:gridGap': [value: number] 'update:gridGap': [value: number]
'update:viewMode': [value: 'grid' | 'list'] 'update:viewMode': [value: 'grid' | 'list']
'toggle-selection': []
}>() }>()
const mobileDisplayOpen = ref(false) const mobileDisplayOpen = ref(false)
@@ -57,6 +59,21 @@ function getIconComponent(name: string) {
<slot name="toolbar" /> <slot name="toolbar" />
<slot name="actions" /> <slot name="actions" />
<!-- Select mode toggle -->
<Button
variant="ghost"
size="sm"
class="hidden md:flex h-8 gap-1.5 text-xs px-2.5"
:class="selectionMode ? 'text-primary bg-primary/10 hover:bg-primary/20' : 'text-muted-foreground hover:text-foreground'"
@click="emit('toggle-selection')"
>
<CheckSquare v-if="selectionMode" :size="14" />
<Square v-else :size="14" />
Select
</Button>
<Separator orientation="vertical" class="hidden md:block mx-1 h-4" />
<!-- Desktop: view mode toggle --> <!-- Desktop: view mode toggle -->
<div class="hidden md:flex items-center"> <div class="hidden md:flex items-center">
<Button <Button
@@ -142,6 +159,12 @@ function getIconComponent(name: string) {
<SlidersHorizontal :size="14" class="mr-2" /> <SlidersHorizontal :size="14" class="mr-2" />
Display Display
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="emit('toggle-selection')">
<CheckSquare v-if="selectionMode" :size="14" class="mr-2" />
<Square v-else :size="14" class="mr-2" />
{{ selectionMode ? 'Exit Select' : 'Select' }}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -4,16 +4,23 @@ import { FORMAT_TO_GROUP } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover' import { bookCoverStyle } from '../lib/book-cover'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2, TriangleAlert } from 'lucide-vue-next' import { BookOpen, Check, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2, TriangleAlert } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useCoverVersions } from '../composables/useCoverVersions' import { useCoverVersions } from '../composables/useCoverVersions'
const router = useRouter() const router = useRouter()
const props = defineProps<{ book: BookCard }>() const props = defineProps<{
book: BookCard
selectionMode?: boolean
selected?: boolean
}>()
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete' type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
const emit = defineEmits<{ action: [type: BookActionType] }>() const emit = defineEmits<{
action: [type: BookActionType]
select: []
}>()
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id))) const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
const authorLine = computed(() => props.book.authors.join(', ') || null) const authorLine = computed(() => props.book.authors.join(', ') || null)
@@ -46,13 +53,16 @@ function openFile(file: BookFileRef) {
<template> <template>
<div <div
class="group flex flex-col @container" class="group flex flex-col @container"
:class="primaryFile && !isMissing ? 'cursor-pointer' : 'cursor-default'" :class="selectionMode || (primaryFile && !isMissing) ? 'cursor-pointer' : 'cursor-default'"
@click="primaryFile && !isMissing && openFile(primaryFile)" @click="selectionMode ? emit('select') : primaryFile && !isMissing && openFile(primaryFile)"
> >
<!-- Cover --> <!-- Cover -->
<div <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-all duration-150"
:class="isMissing ? 'ring-2 ring-amber-500' : 'group-hover:shadow-xl group-hover:scale-[1.02]'" :class="[
isMissing ? 'ring-2 ring-amber-500' : selectionMode ? '' : 'group-hover:shadow-xl group-hover:scale-[1.02]',
selected ? 'ring-2 ring-primary' : '',
]"
style="aspect-ratio: 2/3" style="aspect-ratio: 2/3"
:style="coverLoaded ? {} : coverStyle" :style="coverLoaded ? {} : coverStyle"
> >
@@ -77,6 +87,16 @@ function openFile(file: BookFileRef) {
</span> </span>
</div> </div>
<!-- Selection checkbox overlay -->
<div v-if="selectionMode" class="absolute inset-0 z-30 pointer-events-none" :class="selected ? 'bg-primary/20' : ''">
<div
class="absolute top-1.5 left-1.5 h-5 w-5 rounded flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'bg-black/40 border border-white/50'"
>
<Check v-if="selected" class="text-primary-foreground" :size="12" />
</div>
</div>
<!-- Missing badge --> <!-- Missing badge -->
<div v-if="isMissing" class="absolute top-1.5 right-1.5 z-20"> <div v-if="isMissing" class="absolute top-1.5 right-1.5 z-20">
<span <span
@@ -99,6 +119,7 @@ function openFile(file: BookFileRef) {
<!-- Hover overlay --> <!-- Hover overlay -->
<div <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/60 backdrop-blur-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-150"
@click.stop @click.stop
> >
@@ -3,16 +3,23 @@ import type { BookCard, BookFileRef } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover' import { bookCoverStyle } from '../lib/book-cover'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2, TriangleAlert } from 'lucide-vue-next' import { BookOpen, Check, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2, TriangleAlert } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useCoverVersions } from '../composables/useCoverVersions' import { useCoverVersions } from '../composables/useCoverVersions'
const router = useRouter() const router = useRouter()
const props = defineProps<{ book: BookCard }>() const props = defineProps<{
book: BookCard
selectionMode?: boolean
selected?: boolean
}>()
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete' type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
const emit = defineEmits<{ action: [type: BookActionType] }>() const emit = defineEmits<{
action: [type: BookActionType]
select: []
}>()
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id))) const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
const authorLine = computed(() => props.book.authors.join(', ') || null) const authorLine = computed(() => props.book.authors.join(', ') || null)
@@ -44,9 +51,22 @@ function openFile(file: BookFileRef) {
<template> <template>
<div <div
class="flex items-center gap-3 py-2 px-2 rounded-md transition-colors" class="flex items-center gap-3 py-2 px-2 rounded-md transition-colors"
:class="[primaryFile && !isMissing ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default', isMissing ? 'opacity-60 grayscale' : '']" :class="[
@click="primaryFile && !isMissing && openFile(primaryFile)" selectionMode || (primaryFile && !isMissing) ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default',
isMissing ? 'opacity-60 grayscale' : '',
selected ? 'bg-primary/8 ring-1 ring-primary/30' : '',
]"
@click="selectionMode ? emit('select') : primaryFile && !isMissing && openFile(primaryFile)"
> >
<!-- Selection checkbox -->
<div
v-if="selectionMode"
class="h-5 w-5 rounded shrink-0 flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'border border-border bg-background'"
>
<Check v-if="selected" class="text-primary-foreground" :size="12" />
</div>
<!-- Cover --> <!-- Cover -->
<div class="h-16 w-12 rounded shrink-0 overflow-hidden relative" :style="coverLoaded ? {} : coverStyle"> <div class="h-16 w-12 rounded shrink-0 overflow-hidden relative" :style="coverLoaded ? {} : coverStyle">
<img <img
@@ -69,7 +89,7 @@ function openFile(file: BookFileRef) {
</div> </div>
<!-- Right badges + actions --> <!-- Right badges + actions -->
<div class="flex items-center gap-2 shrink-0" @click.stop> <div v-if="!selectionMode" class="flex items-center gap-2 shrink-0" @click.stop>
<div class="flex flex-col items-end gap-1"> <div class="flex flex-col items-end gap-1">
<span <span
v-if="isMissing" v-if="isMissing"
@@ -0,0 +1,42 @@
import { computed, ref } from 'vue'
export function useBookSelection() {
const selectionMode = ref(false)
const selectedIds = ref<Set<number>>(new Set())
const selectedCount = computed(() => selectedIds.value.size)
function enterSelectionMode() {
selectionMode.value = true
}
function exitSelectionMode() {
selectionMode.value = false
selectedIds.value = new Set()
}
function toggleBook(id: number) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function selectAll(ids: number[]) {
selectedIds.value = new Set(ids)
}
function isSelected(id: number) {
return selectedIds.value.has(id)
}
return {
selectionMode,
selectedIds,
selectedCount,
enterSelectionMode,
exitSelectionMode,
toggleBook,
selectAll,
isSelected,
}
}
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { Check, FolderPlus, Loader2, Plus } from 'lucide-vue-next'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCollections } from '../composables/useCollections'
import type { Collection } from '@projectx/types'
const props = defineProps<{
open: boolean
bookIds: number[]
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
done: []
}>()
const { fetchCollectionsWithMembership, createCollection, addBooksToCollection } = useCollections()
const localCollections = ref<Collection[]>([])
const addedIds = ref<Set<number>>(new Set())
const newName = ref('')
const creating = ref(false)
const addingTo = ref<number | null>(null)
watch(
() => props.open,
async (open) => {
if (open) {
addedIds.value = new Set()
try {
localCollections.value = await fetchCollectionsWithMembership(props.bookIds)
} catch {
toast.error('Failed to load collections')
}
}
},
{ immediate: true },
)
function isFullyAdded(collection: Collection): boolean {
if (props.bookIds.length === 0) return false
return addedIds.value.has(collection.id) || (collection.memberCount ?? 0) >= props.bookIds.length
}
function partialCount(collection: Collection): number {
return addedIds.value.has(collection.id) ? props.bookIds.length : (collection.memberCount ?? 0)
}
async function handleCreate() {
const name = newName.value.trim()
if (!name) return
creating.value = true
try {
const collection = await createCollection(name)
newName.value = ''
localCollections.value = [...localCollections.value, collection]
try {
await addBooksToCollection(collection.id, props.bookIds)
collection.memberCount = props.bookIds.length
addedIds.value = new Set([...addedIds.value, collection.id])
toast.success(`Created "${collection.name}" and added ${props.bookIds.length} book${props.bookIds.length === 1 ? '' : 's'}`)
} catch {
toast.error(`Created "${collection.name}" but failed to add books`)
}
} catch {
toast.error('Failed to create collection')
} finally {
creating.value = false
}
}
async function handleAddTo(collection: Collection) {
if (isFullyAdded(collection)) return
addingTo.value = collection.id
try {
await addBooksToCollection(collection.id, props.bookIds)
addedIds.value = new Set([...addedIds.value, collection.id])
localCollections.value = localCollections.value.map((c) => (c.id === collection.id ? { ...c, memberCount: props.bookIds.length } : c))
const alreadyHad = collection.memberCount ?? 0
const added = props.bookIds.length - alreadyHad
const msg =
alreadyHad > 0
? `Added ${added} new book${added === 1 ? '' : 's'} to "${collection.name}" (${alreadyHad} already there)`
: `Added ${props.bookIds.length} book${props.bookIds.length === 1 ? '' : 's'} to "${collection.name}"`
toast.success(msg)
} catch {
toast.error('Failed to add to collection')
} finally {
addingTo.value = null
}
}
function handleDone() {
if (addedIds.value.size > 0) emit('done')
emit('update:open', false)
}
</script>
<template>
<Sheet :open="open" @update:open="emit('update:open', $event)">
<SheetContent
side="bottom"
class="max-h-[80vh] overflow-y-auto sm:inset-x-auto sm:right-auto sm:left-1/2 sm:-translate-x-1/2 sm:w-full sm:max-w-lg sm:rounded-t-lg"
>
<SheetHeader>
<SheetTitle class="flex items-center gap-2">
<FolderPlus :size="16" />
Add to Collection
</SheetTitle>
</SheetHeader>
<div class="px-4 pb-4 space-y-4">
<p class="text-xs text-muted-foreground">{{ bookIds.length }} book{{ bookIds.length === 1 ? '' : 's' }} selected</p>
<!-- Create new collection -->
<div class="space-y-2">
<p class="text-xs font-medium text-foreground uppercase tracking-wider">New Collection</p>
<div class="flex gap-2">
<Input v-model="newName" placeholder="Collection name" class="flex-1" @keydown.enter="handleCreate" />
<Button :disabled="!newName.trim() || creating" size="sm" @click="handleCreate">
<Loader2 v-if="creating" :size="14" class="animate-spin mr-1" />
<Plus v-else :size="14" class="mr-1" />
Create
</Button>
</div>
</div>
<!-- Existing collections -->
<div v-if="localCollections.length > 0" class="space-y-2">
<p class="text-xs font-medium text-foreground uppercase tracking-wider">Collections</p>
<div class="space-y-1">
<button
v-for="collection in localCollections"
:key="collection.id"
class="w-full flex items-center justify-between px-3 py-2.5 rounded-md transition-colors text-left"
:class="
isFullyAdded(collection)
? 'opacity-50 cursor-default'
: addingTo === collection.id
? 'bg-muted cursor-wait'
: 'hover:bg-muted cursor-pointer'
"
:disabled="isFullyAdded(collection) || addingTo === collection.id"
@click="handleAddTo(collection)"
>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-foreground truncate">{{ collection.name }}</span>
<span class="text-xs text-muted-foreground">
<template v-if="isFullyAdded(collection)">All {{ bookIds.length }} already here</template>
<template v-else-if="partialCount(collection) > 0"> {{ partialCount(collection) }} of {{ bookIds.length }} already here </template>
<template v-else>{{ collection.bookCount }} book{{ collection.bookCount === 1 ? '' : 's' }}</template>
</span>
</div>
<Loader2 v-if="addingTo === collection.id" :size="18" class="animate-spin text-muted-foreground shrink-0" />
<Check v-else-if="isFullyAdded(collection)" :size="18" class="text-primary shrink-0" />
<Plus v-else :size="18" class="text-muted-foreground shrink-0" />
</button>
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-xs text-muted-foreground">No collections yet. Create one above.</p>
</div>
<!-- Done -->
<div class="pt-2 border-t border-border">
<Button variant="outline" class="w-full" @click="handleDone">Done</Button>
</div>
</div>
</SheetContent>
</Sheet>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { X } from 'lucide-vue-next'
import { useCollections } from '../composables/useCollections'
defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const router = useRouter()
const { createCollection } = useCollections()
const name = ref('')
const saving = ref(false)
const error = ref<string | null>(null)
async function submit() {
if (!name.value.trim()) return
saving.value = true
error.value = null
try {
const collection = await createCollection(name.value.trim())
name.value = ''
emit('close')
router.push({ name: 'collection', params: { id: collection.id } })
} catch {
error.value = 'Failed to create collection'
} finally {
saving.value = false
}
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="emit('close')" />
<div class="relative z-10 w-full max-w-md mx-4 bg-card border border-border rounded-xl shadow-2xl p-6">
<div class="flex items-center justify-between mb-5">
<h2 class="text-base font-semibold text-foreground">New Collection</h2>
<button @click="emit('close')" class="text-muted-foreground hover:text-foreground transition-colors">
<X :size="18" />
</button>
</div>
<form @submit.prevent="submit" class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium text-foreground">Name</label>
<input
v-model="name"
type="text"
placeholder="e.g. Favorites"
autofocus
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-3 focus:outline-none focus:ring-2 focus:ring-primary placeholder:text-muted-foreground"
/>
</div>
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
<div class="flex justify-end gap-2 mt-1">
<button
type="button"
@click="emit('close')"
class="h-9 px-4 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
<button
type="submit"
:disabled="!name.trim() || saving"
class="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ saving ? 'Creating...' : 'Create' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,36 @@
import { ref, type Ref } from 'vue'
import { api } from '@/lib/api'
import type { BookCard } from '@projectx/types'
const PAGE_SIZE = 50
export function useCollectionBooks(collectionId: Ref<number>) {
const items = ref<BookCard[]>([])
const total = ref(0)
const loading = ref(false)
const hasMore = ref(false)
let page = 0
async function load(reset = false) {
if (loading.value) return
if (!reset && !hasMore.value) return
if (reset) {
page = 0
items.value = []
}
loading.value = true
try {
const res = await api(`/api/collections/${collectionId.value}/books?page=${page}&size=${PAGE_SIZE}`)
if (!res.ok) throw new Error('Failed to load collection books')
const data = await res.json()
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
hasMore.value = items.value.length < data.total
page++
} finally {
loading.value = false
}
}
return { items, total, loading, hasMore, load }
}
@@ -0,0 +1,64 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { Collection } from '@projectx/types'
const collections = ref<Collection[]>([])
export function useCollections() {
async function fetchCollections() {
const res = await api('/api/collections')
if (!res.ok) throw new Error('Failed to fetch collections')
collections.value = await res.json()
}
async function fetchCollectionsWithMembership(bookIds: number[]): Promise<Collection[]> {
const res = await api(`/api/collections?bookIds=${bookIds.join(',')}`)
if (!res.ok) throw new Error('Failed to fetch collections')
return res.json()
}
async function createCollection(name: string, description?: string): Promise<Collection> {
const res = await api('/api/collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
})
if (!res.ok) throw new Error('Failed to create collection')
const created: Collection = await res.json()
collections.value = [...collections.value, created]
return created
}
async function addBooksToCollection(collectionId: number, bookIds: number[]): Promise<Collection> {
const res = await api(`/api/collections/${collectionId}/books`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds }),
})
if (!res.ok) throw new Error('Failed to add books to collection')
const updated: Collection = await res.json()
collections.value = collections.value.map((c) => (c.id === collectionId ? updated : c))
return updated
}
async function removeBooksFromCollection(collectionId: number, bookIds: number[]): Promise<Collection> {
const res = await api(`/api/collections/${collectionId}/books`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds }),
})
if (!res.ok) throw new Error('Failed to remove books from collection')
const updated: Collection = await res.json()
collections.value = collections.value.map((c) => (c.id === collectionId ? updated : c))
return updated
}
return {
collections,
fetchCollections,
fetchCollectionsWithMembership,
createCollection,
addBooksToCollection,
removeBooksFromCollection,
}
}
+5
View File
@@ -20,6 +20,11 @@ const router = createRouter({
name: 'lens', name: 'lens',
component: () => import('@/views/LensView.vue'), component: () => import('@/views/LensView.vue'),
}, },
{
path: '/collection/:id',
name: 'collection',
component: () => import('@/views/CollectionView.vue'),
},
{ {
path: '/read/:bookId/:fileId', path: '/read/:bookId/:fileId',
name: 'reader', name: 'reader',
+185
View File
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { FolderOpen } 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'
import AppHeader from '@/components/AppHeader.vue'
import ViewHeader from '@/components/ViewHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { toast } from 'vue-sonner'
import { useCollections } from '@/features/collection/composables/useCollections'
import { useCollectionBooks } from '@/features/collection/composables/useCollectionBooks'
import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import type { BookCard } from '@projectx/types'
const route = useRoute()
const router = useRouter()
const themeStore = useThemeStore()
const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '')
const { coverSize, gridGap, viewMode } = useDisplaySettings()
const collectionId = computed(() => Number(route.params.id))
const { collections, fetchCollections, removeBooksFromCollection } = useCollections()
const collection = computed(() => collections.value.find((c) => c.id === collectionId.value))
const { items: books, total, loading, hasMore, load } = useCollectionBooks(collectionId)
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, isSelected } = useBookSelection()
function toggleSelectionMode() {
if (selectionMode.value) exitSelectionMode()
else enterSelectionMode()
}
const addToCollectionOpen = ref(false)
let removingInProgress = false
const quickViewBookId = ref<number | null>(null)
const quickViewOpen = ref(false)
async function handleRemoveFromCollection() {
if (removingInProgress || !collectionId.value || selectedIds.value.size === 0) return
removingInProgress = true
try {
const ids = [...selectedIds.value]
await removeBooksFromCollection(collectionId.value, ids)
load(true)
exitSelectionMode()
toast.success(`Removed ${ids.length} book${ids.length === 1 ? '' : 's'} from collection`)
} catch {
toast.error('Failed to remove books from collection')
} finally {
removingInProgress = false
}
}
function handleBookAction(book: BookCard, action: 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete') {
if (action === 'quick-view') {
quickViewBookId.value = book.id
quickViewOpen.value = true
}
}
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
onMounted(async () => {
await fetchCollections()
if (!collection.value) {
router.push({ name: 'home' })
return
}
load(true)
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loading.value) load()
},
{ rootMargin: '300px' },
)
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => observer?.disconnect())
watch(collectionId, () => load(true))
watch(loading, (isLoading) => {
if (!isLoading && sentinel.value) {
const rect = sentinel.value.getBoundingClientRect()
if (rect.top < window.innerHeight + 300) load()
}
})
</script>
<template>
<BookQuickView
:book-id="quickViewBookId"
:open="quickViewOpen"
@update:open="quickViewOpen = $event"
@action="quickViewBookId !== null && handleBookAction({ id: quickViewBookId } as BookCard, $event)"
/>
<SelectionActionBar
:visible="selectionMode"
:count="selectedCount"
:in-collection="true"
@add-to-collection="addToCollectionOpen = true"
@remove-from-collection="handleRemoveFromCollection"
@delete="() => {}"
@exit="exitSelectionMode"
/>
<AddToCollectionSheet
:open="addToCollectionOpen"
:book-ids="[...selectedIds]"
@update:open="addToCollectionOpen = $event"
@done="exitSelectionMode"
/>
<SidebarProvider>
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader />
<ViewHeader
:title="collection?.name ?? 'Collection'"
icon="FolderOpen"
:total="total"
:loaded="books.length"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
/>
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
<div v-if="!loading && books.length === 0" 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">
<FolderOpen :size="28" class="text-muted-foreground/50" />
</div>
<p class="text-sm font-medium text-foreground">No books in this collection</p>
<p class="text-xs text-muted-foreground">Select books from your library and add them here.</p>
</div>
<div
v-if="viewMode === 'grid' && books.length > 0"
class="grid"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
>
<BookCoverCard
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="toggleBook(book.id)"
/>
</div>
<div v-else-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="toggleBook(book.id)"
/>
</div>
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
<span v-if="loading" class="text-xs text-muted-foreground">Loading...</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">All {{ total.toLocaleString() }} books loaded</span>
</div>
</main>
</SidebarInset>
</SidebarProvider>
</template>
+73 -6
View File
@@ -9,9 +9,12 @@ import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import ViewHeader from '@/components/ViewHeader.vue' import ViewHeader from '@/components/ViewHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue' import AppSidebar from '@/components/AppSidebar.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar' import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { useBookQuery, type BookCard } from '@/features/book/composables/useBookQuery' import { useBookQuery, type BookCard } from '@/features/book/composables/useBookQuery'
import { useBookEvents } from '@/features/book/composables/useBookEvents' import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useDisplaySettings } from '@/composables/useDisplaySettings' import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries' import { useLibraries } from '@/features/library/composables/useLibraries'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress' import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
@@ -32,10 +35,14 @@ const title = computed(() => libraries.value.find((l) => l.id === libraryId.valu
const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId) const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
const FILTER_STORAGE_PREFIX = 'projectx:filter:library:' const FILTER_STORAGE_PREFIX = 'projectx:filter:library:'
function getFilterKey(id: number) { return `${FILTER_STORAGE_PREFIX}${id}` } function getFilterKey(id: number) {
return `${FILTER_STORAGE_PREFIX}${id}`
}
const SORT_STORAGE_PREFIX = 'projectx:sort:library:' const SORT_STORAGE_PREFIX = 'projectx:sort:library:'
function getSortKey(id: number) { return `${SORT_STORAGE_PREFIX}${id}` } function getSortKey(id: number) {
return `${SORT_STORAGE_PREFIX}${id}`
}
const savedFilter = ref<GroupRule | undefined>(undefined) const savedFilter = ref<GroupRule | undefined>(undefined)
const hasSavedFilter = computed(() => savedFilter.value !== undefined) const hasSavedFilter = computed(() => savedFilter.value !== undefined)
@@ -80,7 +87,13 @@ function forgetSavedFilter() {
} }
const { subscribeLibrary } = useScanProgress() const { subscribeLibrary } = useScanProgress()
watch(libraryId, (id) => { if (id !== null) subscribeLibrary(id) }, { immediate: true }) watch(
libraryId,
(id) => {
if (id !== null) subscribeLibrary(id)
},
{ immediate: true },
)
const { onBookMissing, onBookRestored, onBookMoved } = useBookEvents() const { onBookMissing, onBookRestored, onBookMoved } = useBookEvents()
onBookMissing((bookIds) => { onBookMissing((bookIds) => {
@@ -189,6 +202,15 @@ watch(loading, (isLoading) => {
if (!isLoading) loadIfSentinelVisible() if (!isLoading) loadIfSentinelVisible()
}) })
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, isSelected } = useBookSelection()
function toggleSelectionMode() {
if (selectionMode.value) exitSelectionMode()
else enterSelectionMode()
}
const addToCollectionOpen = ref(false)
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete' type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
const quickViewBookId = ref<number | null>(null) const quickViewBookId = ref<number | null>(null)
@@ -200,6 +222,14 @@ function handleBookAction(book: BookCard, action: BookActionType) {
quickViewOpen.value = true quickViewOpen.value = true
return return
} }
if (action === 'add-to-collection') {
if (!selectionMode.value) {
enterSelectionMode()
toggleBook(book.id)
}
addToCollectionOpen.value = true
return
}
// TODO: implement remaining actions // TODO: implement remaining actions
} }
</script> </script>
@@ -217,6 +247,8 @@ function handleBookAction(book: BookCard, action: BookActionType) {
v-model:coverSize="coverSize" v-model:coverSize="coverSize"
v-model:gridGap="gridGap" v-model:gridGap="gridGap"
v-model:viewMode="viewMode" v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
> >
<template #toolbar> <template #toolbar>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@@ -285,7 +317,11 @@ function handleBookAction(book: BookCard, action: BookActionType) {
v-if="activeFilterCount > 0" v-if="activeFilterCount > 0"
@click="saveFilter" @click="saveFilter"
class="flex items-center gap-1.5 h-7 px-3 rounded-md border text-xs font-medium transition-colors" class="flex items-center gap-1.5 h-7 px-3 rounded-md border text-xs font-medium transition-colors"
:class="isFilterSaved ? 'border-primary/40 text-primary bg-primary/8' : 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'" :class="
isFilterSaved
? 'border-primary/40 text-primary bg-primary/8'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:title="isFilterSaved ? 'Filter saved' : 'Save filter for this library'" :title="isFilterSaved ? 'Filter saved' : 'Save filter for this library'"
> >
<BookmarkCheck v-if="isFilterSaved" :size="13" /> <BookmarkCheck v-if="isFilterSaved" :size="13" />
@@ -311,12 +347,28 @@ function handleBookAction(book: BookCard, action: BookActionType) {
class="grid" class="grid"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
> >
<BookCoverCard v-for="book in books" :key="book.id" :book="book" @action="handleBookAction(book, $event)" /> <BookCoverCard
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="toggleBook(book.id)"
/>
</div> </div>
<!-- List view --> <!-- List view -->
<div v-else class="flex flex-col divide-y divide-border"> <div v-else class="flex flex-col divide-y divide-border">
<BookListRow v-for="book in books" :key="book.id" :book="book" @action="handleBookAction(book, $event)" /> <BookListRow
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="toggleBook(book.id)"
/>
</div> </div>
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center"> <div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
@@ -333,4 +385,19 @@ function handleBookAction(book: BookCard, action: BookActionType) {
@update:open="quickViewOpen = $event" @update:open="quickViewOpen = $event"
@action="quickViewBookId !== null && handleBookAction({ id: quickViewBookId } as BookCard, $event)" @action="quickViewBookId !== null && handleBookAction({ id: quickViewBookId } as BookCard, $event)"
/> />
<SelectionActionBar
:visible="selectionMode"
:count="selectedCount"
@add-to-collection="addToCollectionOpen = true"
@delete="() => {}"
@exit="exitSelectionMode"
/>
<AddToCollectionSheet
:open="addToCollectionOpen"
:book-ids="[...selectedIds]"
@update:open="addToCollectionOpen = $event"
@done="exitSelectionMode"
/>
</template> </template>
+44 -11
View File
@@ -2,16 +2,19 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Settings2, Trash2 } from 'lucide-vue-next' import { Settings2, Trash2 } from 'lucide-vue-next'
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue' import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import ViewHeader from '@/components/ViewHeader.vue' import ViewHeader from '@/components/ViewHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue' import AppSidebar from '@/components/AppSidebar.vue'
import LensEditorPanel from '@/features/lens/components/LensEditorPanel.vue' import LensEditorPanel from '@/features/lens/components/LensEditorPanel.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar' import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { useLens } from '@/features/lens/composables/useLens' import { useLens } from '@/features/lens/composables/useLens'
import { useLenses } from '@/features/lens/composables/useLenses' import { useLenses } from '@/features/lens/composables/useLenses'
import { useDisplaySettings } from '@/composables/useDisplaySettings' import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme' import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import { FIELD_LABELS, ruleToLabel } from '@/features/book/lib/filter-labels' import { FIELD_LABELS, ruleToLabel } from '@/features/book/lib/filter-labels'
import type { GroupRule } from '@projectx/types' import type { GroupRule } from '@projectx/types'
@@ -53,6 +56,15 @@ const sortChip = computed(() => {
const joinLabel = computed(() => lens.value?.filter?.join ?? 'AND') const joinLabel = computed(() => lens.value?.filter?.join ?? 'AND')
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, isSelected } = useBookSelection()
function toggleSelectionMode() {
if (selectionMode.value) exitSelectionMode()
else enterSelectionMode()
}
const addToCollectionOpen = ref(false)
const editorOpen = ref(false) const editorOpen = ref(false)
const confirmDelete = ref(false) const confirmDelete = ref(false)
const deleting = ref(false) const deleting = ref(false)
@@ -113,6 +125,21 @@ watch(loading, (isLoading) => {
<template> <template>
<LensEditorPanel :open="editorOpen" :lens="lens" @close="editorOpen = false" @saved="onSaved" /> <LensEditorPanel :open="editorOpen" :lens="lens" @close="editorOpen = false" @saved="onSaved" />
<SelectionActionBar
:visible="selectionMode"
:count="selectedCount"
@add-to-collection="addToCollectionOpen = true"
@delete="() => {}"
@exit="exitSelectionMode"
/>
<AddToCollectionSheet
:open="addToCollectionOpen"
:book-ids="[...selectedIds]"
@update:open="addToCollectionOpen = $event"
@added="exitSelectionMode"
/>
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
@@ -126,6 +153,8 @@ watch(loading, (isLoading) => {
v-model:coverSize="coverSize" v-model:coverSize="coverSize"
v-model:gridGap="gridGap" v-model:gridGap="gridGap"
v-model:viewMode="viewMode" v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
> >
<template #actions> <template #actions>
<button <button
@@ -204,22 +233,26 @@ watch(loading, (isLoading) => {
class="grid" class="grid"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
> >
<BookCoverCard v-for="book in books" :key="book.id" :book="book" /> <BookCoverCard
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@select="toggleBook(book.id)"
/>
</div> </div>
<!-- List view --> <!-- List view -->
<div v-else-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border"> <div v-else-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<div <BookListRow
v-for="book in books" v-for="book in books"
:key="book.id" :key="book.id"
class="flex items-center gap-3 py-2.5 px-1 hover:bg-muted/50 rounded-md transition-colors cursor-pointer" :book="book"
> :selection-mode="selectionMode"
<BookCoverImage :book-id="book.id" type="cover" class="h-12 w-9 object-cover rounded shrink-0 bg-muted" :alt="book.title ?? ''" /> :selected="isSelected(book.id)"
<div class="flex flex-col min-w-0"> @select="toggleBook(book.id)"
<span class="text-sm font-medium text-foreground truncate">{{ book.title ?? '-' }}</span> />
<span v-if="book.authors.length" class="text-xs text-muted-foreground truncate">{{ book.authors.join(', ') }}</span>
</div>
</div>
</div> </div>
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center"> <div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
+15
View File
@@ -0,0 +1,15 @@
export type Collection = {
id: number;
name: string;
description: string | null;
bookCount: number;
memberCount?: number;
createdAt: string;
updatedAt: string;
};
export type CollectionSummary = {
id: number;
name: string;
bookCount: number;
};
+1
View File
@@ -1,5 +1,6 @@
export * from "./auth"; export * from "./auth";
export * from "./book"; export * from "./book";
export * from "./collection";
export * from "./library"; export * from "./library";
export * from "./query"; export * from "./query";
export * from "./reader-settings"; export * from "./reader-settings";
+2
View File
@@ -11,6 +11,7 @@ import { PermissionGuard } from './common/guards/permission.guard';
import { DbModule } from './db/db.module'; import { DbModule } from './db/db.module';
import { AnnotationModule } from './modules/annotation/annotation.module'; import { AnnotationModule } from './modules/annotation/annotation.module';
import { AppSettingsModule } from './modules/app-settings/app-settings.module'; import { AppSettingsModule } from './modules/app-settings/app-settings.module';
import { CollectionModule } from './modules/collection/collection.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { CbzModule } from './modules/cbz/cbz.module'; import { CbzModule } from './modules/cbz/cbz.module';
import { BookmarkModule } from './modules/bookmark/bookmark.module'; import { BookmarkModule } from './modules/bookmark/bookmark.module';
@@ -43,6 +44,7 @@ import { UserModule } from './modules/user/user.module';
LibraryModule, LibraryModule,
PathModule, PathModule,
BookModule, BookModule,
CollectionModule,
LensModule, LensModule,
ScannerModule, ScannerModule,
MetadataModule, MetadataModule,
@@ -111,6 +111,22 @@ CREATE TABLE "books" (
"updated_at" timestamp DEFAULT now() NOT NULL "updated_at" timestamp DEFAULT now() NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "collection_books" (
"collection_id" integer NOT NULL,
"book_id" integer NOT NULL,
"added_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "collection_books_collection_id_book_id_pk" PRIMARY KEY("collection_id","book_id")
);
--> statement-breakpoint
CREATE TABLE "collections" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"description" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "libraries" ( CREATE TABLE "libraries" (
"id" serial PRIMARY KEY NOT NULL, "id" serial PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL, "name" varchar(255) NOT NULL,
@@ -285,6 +301,9 @@ ALTER TABLE "book_files" ADD CONSTRAINT "book_files_book_id_books_id_fk" FOREIGN
ALTER TABLE "book_files" ADD CONSTRAINT "book_files_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "book_files" ADD CONSTRAINT "book_files_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "books" ADD CONSTRAINT "books_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "books" ADD CONSTRAINT "books_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "books" ADD CONSTRAINT "books_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "books" ADD CONSTRAINT "books_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_books" ADD CONSTRAINT "collection_books_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_books" ADD CONSTRAINT "collection_books_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collections" ADD CONSTRAINT "collections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "library_folders" ADD CONSTRAINT "library_folders_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "library_folders" ADD CONSTRAINT "library_folders_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lenses" ADD CONSTRAINT "lenses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "lenses" ADD CONSTRAINT "lenses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -1,5 +1,5 @@
{ {
"id": "2581dc26-862c-4882-88e4-15e86f2dff12", "id": "603b7da1-d34a-4b50-bdc9-c3f70790befb",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -858,6 +858,138 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
}, },
"public.collection_books": {
"name": "collection_books",
"schema": "",
"columns": {
"collection_id": {
"name": "collection_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"book_id": {
"name": "book_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"added_at": {
"name": "added_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"collection_books_collection_id_collections_id_fk": {
"name": "collection_books_collection_id_collections_id_fk",
"tableFrom": "collection_books",
"tableTo": "collections",
"columnsFrom": [
"collection_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"collection_books_book_id_books_id_fk": {
"name": "collection_books_book_id_books_id_fk",
"tableFrom": "collection_books",
"tableTo": "books",
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"collection_books_collection_id_book_id_pk": {
"name": "collection_books_collection_id_book_id_pk",
"columns": [
"collection_id",
"book_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.collections": {
"name": "collections",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"collections_user_id_users_id_fk": {
"name": "collections_user_id_users_id_fk",
"tableFrom": "collections",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.libraries": { "public.libraries": {
"name": "libraries", "name": "libraries",
"schema": "", "schema": "",
+1 -1
View File
@@ -5,7 +5,7 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1770027078768, "when": 1770045564307,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
} }
+29
View File
@@ -0,0 +1,29 @@
import { integer, pgTable, primaryKey, serial, text, timestamp } from 'drizzle-orm/pg-core';
import { books } from './books';
import { users } from './auth';
export const collections = pgTable('collections', {
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const collectionBooks = pgTable(
'collection_books',
{
collectionId: integer('collection_id')
.notNull()
.references(() => collections.id, { onDelete: 'cascade' }),
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
addedAt: timestamp('added_at').defaultNow().notNull(),
},
(table) => [primaryKey({ columns: [table.collectionId, table.bookId] })],
);
+1
View File
@@ -1,6 +1,7 @@
export * from './auth'; export * from './auth';
export * from './libraries'; export * from './libraries';
export * from './books'; export * from './books';
export * from './collections';
export * from './lenses'; export * from './lenses';
export * from './metadata'; export * from './metadata';
export * from './scanner'; export * from './scanner';
@@ -0,0 +1,76 @@
import {
BadRequestException,
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { CollectionBooksDto } from './dto/collection-books.dto';
import { CreateCollectionDto } from './dto/create-collection.dto';
import { UpdateCollectionDto } from './dto/update-collection.dto';
import { CollectionService } from './collection.service';
@Controller('collections')
export class CollectionController {
constructor(private readonly collectionService: CollectionService) {}
@Get()
findAll(@CurrentUser() user: RequestUser, @Query('bookIds') bookIdsStr?: string) {
if (!bookIdsStr) return this.collectionService.findAll(user);
const bookIds = bookIdsStr.split(',').map(Number);
if (bookIds.some(isNaN)) throw new BadRequestException('bookIds must be a comma-separated list of integers');
return this.collectionService.findAll(user, bookIds);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.collectionService.findOne(id, user);
}
@Post()
create(@Body() dto: CreateCollectionDto, @CurrentUser() user: RequestUser) {
return this.collectionService.create(dto, user);
}
@Patch(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCollectionDto, @CurrentUser() user: RequestUser) {
return this.collectionService.update(id, dto, user);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.collectionService.remove(id, user);
}
@Post(':id/books')
addBooks(@Param('id', ParseIntPipe) id: number, @Body() dto: CollectionBooksDto, @CurrentUser() user: RequestUser) {
return this.collectionService.addBooks(id, dto, user);
}
@Delete(':id/books')
removeBooks(@Param('id', ParseIntPipe) id: number, @Body() dto: CollectionBooksDto, @CurrentUser() user: RequestUser) {
return this.collectionService.removeBooks(id, dto, user);
}
@Get(':id/books')
getBooks(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: RequestUser,
@Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(50), ParseIntPipe) size: number,
) {
return this.collectionService.getBooks(id, user, page, size);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { CollectionController } from './collection.controller';
import { CollectionRepository } from './collection.repository';
import { CollectionService } from './collection.service';
@Module({
imports: [BookModule],
controllers: [CollectionController],
providers: [CollectionService, CollectionRepository],
})
export class CollectionModule {}
@@ -0,0 +1,96 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, count, eq, inArray, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { collectionBooks, collections } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
const collectionFields = {
id: collections.id,
userId: collections.userId,
name: collections.name,
description: collections.description,
createdAt: collections.createdAt,
updatedAt: collections.updatedAt,
bookCount: count(collectionBooks.bookId),
};
@Injectable()
export class CollectionRepository {
constructor(@Inject(DB) private readonly db: Db) {}
findAllForUser(userId: number) {
return this.db
.select(collectionFields)
.from(collections)
.leftJoin(collectionBooks, eq(collections.id, collectionBooks.collectionId))
.where(eq(collections.userId, userId))
.groupBy(collections.id, collections.userId)
.orderBy(collections.name);
}
findAllForUserWithMembership(userId: number, bookIds: number[]) {
const bookIdList = sql.join(
bookIds.map((id) => sql`${id}`),
sql`, `,
);
return this.db
.select({
...collectionFields,
memberCount: sql<number>`count(case when ${collectionBooks.bookId} in (${bookIdList}) then 1 end)::int`,
})
.from(collections)
.leftJoin(collectionBooks, eq(collections.id, collectionBooks.collectionId))
.where(eq(collections.userId, userId))
.groupBy(collections.id, collections.userId)
.orderBy(collections.name);
}
findById(id: number) {
return this.db
.select(collectionFields)
.from(collections)
.leftJoin(collectionBooks, eq(collections.id, collectionBooks.collectionId))
.where(eq(collections.id, id))
.groupBy(collections.id, collections.userId)
.limit(1);
}
insert(values: typeof collections.$inferInsert) {
return this.db.insert(collections).values(values).returning();
}
update(id: number, userId: number, values: Partial<typeof collections.$inferInsert>) {
return this.db
.update(collections)
.set({ ...values, updatedAt: sql`now()` })
.where(and(eq(collections.id, id), eq(collections.userId, userId)))
.returning();
}
delete(id: number, userId: number) {
return this.db
.delete(collections)
.where(and(eq(collections.id, id), eq(collections.userId, userId)))
.returning();
}
addBooks(collectionId: number, bookIds: number[]) {
const values = bookIds.map((bookId) => ({ collectionId, bookId }));
return this.db.insert(collectionBooks).values(values).onConflictDoNothing().returning();
}
removeBooks(collectionId: number, bookIds: number[]) {
return this.db
.delete(collectionBooks)
.where(and(eq(collectionBooks.collectionId, collectionId), inArray(collectionBooks.bookId, bookIds)))
.returning();
}
findBookIds(collectionId: number) {
return this.db.select({ bookId: collectionBooks.bookId }).from(collectionBooks).where(eq(collectionBooks.collectionId, collectionId));
}
}
@@ -0,0 +1,143 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { basename } from 'path';
import { inArray } from 'drizzle-orm';
import type { BookCard, BooksPage } from '@projectx/types';
import type { RequestUser } from '../../common/types/request-user';
import { books } from '../../db/schema';
import { BookRepository } from '../book/book.repository';
import { CollectionBooksDto } from './dto/collection-books.dto';
import { CreateCollectionDto } from './dto/create-collection.dto';
import { UpdateCollectionDto } from './dto/update-collection.dto';
import { CollectionRepository } from './collection.repository';
@Injectable()
export class CollectionService {
constructor(
private readonly collectionRepo: CollectionRepository,
private readonly bookRepo: BookRepository,
) {}
private isSuperuser(user: RequestUser): boolean {
return user.roles.some((r) => r.isSuperuser);
}
private assertAccess(ownerId: number, user: RequestUser): void {
if (ownerId !== user.id && !this.isSuperuser(user)) {
throw new ForbiddenException('No access to this collection');
}
}
private assembleBookCards(
rows: { id: number; status: string; folderPath: string; title: string | null; seriesName: string | null; seriesIndex: number | null }[],
authorRows: { bookId: number; name: string }[],
fileRows: { bookId: number; id: number; format: string | null; role: string }[],
): BookCard[] {
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
list.push(row.name);
authorsByBook.set(row.bookId, list);
}
const filesByBook = new Map<number, { id: number; format: string | null; role: string }[]>();
for (const row of fileRows) {
const list = filesByBook.get(row.bookId) ?? [];
list.push({ id: row.id, format: row.format, role: row.role });
filesByBook.set(row.bookId, list);
}
return rows.map((row) => ({
id: row.id,
status: row.status,
title: row.title ?? basename(row.folderPath),
seriesName: row.seriesName ?? null,
seriesIndex: row.seriesIndex ?? null,
authors: authorsByBook.get(row.id) ?? [],
files: filesByBook.get(row.id) ?? [],
}));
}
findAll(user: RequestUser, bookIds?: number[]) {
if (bookIds && bookIds.length > 0) {
return this.collectionRepo.findAllForUserWithMembership(user.id, bookIds);
}
return this.collectionRepo.findAllForUser(user.id);
}
async findOne(id: number, user: RequestUser) {
const [collection] = await this.collectionRepo.findById(id);
if (!collection) throw new NotFoundException('Collection not found');
this.assertAccess(collection.userId, user);
return collection;
}
async create(dto: CreateCollectionDto, user: RequestUser) {
const [inserted] = await this.collectionRepo.insert({
userId: user.id,
name: dto.name,
description: dto.description ?? null,
});
const [collection] = await this.collectionRepo.findById(inserted.id);
return collection;
}
async update(id: number, dto: UpdateCollectionDto, user: RequestUser) {
const [existing] = await this.collectionRepo.findById(id);
if (!existing) throw new NotFoundException('Collection not found');
this.assertAccess(existing.userId, user);
const [updated] = await this.collectionRepo.update(id, existing.userId, {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
});
return updated;
}
async remove(id: number, user: RequestUser) {
const [existing] = await this.collectionRepo.findById(id);
if (!existing) throw new NotFoundException('Collection not found');
this.assertAccess(existing.userId, user);
await this.collectionRepo.delete(id, existing.userId);
}
async addBooks(id: number, dto: CollectionBooksDto, user: RequestUser) {
const [existing] = await this.collectionRepo.findById(id);
if (!existing) throw new NotFoundException('Collection not found');
this.assertAccess(existing.userId, user);
await this.collectionRepo.addBooks(id, dto.bookIds);
const [updated] = await this.collectionRepo.findById(id);
return updated;
}
async removeBooks(id: number, dto: CollectionBooksDto, user: RequestUser) {
const [existing] = await this.collectionRepo.findById(id);
if (!existing) throw new NotFoundException('Collection not found');
this.assertAccess(existing.userId, user);
await this.collectionRepo.removeBooks(id, dto.bookIds);
const [updated] = await this.collectionRepo.findById(id);
return updated;
}
async getBooks(id: number, user: RequestUser, page: number, size: number): Promise<BooksPage> {
const [existing] = await this.collectionRepo.findById(id);
if (!existing) throw new NotFoundException('Collection not found');
this.assertAccess(existing.userId, user);
const bookIdRows = await this.collectionRepo.findBookIds(id);
if (bookIdRows.length === 0) {
return { items: [], total: 0, page, size };
}
const bookIds = bookIdRows.map((r) => r.bookId);
const where = inArray(books.id, bookIds);
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards({
where,
orderBy: [],
limit: size,
offset: page * size,
});
return { items: this.assembleBookCards(rows, authorRows, fileRows), total, page, size };
}
}
@@ -0,0 +1,8 @@
import { ArrayMinSize, IsArray, IsInt } from 'class-validator';
export class CollectionBooksDto {
@IsArray()
@ArrayMinSize(1)
@IsInt({ each: true })
bookIds: number[];
}
@@ -0,0 +1,12 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateCollectionDto {
@IsString()
@MaxLength(255)
name: string;
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
}
@@ -0,0 +1,13 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateCollectionDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
}