This commit is contained in:
Oscar
2026-06-03 20:21:43 +03:00
commit a38e905b29
25 changed files with 19011 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Dependencies
node_modules/
# Build output
dist/
build/
.output/
.nuxt/
# Environment
.env
.env.*
!.env.example
# Logs
logs/
*.log
npm-debug.log*
pnpm-debug.log*
# TypeScript
*.tsbuildinfo
# OS
.DS_Store
Thumbs.db
# Editors
.idea/
.vscode/
*.swp
*.swo
# pnpm
.pnpm-store/
# Nuxt
.nuxt/
.output/

395
PROMPT.md Normal file
View File

@@ -0,0 +1,395 @@
# TMC Test Task — Claude Code Prompt
> Запусти этот промт из корня `A:\my-apps\tmc-test-task`
---
## Контекст и цель
Ты создаёшь fullstack-приложение для тестового задания. В текущей директории создай две папки: `backend` и `frontend`. Начни с бэкенда, затем фронтенд. Работай итеративно — сначала scaffold, потом реализация по фичам.
---
## Структура репозитория
```
tmc-test-task/
├── backend/
│ ├── src/
│ │ ├── index.ts
│ │ ├── routes/
│ │ │ └── items.ts
│ │ ├── services/
│ │ │ ├── itemsStore.ts ← in-memory хранилище
│ │ │ └── queue.ts ← очередь с дедупликацией
│ │ ├── middleware/
│ │ │ └── batcher.ts ← батчинг запросов
│ │ └── swagger.ts
│ ├── eslint.config.js
│ ├── tsconfig.json
│ └── package.json
└── frontend/
├── app/
│ ├── components/
│ │ ├── LeftPanel.vue
│ │ └── RightPanel.vue
│ ├── composables/
│ │ └── useItems.ts
│ ├── services/ ← сюда генерится axios-клиент
│ └── app.vue
├── eslint.config.js
├── nuxt.config.ts
└── package.json
```
---
## BACKEND
### Технологии
- **Runtime**: Node.js + TypeScript
- **Framework**: Express.js
- **Package manager**: pnpm
- **Dev runner**: `tsx watch`
- **Build**: `tsc && tsc-alias`
- **Lint**: `@antfu/eslint-config`
- **Docs**: `swagger-jsdoc` + `swagger-ui-express`
### package.json (backend)
```json
{
"name": "tmc-backend",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && tsc-alias",
"start": "node dist/index.js",
"lint": "eslint ."
}
}
```
Зависимости: `express`, `swagger-jsdoc`, `swagger-ui-express`, `cors`
Dev: `tsx`, `tsc-alias`, `typescript`, `@types/node`, `@types/express`, `@types/swagger-jsdoc`, `@types/swagger-ui-express`, `@types/cors`, `eslint`, `@antfu/eslint-config`
### tsconfig.json (backend)
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
```
### eslint.config.js (backend)
```js
import antfu from '@antfu/eslint-config'
export default antfu({
typescript: true,
node: true,
formatters: true,
rules: {
'no-console': 'warn',
'node/prefer-global/process': 'off',
},
})
```
### Бизнес-логика
#### In-memory хранилище (`itemsStore.ts`)
- При старте приложения инициализировать массив `allItems` с ID от 1 до 1 000 000
- `selectedIds: Set<number>` — множество выбранных ID
- `orderedSelected: number[]` — массив для сохранения порядка drag&drop
- Методы:
- `getItems(page, limit, search)` — возвращает НЕвыбранные элементы с пагинацией и фильтрацией по ID (строковый поиск по ID)
- `getSelectedItems(page, limit, search)` — возвращает выбранные в порядке `orderedSelected`
- `addItem(id)` — добавить новый элемент (id может быть любым числом)
- `selectItem(id)` — переместить в правое окно
- `deselectItem(id)` — вернуть в левое окно
- `reorderSelected(ids: number[])` — сохранить новый порядок
#### Очередь с дедупликацией (`queue.ts`)
Реализовать `RequestQueue` класс:
- Очередь принимает задачи типа: `'add' | 'select' | 'deselect' | 'reorder'`
- **Дедупликация**: для `add`, `select`, `deselect` — ключ дедупликации это `${type}:${id}`. Если такая задача уже в очереди (не выполнена), повторно не добавлять
- **Гарантия**: одно значение не будет добавлено повторно (проверять и в store)
- Батчинг:
- Задачи `add` — флашатся раз в **10 секунд** (или при накоплении 100 задач)
- Задачи `select`, `deselect`, `reorder` — флашатся раз в **1 секунду**
- При флаше — выполнять все задачи из батча атомарно
#### Swagger (`swagger.ts`)
Настроить `swagger-jsdoc` + `swagger-ui-express`:
- Документация доступна по `GET /docs`
- JSON-схема по `GET /docs/json` (нужен для генерации клиента во фронте)
- Описать все модели и ответы
### API Routes
Все роуты с JSDoc-комментариями для swagger. Базовый путь: `/api`
#### `GET /api/items`
Возвращает НЕвыбранные элементы (левое окно).
Query params:
- `page` (number, default: 1)
- `limit` (number, default: 20, max: 20)
- `search` (string, optional) — фильтр по ID (поиск подстроки в строке ID)
Response:
```json
{
"data": [{ "id": 1 }, { "id": 2 }],
"total": 999980,
"page": 1,
"limit": 20,
"hasMore": true
}
```
#### `GET /api/items/selected`
Возвращает выбранные элементы (правое окно) в сохранённом порядке.
Query params:
- `page` (number, default: 1)
- `limit` (number, default: 20, max: 20)
- `search` (string, optional)
Response: аналогично выше.
#### `POST /api/items/add`
Добавить элемент в очередь на добавление.
Body:
```json
{ "id": 1000001 }
```
Response:
```json
{ "queued": true, "id": 1000001 }
```
Ошибки: 400 если `id` не передан или не число.
#### `POST /api/items/select`
Добавить элемент в очередь на выбор (переместить вправо).
Body:
```json
{ "id": 42 }
```
Response:
```json
{ "queued": true }
```
#### `POST /api/items/deselect`
Вернуть элемент в левое окно.
Body:
```json
{ "id": 42 }
```
#### `PUT /api/items/reorder`
Сохранить новый порядок выбранных элементов после drag&drop.
Body:
```json
{ "ids": [5, 3, 1, 42] }
```
Response:
```json
{ "queued": true }
```
### CORS и порт
- Порт: `1337`
- CORS: разрешить `http://localhost:3000` (фронт на nuxt)
- Swagger UI: `GET /docs`, JSON: `GET /docs/json`
---
## FRONTEND
### Технологии
- **Framework**: Nuxt 3
- **UI**: `@nuxt/ui` (последняя версия)
- **Styling**: Tailwind CSS (через `@nuxt/ui`), SASS
- **Package manager**: pnpm
- **Lint**: `@antfu/eslint-config` + `vue-tsc`
- **API клиент**: axios + автогенерация через `swagger-typescript-api`
### package.json (frontend) — scripts
```json
{
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"lint": "eslint .",
"typecheck": "vue-tsc --noEmit",
"gen:api": "npx swagger-typescript-api generate --path http://localhost:1337/docs/json --output ./app/services --name api.ts --axios --unwrap-response-data"
}
}
```
Зависимости: `@nuxt/ui`, `axios`, `vue-draggable-next` (drag&drop)
Dev: `eslint`, `@antfu/eslint-config`, `vue-tsc`, `typescript`, `sass`, `swagger-typescript-api`
### nuxt.config.ts
```ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
css: ['~/assets/styles/main.scss'],
runtimeConfig: {
public: {
apiBase: process.env.API_BASE ?? 'http://localhost:1337/api',
},
},
})
```
### eslint.config.js (frontend)
```js
import antfu from '@antfu/eslint-config'
export default antfu({
typescript: true,
vue: true,
formatters: true,
rules: {
'no-console': 'warn',
},
})
```
### UI и функционал
#### Главная страница (`app/app.vue` или `app/pages/index.vue`)
Разметка: два равных контейнера в ряд (50/50 или flex/grid), высота — весь экран минус header.
#### Левое окно (компонент `LeftPanel.vue`)
- Заголовок: "All items"
- Инпут поиска: фильтрация по ID (debounce 300ms)
- Кнопка "Add item": открывает modal/popover с инпутом для ввода ID → POST /api/items/add
- Список элементов:
- Каждый элемент: `#ID` + кнопка "→" для выбора (POST /api/items/select)
- Инфинити-скролл: IntersectionObserver на sentinel-элементе в конце списка, подгружать по 20 элементов
- При поиске — сбрасывать страницу, начинать с 1
- `hasMore` из ответа API определяет, показывать ли sentinel
#### Правое окно (компонент `RightPanel.vue`)
- Заголовок: "Selected items"
- Инпут поиска: фильтрация по ID (debounce 300ms)
- Список с drag&drop (библиотека `vue-draggable-next`):
- Drag&drop работает для видимого (в том числе отфильтрованного) списка
- После drop: PUT /api/items/reorder с полным новым порядком
- Каждый элемент: drag-handle иконка + `#ID` + кнопка "←" для возврата (POST /api/items/deselect)
- Инфинити-скролл аналогично левому окну
#### Composable (`composables/useItems.ts`)
Создать composable `useItems()` с методами:
- `fetchLeft(reset?)` — загрузить/дозагрузить левую панель
- `fetchRight(reset?)` — загрузить/дозагрузить правую панель
- `selectItem(id)` — выбрать элемент
- `deselectItem(id)` — вернуть
- `reorderSelected(ids)` — сохранить порядок
- `addItem(id)` — добавить новый элемент
- Реактивные состояния: `leftItems`, `rightItems`, `leftSearch`, `rightSearch`, `leftLoading`, `rightLoading`
Использовать axios для запросов. После каждой мутации (select/deselect/add) — перезагрузить обе панели (reset + fetch).
#### Сохранение состояния
**На сервере** хранится выбор и порядок (in-memory). Фронт при монтировании делает `fetchLeft()` и `fetchRight()` — данные восстанавливаются из сервера. Поиск не сохраняется (сбрасывается при перезагрузке страницы — это правильное поведение).
---
## Порядок выполнения
1. Создать директории `backend/` и `frontend/`
2. **Backend**:
1. Инициализировать `pnpm init` + установить зависимости
2. Создать `tsconfig.json`, `eslint.config.js`
3. Реализовать `itemsStore.ts`
4. Реализовать `queue.ts`
5. Реализовать роуты в `routes/items.ts`
6. Настроить swagger в `swagger.ts`
7. Собрать всё в `index.ts`
8. Проверить `pnpm dev` — должен стартовать на порту 1337
3. **Frontend**:
1. `pnpm dlx nuxi@latest init frontend` (или вручную scaffold)
2. Установить зависимости
3. Создать `nuxt.config.ts`, `eslint.config.js`
4. Создать структуру папок
5. Реализовать composable `useItems.ts`
6. Реализовать `LeftPanel.vue`
7. Реализовать `RightPanel.vue`
8. Собрать главную страницу
9. Добавить скрипт `gen:api` и сгенерировать клиент (бэк должен быть запущен)
---
## Важные детали реализации
- Инфинити-скролл: не забудь `disconnect()` observer при unmount
- Дедупликация в очереди: если `POST /api/items/select` вызывается дважды для одного ID — второй запрос игнорируется до выполнения первого
- При инициализации 1М элементов — не держать в памяти объекты, только числа (массив `number[]` весит ~8MB, приемлемо)
- Типы из swagger: после `gen:api` в `app/services/api.ts` будет класс `Api` с axios. Использовать его в composable через `const api = new Api({ baseURL: config.public.apiBase })`
- CORS: обязательно, иначе фронт не достучится до бэка
- Батчинг 10 сек для `add` — при разработке можно уменьшить до 3 сек для теста, но в финальной версии должно быть 10 сек
- `reorder` через очередь: drag&drop может вызываться часто, дедуплицировать по типу (последний вызов побеждает, предыдущие отменяются — реализовать как "debounced queue" для `reorder`)
---
## Что должно работать в итоге
- `http://localhost:1337/docs` — Swagger UI с описанием всех эндпоинтов
- `http://localhost:1337/docs/json` — OpenAPI JSON (для генерации клиента)
- `http://localhost:3000` — фронт с двумя панелями
- Левая панель: поиск + инфинити-скролл + добавление + перемещение вправо
- Правая панель: поиск + инфинити-скролл + drag&drop + перемещение влево
- Состояние сохраняется при перезагрузке страницы (данные на сервере)
- Все запросы через очередь с дедупликацией и батчингом

