diff --git a/backend/src/routes/items.ts b/backend/src/routes/items.ts index d7261d2..6445bd0 100644 --- a/backend/src/routes/items.ts +++ b/backend/src/routes/items.ts @@ -223,7 +223,7 @@ itemsRouter.post('/deselect', (req, res) => { * @openapi * /api/items/reorder: * put: - * summary: Queue a reorder of selected items + * summary: Move a selected item after another item (works with filtered lists) * tags: [Items] * requestBody: * required: true @@ -231,12 +231,15 @@ itemsRouter.post('/deselect', (req, res) => { * application/json: * schema: * type: object - * required: [ids] + * required: [id] * properties: - * ids: - * type: array - * items: - * type: integer + * id: + * type: integer + * description: ID of the item being moved + * afterId: + * type: integer + * nullable: true + * description: ID of the item to place the moved item after. Null moves to the beginning. * responses: * 200: * description: Reorder queued @@ -247,13 +250,19 @@ itemsRouter.post('/deselect', (req, res) => { * properties: * queued: * type: boolean + * 400: + * description: Invalid parameters */ itemsRouter.put('/reorder', (req, res) => { - const ids = req.body?.ids - if (!Array.isArray(ids)) { - res.status(400).json({ error: 'ids must be an array' }) + const { id, afterId } = req.body ?? {} + if (typeof id !== 'number' || !Number.isInteger(id)) { + res.status(400).json({ error: 'id must be an integer' }) return } - queue.enqueue({ type: 'reorder', ids }) + if (afterId !== null && afterId !== undefined && (typeof afterId !== 'number' || !Number.isInteger(afterId))) { + res.status(400).json({ error: 'afterId must be an integer or null' }) + return + } + queue.enqueue({ type: 'reorder', id, afterId: afterId ?? null }) res.json({ queued: true }) }) diff --git a/backend/src/services/itemsStore.ts b/backend/src/services/itemsStore.ts index c849689..32ec704 100644 --- a/backend/src/services/itemsStore.ts +++ b/backend/src/services/itemsStore.ts @@ -71,9 +71,15 @@ export function deselectItem(id: number): boolean { return true } -export function reorderSelected(ids: number[]): void { - orderedSelected.length = 0 - for (const id of ids) { - if (selectedIds.has(id)) orderedSelected.push(id) +export function reorderItem(id: number, afterId: number | null): void { + const idx = orderedSelected.indexOf(id) + if (idx === -1) return + orderedSelected.splice(idx, 1) + if (afterId === null) { + orderedSelected.unshift(id) + } + else { + const afterIdx = orderedSelected.indexOf(afterId) + orderedSelected.splice(afterIdx === -1 ? orderedSelected.length : afterIdx + 1, 0, id) } } diff --git a/backend/src/services/queue.ts b/backend/src/services/queue.ts index ac3934b..bedec0e 100644 --- a/backend/src/services/queue.ts +++ b/backend/src/services/queue.ts @@ -3,26 +3,23 @@ import * as store from './itemsStore.js' interface AddTask { type: 'add', id: number } interface SelectTask { type: 'select', id: number } interface DeselectTask { type: 'deselect', id: number } -interface ReorderTask { type: 'reorder', ids: number[] } +interface ReorderTask { type: 'reorder', id: number, afterId: number | null } type Task = AddTask | SelectTask | DeselectTask | ReorderTask class RequestQueue { private addQueue: AddTask[] = [] - private actionQueue: ReorderTask[] = [] + private reorderQueue: ReorderTask[] = [] private pendingKeys = new Set() - private pendingReorder: ReorderTask | null = null constructor() { setInterval(() => this.flushAdd(), 10_000) - setInterval(() => this.flushActions(), 1_000) + setInterval(() => this.flushReorder(), 1_000) } enqueue(task: Task): boolean { if (task.type === 'reorder') { - this.pendingReorder = task - this.actionQueue = this.actionQueue.filter(t => t.type !== 'reorder') - this.actionQueue.push(task) + this.reorderQueue.push(task) return true } @@ -51,13 +48,10 @@ class RequestQueue { } } - private flushActions(): void { - const batch = this.actionQueue.splice(0) - this.pendingReorder = null + private flushReorder(): void { + const batch = this.reorderQueue.splice(0) for (const task of batch) { - if (task.type === 'reorder') { - store.reorderSelected(task.ids) - } + store.reorderItem(task.id, task.afterId) } } } diff --git a/frontend/app/app.vue b/frontend/app/app.vue index a3a689c..fa86faa 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -1,12 +1,22 @@ @@ -143,6 +146,7 @@ function onDragEnd(): void { /* ── Header ── */ .panel-header { + height: 108px; padding: 18px 20px 16px; border-bottom: 1px solid var(--border); background: var(--surface); @@ -206,7 +210,9 @@ function onDragEnd(): void { font-size: 0.825rem; color: var(--text-primary); outline: none; - transition: border-color 150ms ease, background 150ms ease; + transition: + border-color 150ms ease, + background 150ms ease; &::placeholder { color: var(--text-muted); @@ -223,9 +229,16 @@ function onDragEnd(): void { flex: 1; overflow-y: auto; - &::-webkit-scrollbar { width: 3px; } - &::-webkit-scrollbar-track { background: transparent; } - &::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + &::-webkit-scrollbar { + width: 3px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; + } } .item-list { @@ -235,8 +248,14 @@ function onDragEnd(): void { } @keyframes item-in { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } } .item-row { @@ -252,8 +271,12 @@ function onDragEnd(): void { &:hover { background: var(--surface-subtle); - .action-btn { opacity: 1; } - .drag-handle { color: var(--text-secondary); } + .action-btn { + opacity: 1; + } + .drag-handle { + color: var(--text-secondary); + } } } @@ -267,7 +290,9 @@ function onDragEnd(): void { transition: color 120ms ease; user-select: none; - &:active { cursor: grabbing; } + &:active { + cursor: grabbing; + } } .item-order { @@ -305,7 +330,10 @@ function onDragEnd(): void { cursor: pointer; opacity: 0; flex-shrink: 0; - transition: color 120ms ease, background 120ms ease, opacity 120ms ease; + transition: + color 120ms ease, + background 120ms ease, + opacity 120ms ease; &:hover { color: var(--text-primary); @@ -332,14 +360,24 @@ function onDragEnd(): void { background: var(--text-muted); animation: dot-blink 1.2s ease-in-out infinite; - &:nth-child(2) { animation-delay: 0.2s; } - &:nth-child(3) { animation-delay: 0.4s; } + &:nth-child(2) { + animation-delay: 0.2s; + } + &:nth-child(3) { + animation-delay: 0.4s; + } } } @keyframes dot-blink { - 0%, 80%, 100% { opacity: 0.25; } - 40% { opacity: 1; } + 0%, + 80%, + 100% { + opacity: 0.25; + } + 40% { + opacity: 1; + } } .empty-state { diff --git a/frontend/app/composables/useItems.ts b/frontend/app/composables/useItems.ts index bd48bbf..e5ab47a 100644 --- a/frontend/app/composables/useItems.ts +++ b/frontend/app/composables/useItems.ts @@ -75,8 +75,8 @@ export function useItems() { await Promise.all([fetchLeft(true), fetchRight(true)]) } - async function reorderSelected(ids: number[]): Promise { - await client.api.itemsReorderUpdate({ ids }) + async function reorderItem(id: number, afterId: number | null): Promise { + await client.api.itemsReorderUpdate({ id, afterId }) } async function addItem(id: number): Promise { @@ -97,7 +97,7 @@ export function useItems() { fetchRight, selectItem, deselectItem, - reorderSelected, + reorderItem, addItem, } } diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index 0a169c1..f168c84 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -357,12 +357,13 @@ export class Api< * * @tags Items * @name ItemsReorderUpdate - * @summary Queue a reorder of selected items + * @summary Move a selected item after another item (works with filtered lists) * @request PUT:/api/items/reorder */ itemsReorderUpdate: ( data: { - ids: number[]; + id: number; + afterId: number | null; }, params: RequestParams = {}, ) =>