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:
neon
2026-03-16 16:19:19 -06:00
parent 854ab783c8
commit 0a11d54fe0
24 changed files with 354 additions and 72 deletions
+13 -12
View File
@@ -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": {
+93 -18
View File
@@ -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 }
}