11
backend/eslint.config.js Normal file
View File

@@ -0,0 +1,11 @@
import antfu from '@antfu/eslint-config'
export default antfu({
typescript: true,
node: true,
formatters: true,
rules: {
'no-console': 'warn',
'node/prefer-global/process': 'off',
},
})

29
backend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "tmc-backend",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && tsc-alias",
"start": "node dist/index.js",
"lint": "eslint ."
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@antfu/eslint-config": "^3.9.2",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.6",
"eslint": "^9.0.0",
"tsc-alias": "^1.8.10",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
}
}

4817
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

20
backend/src/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import cors from 'cors'
import express from 'express'
import { itemsRouter } from './routes/items.js'
import { setupSwagger } from './swagger.js'
const app = express()
app.use(cors({ origin: 'http://localhost:3000' }))
app.use(express.json())
setupSwagger(app)
app.use('/api/items', itemsRouter)
const PORT = 1337
app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`Server running on http://localhost:${PORT}`)
// eslint-disable-next-line no-console
console.log(`Swagger UI: http://localhost:${PORT}/docs`)
})

View File

@@ -0,0 +1,5 @@
import type { NextFunction, Request, Response } from 'express'
export function batcherMiddleware(_req: Request, _res: Response, next: NextFunction): void {
next()
}

