mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(collections): add full CRUD with multi-collection assignment
This commit is contained in:
@@ -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>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user