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 * @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 * type: integer
* items: * description: ID of the item being moved
* type: integer * 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 })
}) })

View File

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

View File

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

View File

@@ -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 &amp; 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;

View File

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

View File

@@ -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">
@@ -38,10 +40,10 @@
> >
<span class="drag-handle" title="Drag to reorder" aria-hidden="true"> <span class="drag-handle" title="Drag to reorder" aria-hidden="true">
<svg width="8" height="12" viewBox="0 0 8 12" fill="currentColor"> <svg width="8" height="12" viewBox="0 0 8 12" fill="currentColor">
<circle cx="2" cy="2" r="1.2" /> <circle cx="2" cy="2" r="1.2" />
<circle cx="6" cy="2" r="1.2" /> <circle cx="6" cy="2" r="1.2" />
<circle cx="2" cy="6" r="1.2" /> <circle cx="2" cy="6" r="1.2" />
<circle cx="6" cy="6" r="1.2" /> <circle cx="6" cy="6" r="1.2" />
<circle cx="2" cy="10" r="1.2" /> <circle cx="2" cy="10" r="1.2" />
<circle cx="6" cy="10" r="1.2" /> <circle cx="6" cy="10" r="1.2" />
</svg> </svg>
@@ -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 {

View File

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

View File

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