259
backend/src/routes/items.ts Normal file
View File

@@ -0,0 +1,259 @@
import { Router } from 'express'
import * as store from '../services/itemsStore.js'
import { queue } from '../services/queue.js'
export const itemsRouter = Router()
/**
* @openapi
* components:
* schemas:
* Item:
* type: object
* properties:
* id:
* type: integer
* PaginatedItems:
* type: object
* properties:
* data:
* type: array
* items:
* $ref: '#/components/schemas/Item'
* total:
* type: integer
* page:
* type: integer
* limit:
* type: integer
* hasMore:
* type: boolean
*/
/**
* @openapi
* /api/items:
* get:
* summary: Get unselected items
* tags: [Items]
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* default: 1
* - in: query
* name: limit
* schema:
* type: integer
* default: 20
* maximum: 20
* - in: query
* name: search
* schema:
* type: string
* responses:
* 200:
* description: Paginated list of unselected items
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/PaginatedItems'
*/
itemsRouter.get('/', (req, res) => {
const page = Math.max(1, Number(req.query.page) || 1)
const limit = Math.min(20, Math.max(1, Number(req.query.limit) || 20))
const search = req.query.search ? String(req.query.search) : undefined
res.json(store.getItems(page, limit, search))
})
/**
* @openapi
* /api/items/selected:
* get:
* summary: Get selected items in order
* tags: [Items]
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* default: 1
* - in: query
* name: limit
* schema:
* type: integer
* default: 20
* maximum: 20
* - in: query
* name: search
* schema:
* type: string
* responses:
* 200:
* description: Paginated list of selected items
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/PaginatedItems'
*/
itemsRouter.get('/selected', (req, res) => {
const page = Math.max(1, Number(req.query.page) || 1)
const limit = Math.min(20, Math.max(1, Number(req.query.limit) || 20))
const search = req.query.search ? String(req.query.search) : undefined
res.json(store.getSelectedItems(page, limit, search))
})
/**
* @openapi
* /api/items/add:
* post:
* summary: Queue an item for addition
* tags: [Items]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [id]
* properties:
* id:
* type: integer
* responses:
* 200:
* description: Item queued
* content:
* application/json:
* schema:
* type: object
* properties:
* queued:
* type: boolean
* id:
* type: integer
* 400:
* description: Invalid id
*/
itemsRouter.post('/add', (req, res) => {
const id = req.body?.id
if (id === undefined || id === null || typeof id !== 'number' || !Number.isInteger(id)) {
res.status(400).json({ error: 'id must be an integer' })
return
}
queue.enqueue({ type: 'add', id })
res.json({ queued: true, id })
})
/**
* @openapi
* /api/items/select:
* post:
* summary: Queue an item for selection (move to right panel)
* tags: [Items]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [id]
* properties:
* id:
* type: integer
* responses:
* 200:
* description: Item queued
* content:
* application/json:
* schema:
* type: object
* properties:
* queued:
* type: boolean
*/
itemsRouter.post('/select', (req, res) => {
const id = req.body?.id
if (id === undefined || id === null || typeof id !== 'number' || !Number.isInteger(id)) {
res.status(400).json({ error: 'id must be an integer' })
return
}
queue.enqueue({ type: 'select', id })
res.json({ queued: true })
})
/**
* @openapi
* /api/items/deselect:
* post:
* summary: Queue an item for deselection (move back to left panel)
* tags: [Items]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [id]
* properties:
* id:
* type: integer
* responses:
* 200:
* description: Item queued
* content:
* application/json:
* schema:
* type: object
* properties:
* queued:
* type: boolean
*/
itemsRouter.post('/deselect', (req, res) => {
const id = req.body?.id
if (id === undefined || id === null || typeof id !== 'number' || !Number.isInteger(id)) {
res.status(400).json({ error: 'id must be an integer' })
return
}
queue.enqueue({ type: 'deselect', id })
res.json({ queued: true })
})
/**
* @openapi
* /api/items/reorder:
* put:
* summary: Queue a reorder of selected items
* tags: [Items]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [ids]
* properties:
* ids:
* type: array
* items:
* type: integer
* responses:
* 200:
* description: Reorder queued
* content:
* application/json:
* schema:
* type: object
* properties:
* queued:
* type: boolean
*/
itemsRouter.put('/reorder', (req, res) => {
const ids = req.body?.ids
if (!Array.isArray(ids)) {
res.status(400).json({ error: 'ids must be an array' })
return
}
queue.enqueue({ type: 'reorder', ids })
res.json({ queued: true })
})

