mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add drag-and-drop reorder for libraries, lenses, and collections
- add displayOrder column to lenses and collections schema
- add POST /api/v1/{lenses,collections}/reorder endpoints (user-scoped)
- move Lens type to @projectx/types; add displayOrder to Collection type
- add useDraggableOrder composable with optimistic updates, debounce, rollback, and unmount cleanup
- wire VueDraggable into AppSidebar with per-section edit-mode toggle
- replace section header add/reorder buttons with a single kebab dropdown
This commit is contained in:
+13
-12
@@ -32,11 +32,12 @@
|
||||
"lucide-vue-next": "^0.577.0",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"pinia": "^3.0.4",
|
||||
"reka-ui": "^2.9.0",
|
||||
"reka-ui": "^2.9.2",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.29",
|
||||
"vue": "^3.5.30",
|
||||
"vue-draggable-plus": "^0.6.1",
|
||||
"vue-router": "^5.0.3",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"vue-virtual-scroller": "^1.1.2"
|
||||
@@ -44,27 +45,27 @@
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/jsdom": "^28.0.0",
|
||||
"@types/node": "^25.3.5",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/eslint-plugin": "^1.6.9",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"@vitest/eslint-plugin": "^1.6.12",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.51.0",
|
||||
"eslint-plugin-oxlint": "~1.56.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^28.1.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.51.0",
|
||||
"oxlint": "~1.56.0",
|
||||
"prettier": "3.8.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-vue-devtools": "^8.0.7",
|
||||
"vitest": "^4.0.18",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-vue-devtools": "^8.1.0",
|
||||
"vitest": "^4.1.0",
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import * as Icons from 'lucide-vue-next'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -22,6 +23,7 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
|
||||
import { useStagingSummary } from '@/features/staging/composables/useStagingSummary'
|
||||
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
|
||||
import { useDraggableOrder } from '@/composables/useDraggableOrder'
|
||||
import type { Library } from '@projectx/types'
|
||||
import CreateLensDialog from '@/features/lens/components/CreateLensDialog.vue'
|
||||
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
|
||||
@@ -43,9 +45,9 @@ function useSidebarSection(key: string) {
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { libraries, fetchLibraries, refreshLibraries } = useLibraries()
|
||||
const { lenses, fetchLenses } = useLenses()
|
||||
const { collections, fetchCollections } = useCollections()
|
||||
const { libraries, fetchLibraries, refreshLibraries, reorderLibraries } = useLibraries()
|
||||
const { lenses, fetchLenses, reorderLenses } = useLenses()
|
||||
const { collections, fetchCollections, reorderCollections } = useCollections()
|
||||
const { hasPermission } = usePermissions()
|
||||
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
|
||||
const { summary: stagingSummary, fetchSummary: fetchStagingSummary, subscribe: subscribeStagingSummary } = useStagingSummary()
|
||||
@@ -60,6 +62,28 @@ const { isOpen: librariesOpen, toggle: toggleLibraries } = useSidebarSection('li
|
||||
const { isOpen: lensesOpen, toggle: toggleLenses } = useSidebarSection('lenses')
|
||||
const { isOpen: collectionsOpen, toggle: toggleCollections } = useSidebarSection('collections')
|
||||
|
||||
const isReorderingLibraries = ref(false)
|
||||
const isReorderingLenses = ref(false)
|
||||
const isReorderingCollections = ref(false)
|
||||
|
||||
const {
|
||||
localItems: localLibraries,
|
||||
onDragStart: onLibraryDragStart,
|
||||
onDragEnd: onLibraryDragEnd,
|
||||
} = useDraggableOrder({ source: libraries, persist: reorderLibraries })
|
||||
|
||||
const {
|
||||
localItems: localLenses,
|
||||
onDragStart: onLensDragStart,
|
||||
onDragEnd: onLensDragEnd,
|
||||
} = useDraggableOrder({ source: lenses, persist: reorderLenses })
|
||||
|
||||
const {
|
||||
localItems: localCollections,
|
||||
onDragStart: onCollectionDragStart,
|
||||
onDragEnd: onCollectionDragEnd,
|
||||
} = useDraggableOrder({ source: collections, persist: reorderCollections })
|
||||
|
||||
const isDashboardActive = computed(() => route.name === 'dashboard')
|
||||
const isStagingActive = computed(() => route.name === 'staging')
|
||||
const isAuthorsActive = computed(() => route.name === 'authors' || route.name === 'author-detail')
|
||||
@@ -212,14 +236,26 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
:collapsed-count="libraries.length"
|
||||
:can-add="hasPermission('manage_libraries')"
|
||||
add-title="New Library"
|
||||
:can-reorder="hasPermission('manage_libraries')"
|
||||
:is-reordering="isReorderingLibraries"
|
||||
@toggle="toggleLibraries"
|
||||
@add="createLibraryOpen = true"
|
||||
@toggle-reorder="isReorderingLibraries = !isReorderingLibraries"
|
||||
/>
|
||||
<div v-show="librariesOpen">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<VueDraggable
|
||||
v-model="localLibraries"
|
||||
tag="ul"
|
||||
:animation="150"
|
||||
handle=".drag-handle"
|
||||
:disabled="!isReorderingLibraries"
|
||||
class="contents"
|
||||
@start="onLibraryDragStart"
|
||||
@end="onLibraryDragEnd"
|
||||
>
|
||||
<SidebarNavItem
|
||||
v-for="lib in libraries"
|
||||
v-for="lib in localLibraries"
|
||||
:key="lib.id"
|
||||
:is-active="activeLibraryId === lib.id"
|
||||
:tooltip="getProgress(lib.id)?.status === 'running' ? `${lib.name} - Scanning ${scanPct(lib.id)}%` : lib.name"
|
||||
@@ -241,6 +277,11 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
>
|
||||
{{ lib.bookCount.toLocaleString() }}
|
||||
</span>
|
||||
<Icons.GripVertical
|
||||
v-if="isReorderingLibraries"
|
||||
class="drag-handle ml-1 h-3.5 w-3.5 shrink-0 cursor-grab text-primary/60 group-data-[collapsible=icon]:hidden"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<template #extra>
|
||||
<Transition name="scan-progress">
|
||||
@@ -261,7 +302,7 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
</Transition>
|
||||
</template>
|
||||
</SidebarNavItem>
|
||||
</SidebarMenu>
|
||||
</VueDraggable>
|
||||
</SidebarGroupContent>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
@@ -276,14 +317,26 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
:collapsed-count="lenses.length"
|
||||
:can-add="true"
|
||||
add-title="New Lens"
|
||||
:can-reorder="lenses.length > 1"
|
||||
:is-reordering="isReorderingLenses"
|
||||
@toggle="toggleLenses"
|
||||
@add="createLensOpen = true"
|
||||
@toggle-reorder="isReorderingLenses = !isReorderingLenses"
|
||||
/>
|
||||
<div v-show="lensesOpen">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<VueDraggable
|
||||
v-model="localLenses"
|
||||
tag="ul"
|
||||
:animation="150"
|
||||
handle=".drag-handle"
|
||||
:disabled="!isReorderingLenses"
|
||||
class="contents"
|
||||
@start="onLensDragStart"
|
||||
@end="onLensDragEnd"
|
||||
>
|
||||
<SidebarNavItem
|
||||
v-for="lens in lenses"
|
||||
v-for="lens in localLenses"
|
||||
:key="lens.id"
|
||||
:is-active="activeLensId === lens.id"
|
||||
:tooltip="lens.name"
|
||||
@@ -298,12 +351,17 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
>
|
||||
{{ lens.bookCount.toLocaleString() }}
|
||||
</span>
|
||||
<Icons.GripVertical
|
||||
v-if="isReorderingLenses"
|
||||
class="drag-handle ml-1 h-3.5 w-3.5 shrink-0 cursor-grab text-primary/60 group-data-[collapsible=icon]:hidden"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
</SidebarNavItem>
|
||||
<SidebarMenuItem v-if="lenses.length === 0">
|
||||
<span class="px-2 py-1 text-[11px] text-sidebar-foreground/35 group-data-[collapsible=icon]:hidden">No lenses yet</span>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</VueDraggable>
|
||||
<SidebarMenuItem v-if="localLenses.length === 0">
|
||||
<span class="px-2 py-1 text-[11px] text-sidebar-foreground/35 group-data-[collapsible=icon]:hidden">No lenses yet</span>
|
||||
</SidebarMenuItem>
|
||||
</SidebarGroupContent>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
@@ -318,14 +376,26 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
:collapsed-count="collections.length"
|
||||
:can-add="true"
|
||||
add-title="New Collection"
|
||||
:can-reorder="collections.length > 1"
|
||||
:is-reordering="isReorderingCollections"
|
||||
@toggle="toggleCollections"
|
||||
@add="createCollectionOpen = true"
|
||||
@toggle-reorder="isReorderingCollections = !isReorderingCollections"
|
||||
/>
|
||||
<div v-show="collectionsOpen">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<VueDraggable
|
||||
v-model="localCollections"
|
||||
tag="ul"
|
||||
:animation="150"
|
||||
handle=".drag-handle"
|
||||
:disabled="!isReorderingCollections"
|
||||
class="contents"
|
||||
@start="onCollectionDragStart"
|
||||
@end="onCollectionDragEnd"
|
||||
>
|
||||
<SidebarNavItem
|
||||
v-for="collection in collections"
|
||||
v-for="collection in localCollections"
|
||||
:key="collection.id"
|
||||
:is-active="activeCollectionId === collection.id"
|
||||
:tooltip="collection.name"
|
||||
@@ -340,12 +410,17 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
>
|
||||
{{ collection.bookCount.toLocaleString() }}
|
||||
</span>
|
||||
<Icons.GripVertical
|
||||
v-if="isReorderingCollections"
|
||||
class="drag-handle ml-1 h-3.5 w-3.5 shrink-0 cursor-grab text-primary/60 group-data-[collapsible=icon]:hidden"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
</SidebarNavItem>
|
||||
<SidebarMenuItem v-if="collections.length === 0">
|
||||
<span class="px-2 py-1 text-[11px] text-sidebar-foreground/35 group-data-[collapsible=icon]:hidden">No collections yet</span>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</VueDraggable>
|
||||
<SidebarMenuItem v-if="localCollections.length === 0">
|
||||
<span class="px-2 py-1 text-[11px] text-sidebar-foreground/35 group-data-[collapsible=icon]:hidden">No collections yet</span>
|
||||
</SidebarMenuItem>
|
||||
</SidebarGroupContent>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, Plus } from 'lucide-vue-next'
|
||||
import { ChevronDown, Plus, GripVertical, Check, MoreVertical } from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
isOpen: boolean
|
||||
collapsedCount?: number
|
||||
canAdd?: boolean
|
||||
addTitle?: string
|
||||
canReorder?: boolean
|
||||
isReordering?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ toggle: []; add: [] }>()
|
||||
const emit = defineEmits<{ toggle: []; add: []; toggleReorder: [] }>()
|
||||
|
||||
const hasMenu = () => props.canAdd || props.canReorder
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -33,13 +38,26 @@ const emit = defineEmits<{ toggle: []; add: [] }>()
|
||||
:class="isOpen ? 'rotate-0' : '-rotate-90'"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="canAdd"
|
||||
class="ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/45 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/90"
|
||||
:title="addTitle"
|
||||
@click="emit('add')"
|
||||
>
|
||||
<Plus :size="15" :stroke-width="2.2" />
|
||||
</button>
|
||||
<DropdownMenu v-if="hasMenu()">
|
||||
<DropdownMenuTrigger
|
||||
class="ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-sidebar-accent"
|
||||
:class="isReordering ? 'text-primary' : 'text-sidebar-foreground/45 hover:text-sidebar-foreground/90'"
|
||||
@click.stop
|
||||
>
|
||||
<MoreVertical :size="15" :stroke-width="2.2" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" :side-offset="4">
|
||||
<DropdownMenuItem v-if="canAdd" @click="emit('add')">
|
||||
<Plus class="size-4" />
|
||||
{{ addTitle ?? 'Add' }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator v-if="canAdd && canReorder" />
|
||||
<DropdownMenuItem v-if="canReorder" @click="emit('toggleReorder')">
|
||||
<Check v-if="isReordering" class="size-4 text-primary" />
|
||||
<GripVertical v-else class="size-4" />
|
||||
{{ isReordering ? 'Done reordering' : 'Reorder' }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { onUnmounted, ref, watch, type Ref } from 'vue'
|
||||
|
||||
interface OrderableItem {
|
||||
id: number
|
||||
displayOrder: number
|
||||
}
|
||||
|
||||
interface UseDraggableOrderOptions<T extends OrderableItem> {
|
||||
source: Ref<T[]>
|
||||
persist: (order: { id: number; displayOrder: number }[]) => Promise<void>
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function useDraggableOrder<T extends OrderableItem>({ source, persist, debounceMs = 600 }: UseDraggableOrderOptions<T>) {
|
||||
const localItems = ref<T[]>([]) as Ref<T[]>
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let snapshot: T[] = []
|
||||
let isDragging = false
|
||||
|
||||
watch(
|
||||
source,
|
||||
(items) => {
|
||||
if (isDragging) return
|
||||
localItems.value = [...items]
|
||||
snapshot = [...items]
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onDragStart() {
|
||||
isDragging = true
|
||||
snapshot = [...localItems.value]
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging = false
|
||||
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const order = localItems.value.map((item, index) => ({ id: item.id, displayOrder: index }))
|
||||
try {
|
||||
await persist(order)
|
||||
order.forEach(({ id, displayOrder }) => {
|
||||
const item = source.value.find((i) => i.id === id)
|
||||
if (item) item.displayOrder = displayOrder
|
||||
})
|
||||
} catch {
|
||||
localItems.value = [...snapshot]
|
||||
}
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
return { localItems, onDragStart, onDragEnd }
|
||||
}
|
||||
@@ -76,6 +76,15 @@ export function useCollections() {
|
||||
return updated
|
||||
}
|
||||
|
||||
async function reorderCollections(order: { id: number; displayOrder: number }[]): Promise<void> {
|
||||
const res = await api('/api/v1/collections/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to reorder collections')
|
||||
}
|
||||
|
||||
return {
|
||||
collections,
|
||||
fetchCollections,
|
||||
@@ -84,5 +93,6 @@ export function useCollections() {
|
||||
updateCollection,
|
||||
addBooksToCollection,
|
||||
removeBooksFromCollection,
|
||||
reorderCollections,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { X, Zap } from 'lucide-vue-next'
|
||||
import { api } from '@/lib/api'
|
||||
import type { GroupRule, Rule, SortSpec } from '@projectx/types'
|
||||
import type { Lens } from '@/features/lens/composables/useLenses'
|
||||
import type { GroupRule, Lens, Rule, SortSpec } from '@projectx/types'
|
||||
import { useLenses } from '@/features/lens/composables/useLenses'
|
||||
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
|
||||
import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { GroupRule, SortSpec } from '@projectx/types'
|
||||
|
||||
export interface Lens {
|
||||
id: number
|
||||
userId: number
|
||||
name: string
|
||||
icon: string | null
|
||||
filter: GroupRule | null
|
||||
defaultSort: SortSpec[]
|
||||
isPublic: boolean
|
||||
bookCount?: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateLensPayload {
|
||||
name: string
|
||||
icon?: string
|
||||
filter?: GroupRule
|
||||
defaultSort: SortSpec[]
|
||||
isPublic?: boolean
|
||||
}
|
||||
import type { CreateLensPayload, Lens } from '@projectx/types'
|
||||
|
||||
const lenses = ref<Lens[]>([])
|
||||
const loaded = ref(false)
|
||||
@@ -73,5 +52,14 @@ export function useLenses() {
|
||||
lenses.value = lenses.value.filter((l) => l.id !== id)
|
||||
}
|
||||
|
||||
return { lenses, fetchLenses, refreshLenses, createLens, updateLens, deleteLens }
|
||||
async function reorderLenses(order: { id: number; displayOrder: number }[]): Promise<void> {
|
||||
const res = await api('/api/v1/lenses/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
return { lenses, fetchLenses, refreshLenses, createLens, updateLens, deleteLens, reorderLenses }
|
||||
}
|
||||
|
||||
@@ -26,5 +26,14 @@ export function useLibraries() {
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
return { libraries, fetchLibraries, refreshLibraries }
|
||||
async function reorderLibraries(order: { id: number; displayOrder: number }[]): Promise<void> {
|
||||
const res = await api('/api/v1/libraries/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to reorder libraries')
|
||||
}
|
||||
|
||||
return { libraries, fetchLibraries, refreshLibraries, reorderLibraries }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user