init
This commit is contained in:
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
395
PROMPT.md
Normal 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
11
backend/eslint.config.js
Normal 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
29
backend/package.json
Normal 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
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
20
backend/src/index.ts
Normal 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`)
|
||||||
|
})
|
||||||
5
backend/src/middleware/batcher.ts
Normal file
5
backend/src/middleware/batcher.ts
Normal 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
259
backend/src/routes/items.ts
Normal 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 })
|
||||||
|
})
|
||||||
79
backend/src/services/itemsStore.ts
Normal file
79
backend/src/services/itemsStore.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/src/services/queue.ts
Normal file
65
backend/src/services/queue.ts
Normal 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
23
backend/src/swagger.ts
Normal 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
17
backend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
7
frontend/.claude/settings.local.json
Normal file
7
frontend/.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pnpm dev *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
108
frontend/app/app.vue
Normal file
108
frontend/app/app.vue
Normal 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>
|
||||||
10
frontend/app/assets/styles/main.scss
Normal file
10
frontend/app/assets/styles/main.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
260
frontend/app/components/LeftPanel.vue
Normal file
260
frontend/app/components/LeftPanel.vue
Normal 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>
|
||||||
216
frontend/app/components/RightPanel.vue
Normal file
216
frontend/app/components/RightPanel.vue
Normal 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>
|
||||||
103
frontend/app/composables/useItems.ts
Normal file
103
frontend/app/composables/useItems.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
383
frontend/app/services/api.ts
Normal file
383
frontend/app/services/api.ts
Normal 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
24
frontend/eslint.config.js
Normal 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
16
frontend/nuxt.config.ts
Normal 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
28
frontend/package.json
Normal 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
12085
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
frontend/pnpm-workspace.yaml
Normal file
9
frontend/pnpm-workspace.yaml
Normal 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
3
frontend/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user