View File

@@ -0,0 +1,79 @@
export interface PaginatedResult {
data: { id: number }[]
total: number
page: number
limit: number
hasMore: boolean
}
const allItems: number[] = Array.from({ length: 1_000_000 }, (_, i) => i + 1)
const selectedIds = new Set<number>()
const orderedSelected: number[] = []
export function getItems(page: number, limit: number, search?: string): PaginatedResult {
let filtered: number[]
if (search) {
filtered = allItems.filter(id => !selectedIds.has(id) && String(id).includes(search))
}
else {
filtered = allItems.filter(id => !selectedIds.has(id))
}
const total = filtered.length
const start = (page - 1) * limit
const slice = filtered.slice(start, start + limit)
return {
data: slice.map(id => ({ id })),
total,
page,
limit,
hasMore: start + limit < total,
}
}
export function getSelectedItems(page: number, limit: number, search?: string): PaginatedResult {
let filtered: number[]
if (search) {
filtered = orderedSelected.filter(id => String(id).includes(search))
}
else {
filtered = [...orderedSelected]
}
const total = filtered.length
const start = (page - 1) * limit
const slice = filtered.slice(start, start + limit)
return {
data: slice.map(id => ({ id })),
total,
page,
limit,
hasMore: start + limit < total,
}
}
export function addItem(id: number): boolean {
if (allItems.includes(id) || selectedIds.has(id)) return false
allItems.push(id)
return true
}
export function selectItem(id: number): boolean {
if (selectedIds.has(id)) return false
selectedIds.add(id)
orderedSelected.push(id)
return true
}
export function deselectItem(id: number): boolean {
if (!selectedIds.has(id)) return false
selectedIds.delete(id)
const idx = orderedSelected.indexOf(id)
if (idx !== -1) orderedSelected.splice(idx, 1)
return true
}
export function reorderSelected(ids: number[]): void {
orderedSelected.length = 0
for (const id of ids) {
if (selectedIds.has(id)) orderedSelected.push(id)
}
}

View File

