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