This commit is contained in:
Oscar
2026-06-03 21:00:28 +03:00
parent 61e3e9f916
commit dd86c564c4
8 changed files with 255 additions and 98 deletions

View File

@@ -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 })
})

View File

@@ -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)
}
}

View File

@@ -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<string>()
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)
}
}
}

View File

@@ -1,12 +1,22 @@
<template>
<div class="layout">
<header class="app-header">
<div class="header-ambient" aria-hidden="true" />
<div class="header-inner">
<div class="header-brand">
<span class="brand-mark" />
<h1 class="brand-title">TMC <em>Items Manager</em></h1>
<svg class="brand-icon" width="22" height="16" viewBox="0 0 22 16" fill="none" aria-hidden="true">
<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>
<span class="header-label">Manage &amp; organize</span>
</div>
</header>
<main class="panels">
@@ -35,7 +45,11 @@
</template>
<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 {
leftItems,
@@ -50,7 +64,7 @@ const {
fetchRight,
selectItem,
deselectItem,
reorderSelected,
reorderItem,
addItem,
} = useItems()
@@ -70,8 +84,8 @@ async function handleDeselect(id: number) {
await deselectItem(id)
}
async function handleReorder(ids: number[]) {
await reorderSelected(ids)
async function handleReorder(id: number, afterId: number | null): Promise<void> {
await reorderItem(id, afterId)
}
async function handleAdd(id: number) {
@@ -89,39 +103,63 @@ async function handleAdd(id: number) {
.app-header {
flex-shrink: 0;
position: relative;
background: var(--surface);
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 {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
height: 52px;
height: 64px;
}
.header-brand {
display: flex;
align-items: center;
gap: 10px;
gap: 11px;
}
.brand-mark {
width: 6px;
height: 6px;
background: var(--text-primary);
border-radius: 50%;
.brand-icon {
color: var(--text-primary);
flex-shrink: 0;
opacity: 0.8;
}
.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;
}
.brand-title {
margin: 0;
font-family: var(--font-serif);
font-size: 1.05rem;
font-size: 1.08rem;
font-weight: 400;
color: var(--text-primary);
letter-spacing: -0.02em;
letter-spacing: -0.025em;
line-height: 1;
em {
@@ -130,14 +168,33 @@ async function handleAdd(id: number) {
}
}
.header-label {
font-size: 0.72rem;
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.header-meta-label {
font-size: 0.7rem;
font-weight: 400;
letter-spacing: 0.05em;
text-transform: uppercase;
letter-spacing: 0.04em;
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 {
display: flex;
flex: 1;

View File

@@ -2,7 +2,9 @@
<div class="panel">
<div class="panel-header">
<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">
<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" />
@@ -59,7 +61,9 @@
<div v-if="showAddModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-card">
<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">
<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" />
@@ -74,8 +78,12 @@
@keydown.enter="submitAdd"
>
<div class="modal-footer">
<button class="btn-ghost" @click="closeModal">Cancel</button>
<button class="btn-primary" @click="submitAdd">Add item</button>
<button class="btn-ghost" @click="closeModal">
Cancel
</button>
<button class="btn-primary" @click="submitAdd">
Add item
</button>
</div>
</div>
</div>
@@ -161,6 +169,7 @@ function submitAdd() {
/* ── Header ── */
.panel-header {
height: 108px;
padding: 18px 20px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface);
@@ -209,7 +218,9 @@ function submitAdd() {
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);
@@ -228,7 +239,7 @@ function submitAdd() {
gap: 6px;
padding: 6px 12px;
background: var(--text-primary);
color: #FFFFFF;
color: #ffffff;
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-sans);
@@ -237,7 +248,9 @@ function submitAdd() {
cursor: pointer;
letter-spacing: 0.01em;
white-space: nowrap;
transition: background 150ms ease, transform 100ms ease;
transition:
background 150ms ease,
transform 100ms ease;
&:hover {
background: #333333;
@@ -257,7 +270,10 @@ function submitAdd() {
font-size: 0.825rem;
color: var(--text-secondary);
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 {
color: var(--text-primary);
@@ -271,9 +287,16 @@ function submitAdd() {
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 {
@@ -283,8 +306,14 @@ function submitAdd() {
}
@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 {
@@ -300,7 +329,9 @@ function submitAdd() {
&:hover {
background: var(--surface-subtle);
.action-btn { opacity: 1; }
.action-btn {
opacity: 1;
}
}
}
@@ -328,7 +359,10 @@ function submitAdd() {
color: var(--text-secondary);
cursor: pointer;
opacity: 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);
@@ -355,14 +389,24 @@ function submitAdd() {
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 {
@@ -429,7 +473,9 @@ function submitAdd() {
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: color 120ms ease, background 120ms ease;
transition:
color 120ms ease,
background 120ms ease;
&:hover {
color: var(--text-primary);
@@ -447,7 +493,9 @@ function submitAdd() {
font-size: 0.875rem;
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);
@@ -477,7 +525,9 @@ function submitAdd() {
transition: opacity 180ms ease;
.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;
.modal-card {
transition: transform 150ms ease, opacity 150ms ease;
transition:
transform 150ms ease,
opacity 150ms ease;
}
}

View File

@@ -2,7 +2,9 @@
<div class="panel">
<div class="panel-header">
<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>
</div>
<div class="search-wrap">
@@ -38,10 +40,10 @@
>
<span class="drag-handle" title="Drag to reorder" aria-hidden="true">
<svg width="8" height="12" viewBox="0 0 8 12" fill="currentColor">
<circle cx="2" cy="2" r="1.2" />
<circle cx="6" cy="2" r="1.2" />
<circle cx="2" cy="6" r="1.2" />
<circle cx="6" cy="6" r="1.2" />
<circle cx="2" cy="2" r="1.2" />
<circle cx="6" cy="2" r="1.2" />
<circle cx="2" cy="6" r="1.2" />
<circle cx="6" cy="6" r="1.2" />
<circle cx="2" cy="10" r="1.2" />
<circle cx="6" cy="10" r="1.2" />
</svg>
@@ -85,7 +87,7 @@ const emit = defineEmits<{
(e: 'update:search', val: string): void
(e: 'loadMore'): 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)
@@ -127,9 +129,10 @@ watch(() => props.items, (val) => {
localItems.value = [...val]
}, { deep: true })
function onDragEnd(): void {
const ids = localItems.value.map(i => i.id!)
emit('reorder', ids)
function onDragEnd(event: { oldIndex: number, newIndex: number }): void {
const moved = localItems.value[event.newIndex]
const after = event.newIndex > 0 ? localItems.value[event.newIndex - 1] : null
emit('reorder', moved.id!, after?.id ?? null)
}
</script>
@@ -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 {

View File

@@ -75,8 +75,8 @@ export function useItems() {
await Promise.all([fetchLeft(true), fetchRight(true)])
}
async function reorderSelected(ids: number[]): Promise<void> {
await client.api.itemsReorderUpdate({ ids })
async function reorderItem(id: number, afterId: number | null): Promise<void> {
await client.api.itemsReorderUpdate({ id, afterId })
}
async function addItem(id: number): Promise<void> {
@@ -97,7 +97,7 @@ export function useItems() {
fetchRight,
selectItem,
deselectItem,
reorderSelected,
reorderItem,
addItem,
}
}

View File

@@ -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 = {},
) =>