@@ -0,0 +1,65 @@
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[] }
type Task = AddTask | SelectTask | DeselectTask | ReorderTask
class RequestQueue {
private addQueue: AddTask[] = []
private actionQueue: ReorderTask[] = []
private pendingKeys = new Set<string>()
private pendingReorder: ReorderTask | null = null
constructor() {
setInterval(() => this.flushAdd(), 10_000)
setInterval(() => this.flushActions(), 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)
return true
}
const key = `${task.type}:${(task as AddTask | SelectTask | DeselectTask).id}`
if (this.pendingKeys.has(key)) return false
this.pendingKeys.add(key)
if (task.type === 'add') {
this.addQueue.push(task)
if (this.addQueue.length >= 100) this.flushAdd()
}
else {
// select/deselect применяются немедленно, чтобы GET после POST видел актуальный state
if (task.type === 'select') store.selectItem(task.id)
else if (task.type === 'deselect') store.deselectItem(task.id)
this.pendingKeys.delete(key)
}
return true
}
private flushAdd(): void {
const batch = this.addQueue.splice(0)
for (const task of batch) {
store.addItem(task.id)
this.pendingKeys.delete(`add:${task.id}`)
}
}
private flushActions(): void {
const batch = this.actionQueue.splice(0)
this.pendingReorder = null
for (const task of batch) {
if (task.type === 'reorder') {
store.reorderSelected(task.ids)
}
}
}
}
export const queue = new RequestQueue()

23
backend/src/swagger.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { Express } from 'express'
import swaggerJsdoc from 'swagger-jsdoc'
import swaggerUi from 'swagger-ui-express'
const options: swaggerJsdoc.Options = {
definition: {
openapi: '3.0.0',
info: {
title: 'TMC Items API',
version: '1.0.0',
description: 'API for managing items with selection and ordering',
},
servers: [{ url: 'http://localhost:1337' }],
},
apis: ['./src/routes/*.ts'],
}
const spec = swaggerJsdoc(options)
export function setupSwagger(app: Express): void {
app.get('/docs/json', (_req, res) => res.json(spec))
app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec))
}

17
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(pnpm dev *)"
]
}
}

108
frontend/app/app.vue Normal file
View File

@@ -0,0 +1,108 @@
<template>
<div class="layout">
<header class="app-header">
<h1>TMC Items Manager</h1>
</header>
<main class="panels">
<LeftPanel
:items="leftItems"
:loading="leftLoading"
:has-more="leftHasMore"
:search="leftSearch"
@update:search="leftSearch = $event"
@load-more="fetchLeft()"
@select="handleSelect"
@add="handleAdd"
/>
<RightPanel
:items="rightItems"
:loading="rightLoading"
:has-more="rightHasMore"
:search="rightSearch"
@update:search="rightSearch = $event"
@load-more="fetchRight()"
@deselect="handleDeselect"
@reorder="handleReorder"
/>
</main>
</div>
</template>
<script setup lang="ts">
import { onMounted, watch } from 'vue'
const {
leftItems,
rightItems,
leftSearch,
rightSearch,
leftLoading,
rightLoading,
leftHasMore,
rightHasMore,
fetchLeft,
fetchRight,
selectItem,
deselectItem,
reorderSelected,
addItem,
} = useItems()
onMounted(() => {
fetchLeft()
fetchRight()
})
watch(leftSearch, () => fetchLeft(true))
watch(rightSearch, () => fetchRight(true))
async function handleSelect(id: number) {
await selectItem(id)
}
async function handleDeselect(id: number) {
await deselectItem(id)
}
async function handleReorder(ids: number[]) {
await reorderSelected(ids)
}
async function handleAdd(id: number) {
await addItem(id)
}
</script>
<style lang="scss">
.layout {
display: flex;
flex-direction: column;
height: 100vh;
}
.app-header {
padding: 12px 24px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
flex-shrink: 0;
h1 {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
color: #1f2937;
}
}
.panels {
display: flex;
flex: 1;
overflow: hidden;
> * {
flex: 1;
min-width: 0;
overflow: hidden;
}
}
</style>

View File

@@ -0,0 +1,10 @@
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}

View File

