upd
This commit is contained in:
@@ -223,7 +223,7 @@ itemsRouter.post('/deselect', (req, res) => {
|
|||||||
* @openapi
|
* @openapi
|
||||||
* /api/items/reorder:
|
* /api/items/reorder:
|
||||||
* put:
|
* put:
|
||||||
* summary: Queue a reorder of selected items
|
* summary: Move a selected item after another item (works with filtered lists)
|
||||||
* tags: [Items]
|
* tags: [Items]
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
@@ -231,12 +231,15 @@ itemsRouter.post('/deselect', (req, res) => {
|
|||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* type: object
|
* type: object
|
||||||
* required: [ids]
|
* required: [id]
|
||||||
* properties:
|
* properties:
|
||||||
* ids:
|
* id:
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: integer
|
* 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:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Reorder queued
|
* description: Reorder queued
|
||||||
@@ -247,13 +250,19 @@ itemsRouter.post('/deselect', (req, res) => {
|
|||||||
* properties:
|
* properties:
|
||||||
* queued:
|
* queued:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
|
* 400:
|
||||||
|
* description: Invalid parameters
|
||||||
*/
|
*/
|
||||||
itemsRouter.put('/reorder', (req, res) => {
|
itemsRouter.put('/reorder', (req, res) => {
|
||||||
const ids = req.body?.ids
|
const { id, afterId } = req.body ?? {}
|
||||||
if (!Array.isArray(ids)) {
|
if (typeof id !== 'number' || !Number.isInteger(id)) {
|
||||||
res.status(400).json({ error: 'ids must be an array' })
|
res.status(400).json({ error: 'id must be an integer' })
|
||||||
return
|
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 })
|
res.json({ queued: true })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,9 +71,15 @@ export function deselectItem(id: number): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reorderSelected(ids: number[]): void {
|
export function reorderItem(id: number, afterId: number | null): void {
|
||||||
orderedSelected.length = 0
|
const idx = orderedSelected.indexOf(id)
|
||||||
for (const id of ids) {
|
if (idx === -1) return
|
||||||
if (selectedIds.has(id)) orderedSelected.push(id)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,26 +3,23 @@ import * as store from './itemsStore.js'
|
|||||||
interface AddTask { type: 'add', id: number }
|
interface AddTask { type: 'add', id: number }
|
||||||
interface SelectTask { type: 'select', id: number }
|
interface SelectTask { type: 'select', id: number }
|
||||||
interface DeselectTask { type: 'deselect', 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
|
type Task = AddTask | SelectTask | DeselectTask | ReorderTask
|
||||||
|
|
||||||
class RequestQueue {
|
class RequestQueue {
|
||||||
private addQueue: AddTask[] = []
|
private addQueue: AddTask[] = []
|
||||||
private actionQueue: ReorderTask[] = []
|
private reorderQueue: ReorderTask[] = []
|
||||||
private pendingKeys = new Set<string>()
|
private pendingKeys = new Set<string>()
|
||||||
private pendingReorder: ReorderTask | null = null
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
setInterval(() => this.flushAdd(), 10_000)
|
setInterval(() => this.flushAdd(), 10_000)
|
||||||
setInterval(() => this.flushActions(), 1_000)
|
setInterval(() => this.flushReorder(), 1_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueue(task: Task): boolean {
|
enqueue(task: Task): boolean {
|
||||||
if (task.type === 'reorder') {
|
if (task.type === 'reorder') {
|
||||||
this.pendingReorder = task
|
this.reorderQueue.push(task)
|
||||||
this.actionQueue = this.actionQueue.filter(t => t.type !== 'reorder')
|
|
||||||
this.actionQueue.push(task)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,13 +48,10 @@ class RequestQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private flushActions(): void {
|
private flushReorder(): void {
|
||||||
const batch = this.actionQueue.splice(0)
|
const batch = this.reorderQueue.splice(0)
|
||||||
this.pendingReorder = null
|
|
||||||
for (const task of batch) {
|
for (const task of batch) {
|
||||||
if (task.type === 'reorder') {
|
store.reorderItem(task.id, task.afterId)
|
||||||
store.reorderSelected(task.ids)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
|
<div class="header-ambient" aria-hidden="true" />
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<div class="header-brand">
|
<div class="header-brand">
|
||||||
<span class="brand-mark" />
|
<svg class="brand-icon" width="22" height="16" viewBox="0 0 22 16" fill="none" aria-hidden="true">
|
||||||
<h1 class="brand-title">TMC <em>Items Manager</em></h1>
|
<rect x="0.75" y="0.75" width="20.5" height="14.5" rx="2" stroke="currentColor" stroke-width="1.4" />
|
||||||
|
<line x1="11" y1="0.75" x2="11" y2="15.25" stroke="currentColor" stroke-width="1.4" />
|
||||||
|
</svg>
|
||||||
|
<span class="brand-tag">TMC</span>
|
||||||
|
<span class="brand-sep" aria-hidden="true" />
|
||||||
|
<h1 class="brand-title">Items <em>Manager</em></h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="header-meta-label">Item collection</span>
|
||||||
|
<span class="header-vsep" aria-hidden="true" />
|
||||||
|
<time class="header-date">{{ formattedDate }}</time>
|
||||||
</div>
|
</div>
|
||||||
<span class="header-label">Manage & organize</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="panels">
|
<main class="panels">
|
||||||
@@ -35,7 +45,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, watch } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
|
|
||||||
|
const formattedDate = computed(() =>
|
||||||
|
new Date().toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
leftItems,
|
leftItems,
|
||||||
@@ -50,7 +64,7 @@ const {
|
|||||||
fetchRight,
|
fetchRight,
|
||||||
selectItem,
|
selectItem,
|
||||||
deselectItem,
|
deselectItem,
|
||||||
reorderSelected,
|
reorderItem,
|
||||||
addItem,
|
addItem,
|
||||||
} = useItems()
|
} = useItems()
|
||||||
|
|
||||||
@@ -70,8 +84,8 @@ async function handleDeselect(id: number) {
|
|||||||
await deselectItem(id)
|
await deselectItem(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReorder(ids: number[]) {
|
async function handleReorder(id: number, afterId: number | null): Promise<void> {
|
||||||
await reorderSelected(ids)
|
await reorderItem(id, afterId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdd(id: number) {
|
async function handleAdd(id: number) {
|
||||||
@@ -89,39 +103,63 @@ async function handleAdd(id: number) {
|
|||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-ambient {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(ellipse 55% 180% at 0% 50%, rgba(252, 246, 228, 0.26) 0%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner {
|
.header-inner {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 28px;
|
padding: 0 28px;
|
||||||
height: 52px;
|
height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-brand {
|
.header-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-icon {
|
||||||
width: 6px;
|
color: var(--text-primary);
|
||||||
height: 6px;
|
flex-shrink: 0;
|
||||||
background: var(--text-primary);
|
opacity: 0.8;
|
||||||
border-radius: 50%;
|
}
|
||||||
|
|
||||||
|
.brand-tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 15px;
|
||||||
|
background: var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-title {
|
.brand-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 1.05rem;
|
font-size: 1.08rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.025em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
em {
|
em {
|
||||||
@@ -130,14 +168,33 @@ async function handleAdd(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-label {
|
.header-right {
|
||||||
font-size: 0.72rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-vsep {
|
||||||
|
width: 1px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-date {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
.panels {
|
.panels {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div class="panel-title-row">
|
<div class="panel-title-row">
|
||||||
<h2 class="panel-title">All items</h2>
|
<h2 class="panel-title">
|
||||||
|
All items
|
||||||
|
</h2>
|
||||||
<button class="btn-primary" @click="showAddModal = true">
|
<button class="btn-primary" @click="showAddModal = true">
|
||||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" aria-hidden="true">
|
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" aria-hidden="true">
|
||||||
<path d="M5.5 1V10M1 5.5H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
<path d="M5.5 1V10M1 5.5H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
@@ -59,7 +61,9 @@
|
|||||||
<div v-if="showAddModal" class="modal-overlay" @click.self="closeModal">
|
<div v-if="showAddModal" class="modal-overlay" @click.self="closeModal">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<p class="modal-title">Add new item</p>
|
<p class="modal-title">
|
||||||
|
Add new item
|
||||||
|
</p>
|
||||||
<button class="modal-close" aria-label="Close" @click="closeModal">
|
<button class="modal-close" aria-label="Close" @click="closeModal">
|
||||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
||||||
<path d="M1.5 1.5L11.5 11.5M11.5 1.5L1.5 11.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
<path d="M1.5 1.5L11.5 11.5M11.5 1.5L1.5 11.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
@@ -74,8 +78,12 @@
|
|||||||
@keydown.enter="submitAdd"
|
@keydown.enter="submitAdd"
|
||||||
>
|
>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn-ghost" @click="closeModal">Cancel</button>
|
<button class="btn-ghost" @click="closeModal">
|
||||||
<button class="btn-primary" @click="submitAdd">Add item</button>
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" @click="submitAdd">
|
||||||
|
Add item
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,6 +169,7 @@ function submitAdd() {
|
|||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
.panel-header {
|
.panel-header {
|
||||||
|
height: 108px;
|
||||||
padding: 18px 20px 16px;
|
padding: 18px 20px 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -209,7 +218,9 @@ function submitAdd() {
|
|||||||
font-size: 0.825rem;
|
font-size: 0.825rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 150ms ease, background 150ms ease;
|
transition:
|
||||||
|
border-color 150ms ease,
|
||||||
|
background 150ms ease;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -228,7 +239,7 @@ function submitAdd() {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: var(--text-primary);
|
background: var(--text-primary);
|
||||||
color: #FFFFFF;
|
color: #ffffff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
@@ -237,7 +248,9 @@ function submitAdd() {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background 150ms ease, transform 100ms ease;
|
transition:
|
||||||
|
background 150ms ease,
|
||||||
|
transform 100ms ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #333333;
|
background: #333333;
|
||||||
@@ -257,7 +270,10 @@ function submitAdd() {
|
|||||||
font-size: 0.825rem;
|
font-size: 0.825rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 150ms ease, border-color 150ms ease, background 150ms ease;
|
transition:
|
||||||
|
color 150ms ease,
|
||||||
|
border-color 150ms ease,
|
||||||
|
background 150ms ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -271,9 +287,16 @@ function submitAdd() {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
&::-webkit-scrollbar { width: 3px; }
|
&::-webkit-scrollbar {
|
||||||
&::-webkit-scrollbar-track { background: transparent; }
|
width: 3px;
|
||||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-list {
|
.item-list {
|
||||||
@@ -283,8 +306,14 @@ function submitAdd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes item-in {
|
@keyframes item-in {
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-row {
|
.item-row {
|
||||||
@@ -300,7 +329,9 @@ function submitAdd() {
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
|
|
||||||
.action-btn { opacity: 1; }
|
.action-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +359,10 @@ function submitAdd() {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: color 120ms ease, background 120ms ease, opacity 120ms ease;
|
transition:
|
||||||
|
color 120ms ease,
|
||||||
|
background 120ms ease,
|
||||||
|
opacity 120ms ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -355,14 +389,24 @@ function submitAdd() {
|
|||||||
background: var(--text-muted);
|
background: var(--text-muted);
|
||||||
animation: dot-blink 1.2s ease-in-out infinite;
|
animation: dot-blink 1.2s ease-in-out infinite;
|
||||||
|
|
||||||
&:nth-child(2) { animation-delay: 0.2s; }
|
&:nth-child(2) {
|
||||||
&:nth-child(3) { animation-delay: 0.4s; }
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dot-blink {
|
@keyframes dot-blink {
|
||||||
0%, 80%, 100% { opacity: 0.25; }
|
0%,
|
||||||
40% { opacity: 1; }
|
80%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -429,7 +473,9 @@ function submitAdd() {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 120ms ease, background 120ms ease;
|
transition:
|
||||||
|
color 120ms ease,
|
||||||
|
background 120ms ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -447,7 +493,9 @@ function submitAdd() {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 150ms ease, background 150ms ease;
|
transition:
|
||||||
|
border-color 150ms ease,
|
||||||
|
background 150ms ease;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -477,7 +525,9 @@ function submitAdd() {
|
|||||||
transition: opacity 180ms ease;
|
transition: opacity 180ms ease;
|
||||||
|
|
||||||
.modal-card {
|
.modal-card {
|
||||||
transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1), opacity 180ms ease;
|
transition:
|
||||||
|
transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
|
opacity 180ms ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,7 +535,9 @@ function submitAdd() {
|
|||||||
transition: opacity 150ms ease;
|
transition: opacity 150ms ease;
|
||||||
|
|
||||||
.modal-card {
|
.modal-card {
|
||||||
transition: transform 150ms ease, opacity 150ms ease;
|
transition:
|
||||||
|
transform 150ms ease,
|
||||||
|
opacity 150ms ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div class="panel-title-row">
|
<div class="panel-title-row">
|
||||||
<h2 class="panel-title">Selected items</h2>
|
<h2 class="panel-title">
|
||||||
|
Selected items
|
||||||
|
</h2>
|
||||||
<span v-if="items.length" class="item-count">{{ items.length }}</span>
|
<span v-if="items.length" class="item-count">{{ items.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
@@ -85,7 +87,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'update:search', val: string): void
|
(e: 'update:search', val: string): void
|
||||||
(e: 'loadMore'): void
|
(e: 'loadMore'): void
|
||||||
(e: 'deselect', id: number): void
|
(e: 'deselect', id: number): void
|
||||||
(e: 'reorder', ids: number[]): void
|
(e: 'reorder', id: number, afterId: number | null): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const sentinel = ref<HTMLElement | null>(null)
|
const sentinel = ref<HTMLElement | null>(null)
|
||||||
@@ -127,9 +129,10 @@ watch(() => props.items, (val) => {
|
|||||||
localItems.value = [...val]
|
localItems.value = [...val]
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
function onDragEnd(): void {
|
function onDragEnd(event: { oldIndex: number, newIndex: number }): void {
|
||||||
const ids = localItems.value.map(i => i.id!)
|
const moved = localItems.value[event.newIndex]
|
||||||
emit('reorder', ids)
|
const after = event.newIndex > 0 ? localItems.value[event.newIndex - 1] : null
|
||||||
|
emit('reorder', moved.id!, after?.id ?? null)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -143,6 +146,7 @@ function onDragEnd(): void {
|
|||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
.panel-header {
|
.panel-header {
|
||||||
|
height: 108px;
|
||||||
padding: 18px 20px 16px;
|
padding: 18px 20px 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -206,7 +210,9 @@ function onDragEnd(): void {
|
|||||||
font-size: 0.825rem;
|
font-size: 0.825rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 150ms ease, background 150ms ease;
|
transition:
|
||||||
|
border-color 150ms ease,
|
||||||
|
background 150ms ease;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -223,9 +229,16 @@ function onDragEnd(): void {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
&::-webkit-scrollbar { width: 3px; }
|
&::-webkit-scrollbar {
|
||||||
&::-webkit-scrollbar-track { background: transparent; }
|
width: 3px;
|
||||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-list {
|
.item-list {
|
||||||
@@ -235,8 +248,14 @@ function onDragEnd(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes item-in {
|
@keyframes item-in {
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-row {
|
.item-row {
|
||||||
@@ -252,8 +271,12 @@ function onDragEnd(): void {
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
|
|
||||||
.action-btn { opacity: 1; }
|
.action-btn {
|
||||||
.drag-handle { color: var(--text-secondary); }
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.drag-handle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +290,9 @@ function onDragEnd(): void {
|
|||||||
transition: color 120ms ease;
|
transition: color 120ms ease;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
&:active { cursor: grabbing; }
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-order {
|
.item-order {
|
||||||
@@ -305,7 +330,10 @@ function onDragEnd(): void {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
flex-shrink: 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 {
|
&:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -332,14 +360,24 @@ function onDragEnd(): void {
|
|||||||
background: var(--text-muted);
|
background: var(--text-muted);
|
||||||
animation: dot-blink 1.2s ease-in-out infinite;
|
animation: dot-blink 1.2s ease-in-out infinite;
|
||||||
|
|
||||||
&:nth-child(2) { animation-delay: 0.2s; }
|
&:nth-child(2) {
|
||||||
&:nth-child(3) { animation-delay: 0.4s; }
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dot-blink {
|
@keyframes dot-blink {
|
||||||
0%, 80%, 100% { opacity: 0.25; }
|
0%,
|
||||||
40% { opacity: 1; }
|
80%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ export function useItems() {
|
|||||||
await Promise.all([fetchLeft(true), fetchRight(true)])
|
await Promise.all([fetchLeft(true), fetchRight(true)])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reorderSelected(ids: number[]): Promise<void> {
|
async function reorderItem(id: number, afterId: number | null): Promise<void> {
|
||||||
await client.api.itemsReorderUpdate({ ids })
|
await client.api.itemsReorderUpdate({ id, afterId })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addItem(id: number): Promise<void> {
|
async function addItem(id: number): Promise<void> {
|
||||||
@@ -97,7 +97,7 @@ export function useItems() {
|
|||||||
fetchRight,
|
fetchRight,
|
||||||
selectItem,
|
selectItem,
|
||||||
deselectItem,
|
deselectItem,
|
||||||
reorderSelected,
|
reorderItem,
|
||||||
addItem,
|
addItem,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,12 +357,13 @@ export class Api<
|
|||||||
*
|
*
|
||||||
* @tags Items
|
* @tags Items
|
||||||
* @name ItemsReorderUpdate
|
* @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
|
* @request PUT:/api/items/reorder
|
||||||
*/
|
*/
|
||||||
itemsReorderUpdate: (
|
itemsReorderUpdate: (
|
||||||
data: {
|
data: {
|
||||||
ids: number[];
|
id: number;
|
||||||
|
afterId: number | null;
|
||||||
},
|
},
|
||||||
params: RequestParams = {},
|
params: RequestParams = {},
|
||||||
) =>
|
) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user