@@ -0,0 +1,260 @@
<template>
<div class="panel">
<div class="panel-header">
<h2>All items</h2>
<div class="panel-controls">
<input
class="search-input"
type="text"
placeholder="Search by ID..."
:value="search"
@input="onSearchInput"
>
<button class="btn btn-primary" @click="showAddModal = true">
Add item
</button>
</div>
</div>
<div class="panel-body">
<ul class="item-list">
<li v-for="item in items" :key="item.id" class="item-row">
<span class="item-label">#{{ item.id }}</span>
<button class="btn btn-icon" title="Move to selected" @click="emit('select', item.id!)">
</button>
</li>
</ul>
<div v-if="loading" class="loading-text">
Loading...
</div>
<div v-if="hasMore && !loading" ref="sentinel" class="sentinel" />
<div v-if="!loading && !hasMore && items.length === 0" class="empty-text">
No items found
</div>
</div>
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
<div class="modal">
<h3>Add new item</h3>
<input
v-model.number="addIdInput"
class="search-input"
type="number"
placeholder="Enter item ID"
@keydown.enter="submitAdd"
>
<div class="modal-actions">
<button class="btn" @click="showAddModal = false">
Cancel
</button>
<button class="btn btn-primary" @click="submitAdd">
Add
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Item } from '~/services/api'
import { onUnmounted, ref, watch } from 'vue'
const props = defineProps<{
items: Item[]
loading: boolean
hasMore: boolean
search: string
}>()
const emit = defineEmits<{
(e: 'update:search', val: string): void
(e: 'loadMore'): void
(e: 'select', id: number): void
(e: 'add', id: number): void
}>()
const sentinel = ref<HTMLElement | null>(null)
const showAddModal = ref(false)
const addIdInput = ref<number | null>(null)
let observer: IntersectionObserver | null = null
watch(sentinel, (el) => {
if (observer) {
observer.disconnect()
observer = null
}
if (el) {
observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && props.hasMore && !props.loading) {
emit('loadMore')
}
}, { threshold: 0.1 })
observer.observe(el)
}
})
onUnmounted(() => {
observer?.disconnect()
})
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function onSearchInput(e: Event) {
const val = (e.target as HTMLInputElement).value
if (debounceTimer)
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
emit('update:search', val)
}, 300)
}
function submitAdd() {
if (addIdInput.value !== null && Number.isInteger(addIdInput.value)) {
emit('add', addIdInput.value)
addIdInput.value = null
showAddModal.value = false
}
}
</script>
<style scoped lang="scss">
.panel {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid #e5e7eb;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
h2 {
margin: 0 0 12px;
font-size: 1.1rem;
font-weight: 600;
}
}
.panel-controls {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
outline: none;
&:focus {
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
}
.btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
&:hover {
background: #f3f4f6;
}
&.btn-primary {
background: #6366f1;
color: #fff;
border-color: #6366f1;
&:hover {
background: #4f46e5;
}
}
&.btn-icon {
padding: 4px 10px;
font-size: 1rem;
}
}
.panel-body {
flex: 1;
overflow-y: auto;
}
.item-list {
list-style: none;
margin: 0;
padding: 0;
}
.item-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid #f3f4f6;
&:hover {
background: #f9fafb;
}
}
.item-label {
font-size: 0.9rem;
color: #374151;
}
.loading-text,
.empty-text {
padding: 16px;
text-align: center;
color: #9ca3af;
font-size: 0.9rem;
}
.sentinel {
height: 1px;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: #fff;
border-radius: 10px;
padding: 24px;
min-width: 320px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
h3 {
margin: 0 0 16px;
font-size: 1rem;
font-weight: 600;
}
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div class="panel">
<div class="panel-header">
<h2>Selected items</h2>
<div class="panel-controls">
<input
class="search-input"
type="text"
placeholder="Search by ID..."
:value="search"
@input="onSearchInput"
>
</div>
</div>
<div class="panel-body">
<VueDraggableNext
v-model="localItems"
tag="ul"
class="item-list"
handle=".drag-handle"
@end="onDragEnd"
>
<li v-for="item in localItems" :key="item.id" class="item-row">
<span class="drag-handle" title="Drag to reorder"></span>
<span class="item-label">#{{ item.id }}</span>
<button class="btn btn-icon" title="Remove from selected" @click="emit('deselect', item.id!)">
</button>
</li>
</VueDraggableNext>
<div v-if="loading" class="loading-text">
Loading...
</div>
<div v-if="hasMore && !loading" ref="sentinel" class="sentinel" />
<div v-if="!loading && !hasMore && items.length === 0" class="empty-text">
No items selected
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Item } from '~/services/api'
import { onUnmounted, ref, watch } from 'vue'
import { VueDraggableNext } from 'vue-draggable-next'
const props = defineProps<{
items: Item[]
loading: boolean
hasMore: boolean
search: string
}>()
const emit = defineEmits<{
(e: 'update:search', val: string): void
(e: 'loadMore'): void
(e: 'deselect', id: number): void
(e: 'reorder', ids: number[]): void
}>()
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
watch(sentinel, (el) => {
if (observer) {
observer.disconnect()
observer = null
}
if (el) {
observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && props.hasMore && !props.loading) {
emit('loadMore')
}
}, { threshold: 0.1 })
observer.observe(el)
}
})
onUnmounted(() => {
observer?.disconnect()
})
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function onSearchInput(e: Event) {
const val = (e.target as HTMLInputElement).value
if (debounceTimer)
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
emit('update:search', val)
}, 300)
}
const localItems = ref([...props.items])
watch(() => props.items, (val) => {
localItems.value = [...val]
}, { deep: true })
function onDragEnd(): void {
const ids = localItems.value.map(i => i.id!)
emit('reorder', ids)
}
</script>
<style scoped lang="scss">
.panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
h2 {
margin: 0 0 12px;
font-size: 1.1rem;
font-weight: 600;
}
}
.panel-controls {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
outline: none;
&:focus {
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
}
.btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
&:hover {
background: #f3f4f6;
}
&.btn-icon {
padding: 4px 10px;
font-size: 1rem;
}
}
.panel-body {
flex: 1;
overflow-y: auto;
}
.item-list {
list-style: none;
margin: 0;
padding: 0;
}
.item-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid #f3f4f6;
cursor: default;
&:hover {
background: #f9fafb;
}
}
.drag-handle {
cursor: grab;
color: #9ca3af;
font-size: 1.1rem;
user-select: none;
&:active {
cursor: grabbing;
}
}
.item-label {
flex: 1;
font-size: 0.9rem;
color: #374151;
}
.loading-text,
.empty-text {
padding: 16px;
text-align: center;
color: #9ca3af;
font-size: 0.9rem;
}
.sentinel {
height: 1px;
}
</style>

View File

@@ -0,0 +1,103 @@
import type { Item, PaginatedItems } from '~/services/api'
import { ref } from 'vue'
import { Api } from '~/services/api'
export function useItems() {
const config = useRuntimeConfig()
const client = new Api({ baseURL: config.public.apiBase })
const leftItems = ref<Item[]>([])
const rightItems = ref<Item[]>([])
const leftSearch = ref('')
const rightSearch = ref('')
const leftLoading = ref(false)
const rightLoading = ref(false)
const leftPage = ref(1)
const rightPage = ref(1)
const leftHasMore = ref(true)
const rightHasMore = ref(true)
async function fetchLeft(reset = false): Promise<void> {
if (reset) {
leftPage.value = 1
leftItems.value = []
leftHasMore.value = true
}
if (!leftHasMore.value || leftLoading.value)
return
leftLoading.value = true
try {
const data: PaginatedItems = await client.api.itemsList({
page: leftPage.value,
limit: 20,
...(leftSearch.value ? { search: leftSearch.value } : {}),
})
leftItems.value.push(...(data.data ?? []))
leftHasMore.value = data.hasMore ?? false
leftPage.value++
}
finally {
leftLoading.value = false
}
}
async function fetchRight(reset = false): Promise<void> {
if (reset) {
rightPage.value = 1
rightItems.value = []
rightHasMore.value = true
}
if (!rightHasMore.value || rightLoading.value)
return
rightLoading.value = true
try {
const data: PaginatedItems = await client.api.itemsSelectedList({
page: rightPage.value,
limit: 20,
...(rightSearch.value ? { search: rightSearch.value } : {}),
})
rightItems.value.push(...(data.data ?? []))
rightHasMore.value = data.hasMore ?? false
rightPage.value++
}
finally {
rightLoading.value = false
}
}
async function selectItem(id: number): Promise<void> {
await client.api.itemsSelectCreate({ id })
await Promise.all([fetchLeft(true), fetchRight(true)])
}
async function deselectItem(id: number): Promise<void> {
await client.api.itemsDeselectCreate({ id })
await Promise.all([fetchLeft(true), fetchRight(true)])
}
async function reorderSelected(ids: number[]): Promise<void> {
await client.api.itemsReorderUpdate({ ids })
}
async function addItem(id: number): Promise<void> {
await client.api.itemsAddCreate({ id })
await Promise.all([fetchLeft(true), fetchRight(true)])
}
return {
leftItems,
rightItems,
leftSearch,
rightSearch,
leftLoading,
rightLoading,
leftHasMore,
rightHasMore,
fetchLeft,
fetchRight,
selectItem,
deselectItem,
reorderSelected,
addItem,
}
}

View File

@@ -0,0 +1,383 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
export interface Item {
id?: number;
}
export interface PaginatedItems {
data?: Item[];
total?: number;
page?: number;
limit?: number;
hasMore?: boolean;
}
import type {
AxiosInstance,
AxiosRequestConfig,
HeadersDefaults,
ResponseType,
} from "axios";
import axios from "axios";
export type QueryParamsType = Record<string | number, any>;
export interface FullRequestParams
extends Omit<AxiosRequestConfig, "data" | "params" | "url" | "responseType"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseType;
/** request body */
body?: unknown;
}
export type RequestParams = Omit<
FullRequestParams,
"body" | "method" | "query" | "path"
>;
export interface ApiConfig<SecurityDataType = unknown>
extends Omit<AxiosRequestConfig, "data" | "cancelToken"> {
securityWorker?: (
securityData: SecurityDataType | null,
) => Promise<AxiosRequestConfig | void> | AxiosRequestConfig | void;
secure?: boolean;
format?: ResponseType;
}
export enum ContentType {
Json = "application/json",
JsonApi = "application/vnd.api+json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown> {
public instance: AxiosInstance;
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private secure?: boolean;
private format?: ResponseType;
constructor({
securityWorker,
secure,
format,
...axiosConfig
}: ApiConfig<SecurityDataType> = {}) {
this.instance = axios.create({
...axiosConfig,
baseURL: axiosConfig.baseURL || "http://localhost:1337",
});
this.secure = secure;
this.format = format;
this.securityWorker = securityWorker;
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
};
protected mergeRequestParams(
params1: AxiosRequestConfig,
params2?: AxiosRequestConfig,
): AxiosRequestConfig {
const method = params1.method || (params2 && params2.method);
return {
...this.instance.defaults,
...params1,
...(params2 || {}),
headers: {
...((method &&
this.instance.defaults.headers[
method.toLowerCase() as keyof HeadersDefaults
]) ||
{}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}
protected stringifyFormItem(formItem: unknown) {
if (typeof formItem === "object" && formItem !== null) {
return JSON.stringify(formItem);
} else {
return `${formItem}`;
}
}
protected createFormData(input: Record<string, unknown>): FormData {
if (input instanceof FormData) {
return input;
}
return Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
const propertyContent: any[] =
property instanceof Array ? property : [property];
for (const formItem of propertyContent) {
const isFileType = formItem instanceof Blob || formItem instanceof File;
formData.append(
key,
isFileType ? formItem : this.stringifyFormItem(formItem),
);
}
return formData;
}, new FormData());
}
public request = async <T = any, _E = any>({
secure,
path,
type,
query,
format,
body,
...params
}: FullRequestParams): Promise<T> => {
const secureParams =
((typeof secure === "boolean" ? secure : this.secure) &&
this.securityWorker &&
(await this.securityWorker(this.securityData))) ||
{};
const requestParams = this.mergeRequestParams(params, secureParams);
const responseFormat = format || this.format || undefined;
if (
type === ContentType.FormData &&
body &&
body !== null &&
typeof body === "object"
) {
body = this.createFormData(body as Record<string, unknown>);
}
if (
type === ContentType.Text &&
body &&
body !== null &&
typeof body !== "string"
) {
body = JSON.stringify(body);
}
return this.instance
.request({
...requestParams,
headers: {
...(requestParams.headers || {}),
...(type ? { "Content-Type": type } : {}),
},
params: query,
responseType: responseFormat,
data: body,
url: path,
})
.then((response) => response.data);
};
}
/**
* @title TMC Items API
* @version 1.0.0
* @baseUrl http://localhost:1337
*
* API for managing items with selection and ordering
*/
export class Api<
SecurityDataType extends unknown,
> extends HttpClient<SecurityDataType> {
api = {
/**
* No description
*
* @tags Items
* @name ItemsList
* @summary Get unselected items
* @request GET:/api/items
*/
itemsList: (
query?: {
/** @default 1 */
page?: number;
/**
* @max 20
* @default 20
*/
limit?: number;
search?: string;
},
params: RequestParams = {},
) =>
this.request<PaginatedItems, any>({
path: `/api/items`,
method: "GET",
query: query,
format: "json",
...params,
}),
/**
* No description
*
* @tags Items
* @name ItemsSelectedList
* @summary Get selected items in order
* @request GET:/api/items/selected
*/
itemsSelectedList: (
query?: {
/** @default 1 */
page?: number;
/**
* @max 20
* @default 20
*/
limit?: number;
search?: string;
},
params: RequestParams = {},
) =>
this.request<PaginatedItems, any>({
path: `/api/items/selected`,
method: "GET",
query: query,
format: "json",
...params,
}),
/**
* No description
*
* @tags Items
* @name ItemsAddCreate
* @summary Queue an item for addition
* @request POST:/api/items/add
*/
itemsAddCreate: (
data: {
id: number;
},
params: RequestParams = {},
) =>
this.request<
{
queued?: boolean;
id?: number;
},
void
>({
path: `/api/items/add`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* No description
*
* @tags Items
* @name ItemsSelectCreate
* @summary Queue an item for selection (move to right panel)
* @request POST:/api/items/select
*/
itemsSelectCreate: (
data: {
id: number;
},
params: RequestParams = {},
) =>
this.request<
{
queued?: boolean;
},
any
>({
path: `/api/items/select`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* No description
*
* @tags Items
* @name ItemsDeselectCreate
* @summary Queue an item for deselection (move back to left panel)
* @request POST:/api/items/deselect
*/
itemsDeselectCreate: (
data: {
id: number;
},
params: RequestParams = {},
) =>
this.request<
{
queued?: boolean;
},
any
>({
path: `/api/items/deselect`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* No description
*
* @tags Items
* @name ItemsReorderUpdate
* @summary Queue a reorder of selected items
* @request PUT:/api/items/reorder
*/
itemsReorderUpdate: (
data: {
ids: number[];
},
params: RequestParams = {},
) =>
this.request<
{
queued?: boolean;
},
any
>({
path: `/api/items/reorder`,
method: "PUT",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
};
}

24
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,24 @@
import antfu from '@antfu/eslint-config'
export default antfu({
typescript: true,
vue: true,
formatters: true,
rules: {
'no-console': 0,
'vue/no-deprecated-slot-attribute': 0,
'vue/block-order': ['error', {
order: ['template', 'script', 'style'],
}],
'unused-imports/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'node/prefer-global/process': 'off',
'eslint-comments/no-unlimited-disable': 'off',
'eslint-comments/no-unused-disable': 'off',
},
})

16
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,16 @@
export default defineNuxtConfig({
devServer: {
host: '0.0.0.0',
},
ssr: false,
srcDir: 'app',
modules: ['@nuxt/ui'],
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
css: ['~/assets/styles/main.scss'],
runtimeConfig: {
public: {
apiBase: process.env.API_BASE ?? 'http://localhost:1337',
},
},
})

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "tmc-frontend",
"type": "module",
"private": true,
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"lint": "eslint . --fix",
"typecheck": "vue-tsc --noEmit",
"gen:api": "npx swagger-typescript-api generate --path http://localhost:1337/docs/json --output ./app/services --name api.ts --axios --unwrap-response-data"
},
"dependencies": {
"@nuxt/ui": "^4.7.1",
"axios": "^1.17.0",
"nuxt": "^4.4.2",
"vue-draggable-next": "^2.3.0"
},
"devDependencies": {
"@antfu/eslint-config": "^9.0.0",
"eslint": "^10.4.1",
"eslint-plugin-format": "^2.0.1",
"sass": "^1.100.0",
"swagger-typescript-api": "^13.12.0",
"typescript": "^6.0.3",
"vue-tsc": "^3.3.3"
}
}

12085
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
allowBuilds:
'@parcel/watcher': false
esbuild: false
vue-demi: false
minimumReleaseAgeExclude:
- axios@1.17.0
shellEmulator: true
trustPolicy: no-downgrade

3
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}