Migrate frontend in typescript
This commit is contained in:
parent
81076dd308
commit
8871c9162c
60 changed files with 1021 additions and 389 deletions
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -35,7 +35,10 @@ jobs:
|
|||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-asyncio httpx
|
||||
pip install pytest pytest-asyncio httpx ruff
|
||||
|
||||
- name: Lint
|
||||
run: ruff check .
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v
|
||||
|
|
@ -59,6 +62,12 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint src/
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:run
|
||||
|
||||
|
|
|
|||
|
|
@ -48,13 +48,14 @@ ruff check . --fix # lint with auto-fix
|
|||
ruff format . # format
|
||||
```
|
||||
|
||||
### Frontend — ESLint + Prettier
|
||||
### Frontend — TypeScript + ESLint + Prettier
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npx eslint src/ # lint
|
||||
npx prettier --check src/ # check formatting
|
||||
npx prettier --write src/ # auto-format
|
||||
npm run type-check # type check (vue-tsc)
|
||||
npx eslint src/ # lint
|
||||
npx prettier --check src/ # check formatting
|
||||
npx prettier --write src/ # auto-format
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
|
@ -64,7 +65,7 @@ npx prettier --write src/ # auto-format
|
|||
cd document-parser
|
||||
pytest tests/ -v
|
||||
|
||||
# Frontend (87 tests)
|
||||
# Frontend (81 tests)
|
||||
cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -38,7 +38,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
|
|||
|
||||
| Service | Stack | Role |
|
||||
|---------|-------|------|
|
||||
| **frontend** | Vue 3, Vite, Pinia | UI, PDF viewer, results display |
|
||||
| **frontend** | Vue 3, TypeScript, Vite, Pinia | UI, PDF viewer, results display |
|
||||
| **document-parser** | FastAPI, Docling, SQLite, pdf2image | REST API, document parsing, storage |
|
||||
|
||||
### Backend structure (clean architecture)
|
||||
|
|
@ -80,7 +80,7 @@ frontend/src/
|
|||
│ ├── document/ # Document store, API, upload, list
|
||||
│ ├── history/ # History store, API, navigation
|
||||
│ └── settings/ # Settings store
|
||||
└── shared/ # Shared utilities (i18n, http, format)
|
||||
└── shared/ # Shared utilities (types, i18n, http, format)
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
|
@ -128,7 +128,7 @@ cd document-parser
|
|||
pip install pytest pytest-asyncio httpx
|
||||
pytest tests/ -v
|
||||
|
||||
# Frontend (87 tests)
|
||||
# Frontend (81 tests)
|
||||
cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
|
@ -167,7 +167,7 @@ GitHub Actions pipelines (see [`.github/workflows/`](.github/workflows/)):
|
|||
|
||||
| Workflow | Trigger | What it does |
|
||||
|----------|---------|--------------|
|
||||
| **CI** | push to `main`, pull requests | Backend tests (99) + Frontend tests (87) + build |
|
||||
| **CI** | push to `main`, pull requests | Lint + type check + Backend tests (99) + Frontend tests (81) + build |
|
||||
| **Release** | push tag `v*` | Build & push multi-arch Docker image to `ghcr.io` |
|
||||
|
||||
To publish a new version:
|
||||
|
|
@ -199,7 +199,7 @@ All Docker images are multi-arch (linux/amd64 + linux/arm64). No GPU required.
|
|||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Vue 3, Vite, Pinia, DOMPurify
|
||||
- **Frontend**: Vue 3, TypeScript, Vite, Pinia, DOMPurify
|
||||
- **Backend**: FastAPI, Docling 2.x, SQLite (aiosqlite), pdf2image
|
||||
- **CI**: GitHub Actions
|
||||
- **Infra**: Docker Compose + Nginx
|
||||
|
|
|
|||
7
frontend/env.d.ts
vendored
Normal file
7
frontend/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
|
|
@ -1,15 +1,24 @@
|
|||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['src/**/*.{js,vue}'],
|
||||
files: ['src/**/*.{ts,js,vue}'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'no-debugger': 'error',
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/require-default-prop': 'off',
|
||||
// Formatting handled by Prettier
|
||||
|
|
|
|||
|
|
@ -8,6 +8,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/app/main.js"></script>
|
||||
<script type="module" src="/src/app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
515
frontend/package-lock.json
generated
515
frontend/package-lock.json
generated
|
|
@ -16,12 +16,16 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"prettier": "^3.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
"vitest": "^2.1.0",
|
||||
"vue-tsc": "^2.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
|
|
@ -946,6 +950,16 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
|
||||
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
||||
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dompurify": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -964,6 +978,273 @@
|
|||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
||||
"integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.57.1",
|
||||
"@typescript-eslint/type-utils": "8.57.1",
|
||||
"@typescript-eslint/utils": "8.57.1",
|
||||
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.57.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz",
|
||||
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.57.1",
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz",
|
||||
"integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.57.1",
|
||||
"@typescript-eslint/types": "^8.57.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz",
|
||||
"integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"@typescript-eslint/visitor-keys": "8.57.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz",
|
||||
"integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz",
|
||||
"integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||
"@typescript-eslint/utils": "8.57.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz",
|
||||
"integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz",
|
||||
"integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.57.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.57.1",
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz",
|
||||
"integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.57.1",
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"@typescript-eslint/typescript-estree": "8.57.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz",
|
||||
"integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||
|
|
@ -1083,6 +1364,32 @@
|
|||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/language-core": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
|
||||
"integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@volar/source-map": "2.4.15"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/source-map": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
|
||||
"integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@volar/typescript": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
|
||||
"integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@volar/language-core": "2.4.15",
|
||||
"path-browserify": "^1.0.1",
|
||||
"vscode-uri": "^3.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
|
||||
|
|
@ -1139,11 +1446,69 @@
|
|||
"@vue/shared": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-vue2": {
|
||||
"version": "2.7.16",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
|
||||
"integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"de-indent": "^1.0.2",
|
||||
"he": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||
},
|
||||
"node_modules/@vue/language-core": {
|
||||
"version": "2.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
|
||||
"integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@volar/language-core": "2.4.15",
|
||||
"@vue/compiler-dom": "^3.5.0",
|
||||
"@vue/compiler-vue2": "^2.7.16",
|
||||
"@vue/shared": "^3.5.0",
|
||||
"alien-signals": "^1.0.3",
|
||||
"minimatch": "^9.0.3",
|
||||
"muggle-string": "^0.4.1",
|
||||
"path-browserify": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/language-core/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/language-core/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
|
||||
|
|
@ -1226,6 +1591,12 @@
|
|||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
|
||||
"integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
|
|
@ -1392,6 +1763,12 @@
|
|||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||
},
|
||||
"node_modules/de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -1718,6 +2095,23 @@
|
|||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
|
|
@ -1812,6 +2206,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -1995,6 +2398,12 @@
|
|||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
|
|
@ -2089,6 +2498,12 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
|
|
@ -2127,6 +2542,18 @@
|
|||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
|
||||
|
|
@ -2369,6 +2796,22 @@
|
|||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
|
|
@ -2396,6 +2839,18 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=18.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
|
@ -2420,6 +2875,42 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz",
|
||||
"integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
"@typescript-eslint/parser": "8.57.1",
|
||||
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||
"@typescript-eslint/utils": "8.57.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
|
|
@ -2581,6 +3072,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||
|
|
@ -2709,6 +3206,22 @@
|
|||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "2.2.12",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
|
||||
"integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@volar/typescript": "2.4.15",
|
||||
"@vue/language-core": "2.2.12"
|
||||
},
|
||||
"bin": {
|
||||
"vue-tsc": "bin/vue-tsc.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest --watch",
|
||||
"test:run": "vitest run",
|
||||
|
|
@ -23,11 +24,15 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"prettier": "^3.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
"vitest": "^2.1.0",
|
||||
"vue-tsc": "^2.2.12"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { AppSidebar } from '../shared/ui/index.js'
|
||||
import { useSettingsStore } from '../features/settings/store.js'
|
||||
import { useDocumentStore } from '../features/document/store.js'
|
||||
import { useI18n } from '../shared/i18n.js'
|
||||
import { AppSidebar } from '../shared/ui/index'
|
||||
import { useSettingsStore } from '../features/settings/store'
|
||||
import { useDocumentStore } from '../features/document/store'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
useSettingsStore()
|
||||
const { t } = useI18n()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { router } from './router/index.js'
|
||||
import { router } from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
|
@ -1,39 +1,40 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../../pages/HomePage.vue')
|
||||
component: () => import('../../pages/HomePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/studio',
|
||||
name: 'studio',
|
||||
component: () => import('../../pages/StudioPage.vue')
|
||||
component: () => import('../../pages/StudioPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/history',
|
||||
name: 'history',
|
||||
component: () => import('../../pages/HistoryPage.vue')
|
||||
component: () => import('../../pages/HistoryPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/documents',
|
||||
name: 'documents',
|
||||
component: () => import('../../pages/DocumentsPage.vue')
|
||||
component: () => import('../../pages/DocumentsPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../../pages/SettingsPage.vue')
|
||||
component: () => import('../../pages/SettingsPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
redirect: '/'
|
||||
}
|
||||
redirect: '/',
|
||||
},
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
routes,
|
||||
})
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { apiFetch } from '../../shared/api/http.js'
|
||||
|
||||
export function createAnalysis(documentId, pipelineOptions = null) {
|
||||
const body = { documentId }
|
||||
if (pipelineOptions) {
|
||||
body.pipelineOptions = pipelineOptions
|
||||
}
|
||||
return apiFetch('/api/analyses', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAnalyses() {
|
||||
return apiFetch('/api/analyses')
|
||||
}
|
||||
|
||||
export function fetchAnalysis(id) {
|
||||
return apiFetch(`/api/analyses/${id}`)
|
||||
}
|
||||
|
||||
export function deleteAnalysis(id) {
|
||||
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createAnalysis, fetchAnalyses, fetchAnalysis, deleteAnalysis } from './api.js'
|
||||
import { createAnalysis, fetchAnalyses, fetchAnalysis, deleteAnalysis } from './api'
|
||||
|
||||
vi.mock('../../shared/api/http.js', () => ({
|
||||
vi.mock('../../shared/api/http', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}))
|
||||
|
||||
import { apiFetch } from '../../shared/api/http.js'
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
|
||||
describe('analysis API', () => {
|
||||
beforeEach(() => {
|
||||
28
frontend/src/features/analysis/api.ts
Normal file
28
frontend/src/features/analysis/api.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Analysis, PipelineOptions } from '../../shared/types'
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
|
||||
export function createAnalysis(
|
||||
documentId: string,
|
||||
pipelineOptions: PipelineOptions | null = null,
|
||||
): Promise<Analysis> {
|
||||
const body: Record<string, unknown> = { documentId }
|
||||
if (pipelineOptions) {
|
||||
body.pipelineOptions = pipelineOptions
|
||||
}
|
||||
return apiFetch<Analysis>('/api/analyses', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAnalyses(): Promise<Analysis[]> {
|
||||
return apiFetch<Analysis[]>('/api/analyses')
|
||||
}
|
||||
|
||||
export function fetchAnalysis(id: string): Promise<Analysis> {
|
||||
return apiFetch<Analysis>(`/api/analyses/${id}`)
|
||||
}
|
||||
|
||||
export function deleteAnalysis(id: string): Promise<unknown> {
|
||||
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
/**
|
||||
* Bbox scaling utilities for mapping Docling TOPLEFT coordinates
|
||||
* to canvas pixel coordinates.
|
||||
*
|
||||
* Docling bbox format (after backend normalization): [l, t, r, b]
|
||||
* All values are in page coordinate space (points, 1pt = 1/72 inch).
|
||||
* Origin: top-left (y=0 at top of page).
|
||||
*
|
||||
* The preview image is a faithful rasterization of the PDF page.
|
||||
* CSS `max-width: 100%; height: auto` preserves the aspect ratio,
|
||||
* so sx and sy are always equal. We compute both for completeness.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute scale factors from page coordinates to displayed pixels.
|
||||
*
|
||||
* @param {number} displayWidth - img.clientWidth (CSS pixels)
|
||||
* @param {number} displayHeight - img.clientHeight (CSS pixels)
|
||||
* @param {number} pageWidth - Page width in Docling points
|
||||
* @param {number} pageHeight - Page height in Docling points
|
||||
* @returns {{ sx: number, sy: number }}
|
||||
*/
|
||||
export function computeScale(displayWidth, displayHeight, pageWidth, pageHeight) {
|
||||
return {
|
||||
sx: displayWidth / pageWidth,
|
||||
sy: displayHeight / pageHeight,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Docling bbox [l, t, r, b] to a canvas rect { x, y, w, h }.
|
||||
*
|
||||
* @param {number[]} bbox - [left, top, right, bottom] in page points
|
||||
* @param {{ sx: number, sy: number }} scale
|
||||
* @returns {{ x: number, y: number, w: number, h: number }}
|
||||
*/
|
||||
export function bboxToRect(bbox, scale) {
|
||||
const [l, t, r, b] = bbox
|
||||
return {
|
||||
x: l * scale.sx,
|
||||
y: t * scale.sy,
|
||||
w: (r - l) * scale.sx,
|
||||
h: (b - t) * scale.sy,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a point (px, py) falls inside a rect.
|
||||
*
|
||||
* @param {number} px
|
||||
* @param {number} py
|
||||
* @param {{ x: number, y: number, w: number, h: number }} rect
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function pointInRect(px, py, rect) {
|
||||
return px >= rect.x && px <= rect.x + rect.w &&
|
||||
py >= rect.y && py <= rect.y + rect.h
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { computeScale, bboxToRect, pointInRect } from './bboxScaling.js'
|
||||
import { computeScale, bboxToRect, pointInRect } from './bboxScaling'
|
||||
|
||||
describe('computeScale', () => {
|
||||
it('returns 1:1 when display matches page', () => {
|
||||
27
frontend/src/features/analysis/bboxScaling.ts
Normal file
27
frontend/src/features/analysis/bboxScaling.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { Scale, Rect } from '../../shared/types'
|
||||
|
||||
export function computeScale(
|
||||
displayWidth: number,
|
||||
displayHeight: number,
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
): Scale {
|
||||
return {
|
||||
sx: displayWidth / pageWidth,
|
||||
sy: displayHeight / pageHeight,
|
||||
}
|
||||
}
|
||||
|
||||
export function bboxToRect(bbox: [number, number, number, number], scale: Scale): Rect {
|
||||
const [l, t, r, b] = bbox
|
||||
return {
|
||||
x: l * scale.sx,
|
||||
y: t * scale.sy,
|
||||
w: (r - l) * scale.sx,
|
||||
h: (b - t) * scale.sy,
|
||||
}
|
||||
}
|
||||
|
||||
export function pointInRect(px: number, py: number, rect: Rect): boolean {
|
||||
return px >= rect.x && px <= rect.x + rect.w && py >= rect.y && py <= rect.y + rect.h
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export { useAnalysisStore } from './store.js'
|
||||
export { useAnalysisStore } from './store'
|
||||
export { default as AnalysisPanel } from './ui/AnalysisPanel.vue'
|
||||
export { default as ResultTabs } from './ui/ResultTabs.vue'
|
||||
export { default as MarkdownViewer } from './ui/MarkdownViewer.vue'
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAnalysisStore } from './store.js'
|
||||
import { useAnalysisStore } from './store'
|
||||
|
||||
vi.mock('../../shared/api/http.js', () => ({
|
||||
vi.mock('../../shared/api/http', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./api.js', () => ({
|
||||
vi.mock('./api', () => ({
|
||||
fetchAnalyses: vi.fn(),
|
||||
fetchAnalysis: vi.fn(),
|
||||
createAnalysis: vi.fn(),
|
||||
deleteAnalysis: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as api from './api.js'
|
||||
import * as api from './api'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API layer — body construction with pipeline options
|
||||
|
|
@ -24,17 +24,17 @@ describe('createAnalysis — pipeline options body construction', () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset the real module to get the unmocked version
|
||||
vi.doUnmock('./api.js')
|
||||
vi.doUnmock('./api')
|
||||
})
|
||||
|
||||
// We test the mock integration (store → api) separately below.
|
||||
// Here we verify the raw apiFetch call shape.
|
||||
|
||||
it('sends body without pipelineOptions when null', async () => {
|
||||
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
|
||||
const { apiFetch: realApiFetch } = await import('../../shared/api/http')
|
||||
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
|
||||
|
||||
const { createAnalysis: real } = await import('./api.js')
|
||||
const { createAnalysis: real } = await import('./api')
|
||||
await real('doc-1', null)
|
||||
|
||||
expect(realApiFetch).toHaveBeenCalledWith('/api/analyses', {
|
||||
|
|
@ -44,10 +44,10 @@ describe('createAnalysis — pipeline options body construction', () => {
|
|||
})
|
||||
|
||||
it('sends body without pipelineOptions when undefined', async () => {
|
||||
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
|
||||
const { apiFetch: realApiFetch } = await import('../../shared/api/http')
|
||||
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
|
||||
|
||||
const { createAnalysis: real } = await import('./api.js')
|
||||
const { createAnalysis: real } = await import('./api')
|
||||
await real('doc-1')
|
||||
|
||||
expect(realApiFetch).toHaveBeenCalledWith('/api/analyses', {
|
||||
|
|
@ -57,7 +57,7 @@ describe('createAnalysis — pipeline options body construction', () => {
|
|||
})
|
||||
|
||||
it('includes pipelineOptions in body when provided', async () => {
|
||||
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
|
||||
const { apiFetch: realApiFetch } = await import('../../shared/api/http')
|
||||
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
|
||||
|
||||
const opts = {
|
||||
|
|
@ -73,7 +73,7 @@ describe('createAnalysis — pipeline options body construction', () => {
|
|||
images_scale: 1.0,
|
||||
}
|
||||
|
||||
const { createAnalysis: real } = await import('./api.js')
|
||||
const { createAnalysis: real } = await import('./api')
|
||||
await real('doc-1', opts)
|
||||
|
||||
const sentBody = JSON.parse(realApiFetch.mock.calls[0][1].body)
|
||||
|
|
@ -82,12 +82,12 @@ describe('createAnalysis — pipeline options body construction', () => {
|
|||
})
|
||||
|
||||
it('includes only specified fields in partial options', async () => {
|
||||
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
|
||||
const { apiFetch: realApiFetch } = await import('../../shared/api/http')
|
||||
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
|
||||
|
||||
const opts = { do_ocr: false }
|
||||
|
||||
const { createAnalysis: real } = await import('./api.js')
|
||||
const { createAnalysis: real } = await import('./api')
|
||||
await real('doc-1', opts)
|
||||
|
||||
const sentBody = JSON.parse(realApiFetch.mock.calls[0][1].body)
|
||||
|
|
@ -95,11 +95,11 @@ describe('createAnalysis — pipeline options body construction', () => {
|
|||
})
|
||||
|
||||
it('does not include pipelineOptions key when options is empty object treated as falsy', async () => {
|
||||
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
|
||||
const { apiFetch: realApiFetch } = await import('../../shared/api/http')
|
||||
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
|
||||
|
||||
// Empty object is truthy in JS, so it SHOULD be included
|
||||
const { createAnalysis: real } = await import('./api.js')
|
||||
const { createAnalysis: real } = await import('./api')
|
||||
await real('doc-1', {})
|
||||
|
||||
const sentBody = JSON.parse(realApiFetch.mock.calls[0][1].body)
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAnalysisStore } from './store.js'
|
||||
import { useAnalysisStore } from './store'
|
||||
|
||||
vi.mock('./api.js', () => ({
|
||||
vi.mock('./api', () => ({
|
||||
fetchAnalyses: vi.fn(),
|
||||
fetchAnalysis: vi.fn(),
|
||||
createAnalysis: vi.fn(),
|
||||
deleteAnalysis: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as api from './api.js'
|
||||
import * as api from './api'
|
||||
|
||||
describe('useAnalysisStore', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -1,36 +1,42 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as api from './api.js'
|
||||
import type { Analysis, Page, PipelineOptions } from '../../shared/types'
|
||||
import * as api from './api'
|
||||
|
||||
export const useAnalysisStore = defineStore('analysis', () => {
|
||||
const analyses = ref([])
|
||||
const currentAnalysis = ref(null)
|
||||
const analyses = ref<Analysis[]>([])
|
||||
const currentAnalysis = ref<Analysis | null>(null)
|
||||
const running = ref(false)
|
||||
const error = ref(null)
|
||||
const pollingInterval = ref(null)
|
||||
const error = ref<string | null>(null)
|
||||
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const currentPages = computed(() => {
|
||||
const currentPages = computed<Page[]>(() => {
|
||||
if (!currentAnalysis.value?.pagesJson) return []
|
||||
try {
|
||||
return JSON.parse(currentAnalysis.value.pagesJson)
|
||||
return JSON.parse(currentAnalysis.value.pagesJson) as Page[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
function clearError() { error.value = null }
|
||||
function clearError(): void {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
error.value = null
|
||||
analyses.value = await api.fetchAnalyses()
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to load analyses'
|
||||
error.value = (e as Error).message || 'Failed to load analyses'
|
||||
console.error('Failed to load analyses', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function run(documentId, pipelineOptions = null) {
|
||||
async function run(
|
||||
documentId: string,
|
||||
pipelineOptions: PipelineOptions | null = null,
|
||||
): Promise<Analysis> {
|
||||
running.value = true
|
||||
error.value = null
|
||||
try {
|
||||
|
|
@ -41,26 +47,26 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
return analysis
|
||||
} catch (e) {
|
||||
running.value = false
|
||||
error.value = e.message || 'Failed to start analysis'
|
||||
error.value = (e as Error).message || 'Failed to start analysis'
|
||||
console.error('Failed to start analysis', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(id) {
|
||||
function startPolling(id: string): void {
|
||||
stopPolling()
|
||||
pollingInterval.value = setInterval(async () => {
|
||||
try {
|
||||
const updated = await api.fetchAnalysis(id)
|
||||
currentAnalysis.value = updated
|
||||
const idx = analyses.value.findIndex(a => a.id === id)
|
||||
const idx = analyses.value.findIndex((a) => a.id === id)
|
||||
if (idx !== -1) analyses.value[idx] = updated
|
||||
if (updated.status === 'COMPLETED' || updated.status === 'FAILED') {
|
||||
stopPolling()
|
||||
running.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Polling error'
|
||||
error.value = (e as Error).message || 'Polling error'
|
||||
console.error('Polling error', e)
|
||||
stopPolling()
|
||||
running.value = false
|
||||
|
|
@ -68,32 +74,44 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
function stopPolling(): void {
|
||||
if (pollingInterval.value) {
|
||||
clearInterval(pollingInterval.value)
|
||||
pollingInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id) {
|
||||
async function select(id: string): Promise<void> {
|
||||
try {
|
||||
currentAnalysis.value = await api.fetchAnalysis(id)
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to load analysis'
|
||||
error.value = (e as Error).message || 'Failed to load analysis'
|
||||
console.error('Failed to load analysis', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
async function remove(id: string): Promise<void> {
|
||||
try {
|
||||
await api.deleteAnalysis(id)
|
||||
analyses.value = analyses.value.filter(a => a.id !== id)
|
||||
analyses.value = analyses.value.filter((a) => a.id !== id)
|
||||
if (currentAnalysis.value?.id === id) currentAnalysis.value = null
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to delete analysis'
|
||||
error.value = (e as Error).message || 'Failed to delete analysis'
|
||||
console.error('Failed to delete analysis', e)
|
||||
}
|
||||
}
|
||||
|
||||
return { analyses, currentAnalysis, currentPages, running, error, clearError, load, run, select, remove, stopPolling }
|
||||
return {
|
||||
analyses,
|
||||
currentAnalysis,
|
||||
currentPages,
|
||||
running,
|
||||
error,
|
||||
clearError,
|
||||
load,
|
||||
run,
|
||||
select,
|
||||
remove,
|
||||
stopPolling,
|
||||
}
|
||||
})
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
<PagePreview
|
||||
:document-id="selectedDoc.id"
|
||||
:page="currentPage"
|
||||
:page-count="selectedDoc.pageCount"
|
||||
:page-count="selectedDoc.pageCount ?? undefined"
|
||||
@update:page="currentPage = $event"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -44,11 +44,11 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useDocumentStore } from '../../document/store.js'
|
||||
import { useAnalysisStore } from '../store.js'
|
||||
import { DocumentUpload, DocumentList, PagePreview } from '../../document/index.js'
|
||||
import { useDocumentStore } from '../../document/store'
|
||||
import { useAnalysisStore } from '../store'
|
||||
import { DocumentUpload, DocumentList, PagePreview } from '../../document/index'
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const analysisStore = useAnalysisStore()
|
||||
|
|
|
|||
|
|
@ -35,11 +35,12 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling.js'
|
||||
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
|
||||
import type { Page, PageElement } from '../../../shared/types'
|
||||
|
||||
const ELEMENT_COLORS = {
|
||||
const ELEMENT_COLORS: Record<string, string> = {
|
||||
title: '#EF4444',
|
||||
section_header: '#F97316',
|
||||
text: '#3B82F6',
|
||||
|
|
@ -51,65 +52,62 @@ const ELEMENT_COLORS = {
|
|||
caption: '#EAB308'
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
/** The <img> element to overlay onto */
|
||||
imageEl: { type: Object, default: null },
|
||||
/** Page data { page_number, width, height, elements[] } for current page */
|
||||
pageData: { type: Object, default: null },
|
||||
/** Index of the element to highlight (from ResultTabs hover) */
|
||||
highlightedIndex: { type: Number, default: -1 }
|
||||
})
|
||||
const props = defineProps<{
|
||||
imageEl: HTMLImageElement | null
|
||||
pageData: Page | null
|
||||
highlightedIndex: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['highlight-element'])
|
||||
const emit = defineEmits<{
|
||||
'highlight-element': [index: number]
|
||||
}>()
|
||||
|
||||
const hiddenTypes = reactive(new Set())
|
||||
const canvasRef = ref(null)
|
||||
const hoveredElement = ref(null)
|
||||
const tooltipStyle = ref({})
|
||||
const hiddenTypes = reactive(new Set<string>())
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const hoveredElement = ref<PageElement | null>(null)
|
||||
const tooltipStyle = ref<Record<string, string>>({})
|
||||
|
||||
const visibleElements = computed(() => {
|
||||
if (!props.pageData) return []
|
||||
return props.pageData.elements.filter(e => !hiddenTypes.has(e.type))
|
||||
return props.pageData.elements.filter((e: PageElement) => !hiddenTypes.has(e.type))
|
||||
})
|
||||
|
||||
function toggleType(type) {
|
||||
function toggleType(type: string): void {
|
||||
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
|
||||
else hiddenTypes.add(type)
|
||||
draw()
|
||||
}
|
||||
|
||||
function countElements(type) {
|
||||
function countElements(type: string): number {
|
||||
if (!props.pageData) return 0
|
||||
return props.pageData.elements.filter(e => e.type === type).length
|
||||
return props.pageData.elements.filter((e: PageElement) => e.type === type).length
|
||||
}
|
||||
|
||||
function draw() {
|
||||
function draw(): void {
|
||||
const canvas = canvasRef.value
|
||||
const img = props.imageEl
|
||||
if (!canvas || !img) return
|
||||
|
||||
// Wait until image is loaded (clientWidth > 0)
|
||||
if (!img.clientWidth || !img.clientHeight) return
|
||||
|
||||
canvas.width = img.clientWidth
|
||||
canvas.height = img.clientHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
if (!props.pageData) return
|
||||
|
||||
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
||||
|
||||
// Build a map of content-filtered indices to match ResultTabs ordering
|
||||
const allElements = (props.pageData.elements || [])
|
||||
const contentElements = allElements.filter(e => e.content)
|
||||
const contentElements = allElements.filter((e: PageElement) => e.content)
|
||||
|
||||
for (const el of visibleElements.value) {
|
||||
const rect = bboxToRect(el.bbox, scale)
|
||||
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
||||
|
||||
// Check if this element is the highlighted one
|
||||
const elContentIdx = contentElements.indexOf(el)
|
||||
const isHighlighted = props.highlightedIndex >= 0 && elContentIdx === props.highlightedIndex
|
||||
|
||||
|
|
@ -122,7 +120,7 @@ function draw() {
|
|||
}
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
function onMouseMove(e: MouseEvent): void {
|
||||
const canvas = canvasRef.value
|
||||
const img = props.imageEl
|
||||
if (!canvas || !img || !props.pageData) return
|
||||
|
|
@ -133,9 +131,9 @@ function onMouseMove(e) {
|
|||
|
||||
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
||||
|
||||
const contentElements = (props.pageData.elements || []).filter(e => e.content)
|
||||
const contentElements = (props.pageData.elements || []).filter((el: PageElement) => el.content)
|
||||
|
||||
let found = null
|
||||
let found: PageElement | null = null
|
||||
let foundIdx = -1
|
||||
for (const el of visibleElements.value) {
|
||||
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
|
||||
|
|
@ -155,8 +153,7 @@ function onMouseMove(e) {
|
|||
}
|
||||
}
|
||||
|
||||
// Redraw on resize
|
||||
let resizeObserver = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
onMounted(() => {
|
||||
if (props.imageEl) {
|
||||
resizeObserver = new ResizeObserver(() => draw())
|
||||
|
|
|
|||
|
|
@ -25,18 +25,19 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '../../../shared/i18n.js'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import type { Page, PageElement } from '../../../shared/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
pages: { type: Array, default: () => [] }
|
||||
pages: { type: Array as () => Page[], default: () => [] }
|
||||
})
|
||||
|
||||
const images = computed(() => {
|
||||
const result = []
|
||||
const result: (PageElement & { page: number })[] = []
|
||||
for (const page of props.pages) {
|
||||
for (const el of (page.elements || [])) {
|
||||
if (el.type === 'picture') {
|
||||
|
|
|
|||
|
|
@ -2,18 +2,18 @@
|
|||
<div class="markdown-viewer" v-html="rendered" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useI18n } from '../../../shared/i18n.js'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
|
||||
const props = defineProps({ content: String })
|
||||
const { t } = useI18n()
|
||||
|
||||
const rendered = computed(() => {
|
||||
if (!props.content) return `<p class="empty">${t('results.noMarkdown')}</p>`
|
||||
return DOMPurify.sanitize(marked.parse(props.content))
|
||||
return DOMPurify.sanitize(marked.parse(props.content) as string)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -96,14 +96,15 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { useAnalysisStore } from '../store.js'
|
||||
import { useAnalysisStore } from '../store'
|
||||
import MarkdownViewer from './MarkdownViewer.vue'
|
||||
import ImageGallery from './ImageGallery.vue'
|
||||
import { useI18n } from '../../../shared/i18n.js'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import type { PageElement } from '../../../shared/types'
|
||||
|
||||
const ELEMENT_COLORS = {
|
||||
const ELEMENT_COLORS: Record<string, string> = {
|
||||
title: '#EF4444',
|
||||
section_header: '#F97316',
|
||||
text: '#3B82F6',
|
||||
|
|
@ -157,7 +158,7 @@ const pageMarkdown = computed(() => {
|
|||
.join('\n\n')
|
||||
})
|
||||
|
||||
function formatElement(el) {
|
||||
function formatElement(el: PageElement) {
|
||||
if (!el.content) return ''
|
||||
const indent = ' '.repeat(Math.max(0, (el.level || 0) - 1))
|
||||
switch (el.type) {
|
||||
|
|
@ -184,7 +185,7 @@ function formatElement(el) {
|
|||
|
||||
// --- Copy to clipboard ---
|
||||
const copiedMarkdown = ref(false)
|
||||
const copiedElements = reactive({})
|
||||
const copiedElements: Record<number, boolean> = reactive({})
|
||||
|
||||
async function copyMarkdown() {
|
||||
try {
|
||||
|
|
@ -194,7 +195,7 @@ async function copyMarkdown() {
|
|||
} catch { /* clipboard not available */ }
|
||||
}
|
||||
|
||||
async function copyElement(idx, content) {
|
||||
async function copyElement(idx: number, content: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
copiedElements[idx] = true
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
<div class="canvas-container" ref="containerRef">
|
||||
<img
|
||||
v-if="documentId"
|
||||
:src="previewUrl"
|
||||
:src="previewUrl ?? undefined"
|
||||
class="page-image"
|
||||
ref="imageRef"
|
||||
@load="onImageLoad"
|
||||
|
|
@ -58,12 +58,13 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, reactive } from 'vue'
|
||||
import { getPreviewUrl } from '../../document/api.js'
|
||||
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling.js'
|
||||
import { getPreviewUrl } from '../../document/api'
|
||||
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
|
||||
import type { Page, PageElement } from '../../../shared/types'
|
||||
|
||||
const ELEMENT_COLORS = {
|
||||
const ELEMENT_COLORS: Record<string, string> = {
|
||||
section_header: '#F97316',
|
||||
text: '#3B82F6',
|
||||
table: '#8B5CF6',
|
||||
|
|
@ -74,17 +75,17 @@ const ELEMENT_COLORS = {
|
|||
}
|
||||
|
||||
const props = defineProps({
|
||||
pages: { type: Array, default: () => [] },
|
||||
pages: { type: Array as () => Page[], default: () => [] },
|
||||
documentId: String
|
||||
})
|
||||
|
||||
const selectedPage = ref(1)
|
||||
const hiddenTypes = reactive(new Set())
|
||||
const containerRef = ref(null)
|
||||
const imageRef = ref(null)
|
||||
const canvasRef = ref(null)
|
||||
const hoveredElement = ref(null)
|
||||
const tooltipStyle = ref({})
|
||||
const hiddenTypes = reactive(new Set<string>())
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const hoveredElement = ref<PageElement | null>(null)
|
||||
const tooltipStyle = ref<Record<string, string>>({})
|
||||
const imageSize = ref({ width: 0, height: 0 })
|
||||
|
||||
const currentPageData = computed(() => {
|
||||
|
|
@ -101,13 +102,13 @@ const previewUrl = computed(() => {
|
|||
return getPreviewUrl(props.documentId, selectedPage.value)
|
||||
})
|
||||
|
||||
function toggleType(type) {
|
||||
function toggleType(type: string) {
|
||||
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
|
||||
else hiddenTypes.add(type)
|
||||
drawOverlay()
|
||||
}
|
||||
|
||||
function countElements(type) {
|
||||
function countElements(type: string) {
|
||||
if (!currentPageData.value) return 0
|
||||
return currentPageData.value.elements.filter(e => e.type === type).length
|
||||
}
|
||||
|
|
@ -128,6 +129,7 @@ function drawOverlay() {
|
|||
canvas.height = img.clientHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const page = currentPageData.value
|
||||
|
|
@ -148,7 +150,7 @@ function drawOverlay() {
|
|||
}
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const canvas = canvasRef.value
|
||||
const page = currentPageData.value
|
||||
const img = imageRef.value
|
||||
|
|
@ -160,7 +162,7 @@ function onMouseMove(e) {
|
|||
|
||||
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
|
||||
|
||||
let found = null
|
||||
let found: PageElement | null = null
|
||||
for (const el of visibleElements.value) {
|
||||
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
|
||||
found = el
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import { apiFetch } from '../../shared/api/http.js'
|
||||
|
||||
export function fetchDocuments() {
|
||||
return apiFetch('/api/documents')
|
||||
}
|
||||
|
||||
export function fetchDocument(id) {
|
||||
return apiFetch(`/api/documents/${id}`)
|
||||
}
|
||||
|
||||
export async function uploadDocument(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return apiFetch('/api/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
skipContentType: true
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDocument(id) {
|
||||
return apiFetch(`/api/documents/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function getPreviewUrl(id, page = 1, dpi = 150) {
|
||||
return `/api/documents/${id}/preview?page=${page}&dpi=${dpi}`
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fetchDocuments, fetchDocument, uploadDocument, deleteDocument, getPreviewUrl } from './api.js'
|
||||
import { fetchDocuments, fetchDocument, uploadDocument, deleteDocument, getPreviewUrl } from './api'
|
||||
|
||||
vi.mock('../../shared/api/http.js', () => ({
|
||||
vi.mock('../../shared/api/http', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}))
|
||||
|
||||
import { apiFetch } from '../../shared/api/http.js'
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
|
||||
describe('document API', () => {
|
||||
beforeEach(() => {
|
||||
28
frontend/src/features/document/api.ts
Normal file
28
frontend/src/features/document/api.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Document } from '../../shared/types'
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
|
||||
export function fetchDocuments(): Promise<Document[]> {
|
||||
return apiFetch<Document[]>('/api/documents')
|
||||
}
|
||||
|
||||
export function fetchDocument(id: string): Promise<Document> {
|
||||
return apiFetch<Document>(`/api/documents/${id}`)
|
||||
}
|
||||
|
||||
export async function uploadDocument(file: File): Promise<Document> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return apiFetch<Document>('/api/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
skipContentType: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDocument(id: string): Promise<unknown> {
|
||||
return apiFetch(`/api/documents/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function getPreviewUrl(id: string, page = 1, dpi = 150): string {
|
||||
return `/api/documents/${id}/preview?page=${page}&dpi=${dpi}`
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export { useDocumentStore } from './store.js'
|
||||
export { useDocumentStore } from './store'
|
||||
export { default as DocumentUpload } from './ui/DocumentUpload.vue'
|
||||
export { default as DocumentList } from './ui/DocumentList.vue'
|
||||
export { default as PagePreview } from './ui/PagePreview.vue'
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useDocumentStore } from './store.js'
|
||||
import { useDocumentStore } from './store'
|
||||
|
||||
vi.mock('./api.js', () => ({
|
||||
vi.mock('./api', () => ({
|
||||
fetchDocuments: vi.fn(),
|
||||
uploadDocument: vi.fn(),
|
||||
deleteDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as api from './api.js'
|
||||
import * as api from './api'
|
||||
|
||||
describe('useDocumentStore', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -1,26 +1,29 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from './api.js'
|
||||
import type { Document } from '../../shared/types'
|
||||
import * as api from './api'
|
||||
|
||||
export const useDocumentStore = defineStore('document', () => {
|
||||
const documents = ref([])
|
||||
const selectedId = ref(null)
|
||||
const documents = ref<Document[]>([])
|
||||
const selectedId = ref<string | null>(null)
|
||||
const uploading = ref(false)
|
||||
const error = ref(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function clearError() { error.value = null }
|
||||
function clearError(): void {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
error.value = null
|
||||
documents.value = await api.fetchDocuments()
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to load documents'
|
||||
error.value = (e as Error).message || 'Failed to load documents'
|
||||
console.error('Failed to load documents', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(file) {
|
||||
async function upload(file: File): Promise<Document> {
|
||||
uploading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
|
|
@ -29,7 +32,7 @@ export const useDocumentStore = defineStore('document', () => {
|
|||
selectedId.value = doc.id
|
||||
return doc
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to upload document'
|
||||
error.value = (e as Error).message || 'Failed to upload document'
|
||||
console.error('Failed to upload document', e)
|
||||
throw e
|
||||
} finally {
|
||||
|
|
@ -37,18 +40,18 @@ export const useDocumentStore = defineStore('document', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
async function remove(id: string): Promise<void> {
|
||||
try {
|
||||
await api.deleteDocument(id)
|
||||
documents.value = documents.value.filter(d => d.id !== id)
|
||||
documents.value = documents.value.filter((d) => d.id !== id)
|
||||
if (selectedId.value === id) selectedId.value = null
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to delete document'
|
||||
error.value = (e as Error).message || 'Failed to delete document'
|
||||
console.error('Failed to delete document', e)
|
||||
}
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
function select(id: string): void {
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
|
|
@ -23,9 +23,9 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDocumentStore } from '../store.js'
|
||||
import { formatSize } from '../../../shared/format.js'
|
||||
<script setup lang="ts">
|
||||
import { useDocumentStore } from '../store'
|
||||
import { formatSize } from '../../../shared/format'
|
||||
const store = useDocumentStore()
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -22,29 +22,30 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useDocumentStore } from '../store.js'
|
||||
import { useI18n } from '../../../shared/i18n.js'
|
||||
import { useDocumentStore } from '../store'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
|
||||
const store = useDocumentStore()
|
||||
const { t } = useI18n()
|
||||
const fileInput = ref(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
|
||||
function openFilePicker() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onFileSelect(e) {
|
||||
const file = e.target.files?.[0]
|
||||
async function onFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) await store.upload(file)
|
||||
e.target.value = ''
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
async function onDrop(e) {
|
||||
async function onDrop(e: DragEvent) {
|
||||
dragging.value = false
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file && file.type === 'application/pdf') {
|
||||
await store.upload(file)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<button class="nav-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
<button class="nav-btn" :disabled="pageCount && page >= pageCount" @click="$emit('update:page', page + 1)">
|
||||
<button class="nav-btn" :disabled="!!(pageCount && page >= pageCount)" @click="$emit('update:page', page + 1)">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -23,9 +23,9 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { getPreviewUrl } from '../api.js'
|
||||
import { getPreviewUrl } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
documentId: String,
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { useHistoryStore } from './store.js'
|
||||
export { useHistoryStore } from './store'
|
||||
export { default as HistoryList } from './ui/HistoryList.vue'
|
||||
|
|
@ -7,23 +7,23 @@ vi.mock('vue-router', () => ({
|
|||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('../analysis/api.js', () => ({
|
||||
vi.mock('../analysis/api', () => ({
|
||||
fetchAnalyses: vi.fn(),
|
||||
fetchAnalysis: vi.fn(),
|
||||
createAnalysis: vi.fn(),
|
||||
deleteAnalysis: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../document/api.js', () => ({
|
||||
vi.mock('../document/api', () => ({
|
||||
fetchDocuments: vi.fn(),
|
||||
uploadDocument: vi.fn(),
|
||||
deleteDocument: vi.fn(),
|
||||
getPreviewUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useHistoryStore } from './store.js'
|
||||
import { useAnalysisStore } from '../analysis/store.js'
|
||||
import { useDocumentStore } from '../document/store.js'
|
||||
import { useHistoryStore } from './store'
|
||||
import { useAnalysisStore } from '../analysis/store'
|
||||
import { useDocumentStore } from '../document/store'
|
||||
|
||||
describe('History → Studio navigation', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -33,7 +33,7 @@ describe('History → Studio navigation', () => {
|
|||
|
||||
describe('History store provides data for navigation', () => {
|
||||
it('analyses contain documentId for document selection', async () => {
|
||||
const { fetchAnalyses } = await import('../analysis/api.js')
|
||||
const { fetchAnalyses } = await import('../analysis/api')
|
||||
fetchAnalyses.mockResolvedValue([
|
||||
{ id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' },
|
||||
{ id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' },
|
||||
|
|
@ -49,7 +49,7 @@ describe('History → Studio navigation', () => {
|
|||
|
||||
describe('Analysis store select() restores analysis state', () => {
|
||||
it('select() sets currentAnalysis from fetched data', async () => {
|
||||
const { fetchAnalysis } = await import('../analysis/api.js')
|
||||
const { fetchAnalysis } = await import('../analysis/api')
|
||||
const analysis = {
|
||||
id: 'a1',
|
||||
documentId: 'd1',
|
||||
|
|
@ -67,7 +67,7 @@ describe('History → Studio navigation', () => {
|
|||
})
|
||||
|
||||
it('select() allows document store to select the associated document', async () => {
|
||||
const { fetchAnalysis } = await import('../analysis/api.js')
|
||||
const { fetchAnalysis } = await import('../analysis/api')
|
||||
fetchAnalysis.mockResolvedValue({
|
||||
id: 'a1',
|
||||
documentId: 'd1',
|
||||
|
|
@ -126,7 +126,7 @@ describe('History → Studio navigation', () => {
|
|||
|
||||
describe('Full restore flow (store-level integration)', () => {
|
||||
it('restores completed analysis: selects analysis + document + verifier mode', async () => {
|
||||
const { fetchAnalysis } = await import('../analysis/api.js')
|
||||
const { fetchAnalysis } = await import('../analysis/api')
|
||||
fetchAnalysis.mockResolvedValue({
|
||||
id: 'a1',
|
||||
documentId: 'd1',
|
||||
|
|
@ -153,7 +153,7 @@ describe('History → Studio navigation', () => {
|
|||
})
|
||||
|
||||
it('restores failed analysis: selects analysis + document, stays in configurer mode', async () => {
|
||||
const { fetchAnalysis } = await import('../analysis/api.js')
|
||||
const { fetchAnalysis } = await import('../analysis/api')
|
||||
fetchAnalysis.mockResolvedValue({
|
||||
id: 'a2',
|
||||
documentId: 'd2',
|
||||
|
|
@ -176,7 +176,7 @@ describe('History → Studio navigation', () => {
|
|||
})
|
||||
|
||||
it('handles missing analysis gracefully', async () => {
|
||||
const { fetchAnalysis } = await import('../analysis/api.js')
|
||||
const { fetchAnalysis } = await import('../analysis/api')
|
||||
fetchAnalysis.mockRejectedValue(new Error('Not found'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { useHistoryStore } from './store.js'
|
||||
import { useAnalysisStore } from '../analysis/store.js'
|
||||
import { useHistoryStore } from './store'
|
||||
import { useAnalysisStore } from '../analysis/store'
|
||||
|
||||
describe('useHistoryStore', () => {
|
||||
it('is a re-export of useAnalysisStore', () => {
|
||||
|
|
@ -1 +1 @@
|
|||
export { useAnalysisStore as useHistoryStore } from '../analysis/store.js'
|
||||
export { useAnalysisStore as useHistoryStore } from '../analysis/store'
|
||||
|
|
@ -30,20 +30,21 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAnalysisStore } from '../../analysis/store.js'
|
||||
import { useI18n } from '../../../shared/i18n.js'
|
||||
import { useAnalysisStore } from '../../analysis/store'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import type { Analysis } from '../../../shared/types'
|
||||
|
||||
const store = useAnalysisStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
function openAnalysis(analysis) {
|
||||
function openAnalysis(analysis: Analysis) {
|
||||
router.push({ name: 'studio', query: { analysisId: analysis.id } })
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
function statusClass(status: string) {
|
||||
return {
|
||||
'status-pending': status === 'PENDING',
|
||||
'status-running': status === 'RUNNING',
|
||||
|
|
@ -52,14 +53,14 @@ function statusClass(status) {
|
|||
}
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
function duration(analysis) {
|
||||
function duration(analysis: Analysis) {
|
||||
if (!analysis.startedAt || !analysis.completedAt) return ''
|
||||
const ms = new Date(analysis.completedAt) - new Date(analysis.startedAt)
|
||||
const ms = new Date(analysis.completedAt!).getTime() - new Date(analysis.startedAt!).getTime()
|
||||
const secs = Math.round(ms / 1000)
|
||||
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { useSettingsStore } from './store.js'
|
||||
export { useSettingsStore } from './store'
|
||||
export { default as SettingsPanel } from './ui/SettingsPanel.vue'
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, watch, watchEffect } from 'vue'
|
||||
import type { Locale, Theme } from '../../shared/types'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const apiUrl = ref('http://localhost:8000')
|
||||
const theme = ref(localStorage.getItem('docling-theme') || 'dark')
|
||||
const locale = ref(localStorage.getItem('docling-locale') || 'fr')
|
||||
const theme = ref<Theme>((localStorage.getItem('docling-theme') as Theme) || 'dark')
|
||||
const locale = ref<Locale>((localStorage.getItem('docling-locale') as Locale) || 'fr')
|
||||
|
||||
watch(theme, (v) => localStorage.setItem('docling-theme', v))
|
||||
watch(locale, (v) => localStorage.setItem('docling-locale', v))
|
||||
|
|
@ -13,8 +14,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
document.documentElement.classList.toggle('light', theme.value === 'light')
|
||||
})
|
||||
|
||||
function setTheme(t) { theme.value = t }
|
||||
function setLocale(l) { locale.value = l }
|
||||
function setTheme(t: Theme): void {
|
||||
theme.value = t
|
||||
}
|
||||
function setLocale(l: Locale): void {
|
||||
locale.value = l
|
||||
}
|
||||
|
||||
return { apiUrl, theme, locale, setTheme, setLocale }
|
||||
})
|
||||
|
|
@ -27,9 +27,9 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useSettingsStore } from '../store.js'
|
||||
import { useI18n } from '../../../shared/i18n.js'
|
||||
<script setup lang="ts">
|
||||
import { useSettingsStore } from '../store'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
|
||||
const store = useSettingsStore()
|
||||
const { t } = useI18n()
|
||||
|
|
|
|||
|
|
@ -35,16 +35,16 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useDocumentStore } from '../features/document/store.js'
|
||||
import { useI18n } from '../shared/i18n.js'
|
||||
import { formatSize } from '../shared/format.js'
|
||||
import { useDocumentStore } from '../features/document/store'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import { formatSize } from '../shared/format'
|
||||
|
||||
const docStore = useDocumentStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
function formatDate(iso) {
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { HistoryList, useHistoryStore } from '../features/history/index.js'
|
||||
import { useI18n } from '../shared/i18n.js'
|
||||
import { HistoryList, useHistoryStore } from '../features/history/index'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
const historyStore = useHistoryStore()
|
||||
const { t } = useI18n()
|
||||
|
|
|
|||
|
|
@ -53,13 +53,14 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDocumentStore } from '../features/document/store.js'
|
||||
import { useHistoryStore } from '../features/history/index.js'
|
||||
import { DocumentUpload } from '../features/document/index.js'
|
||||
import { useI18n } from '../shared/i18n.js'
|
||||
import { useDocumentStore } from '../features/document/store'
|
||||
import { useHistoryStore } from '../features/history/index'
|
||||
import { DocumentUpload } from '../features/document/index'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import type { Document } from '../shared/types'
|
||||
|
||||
const router = useRouter()
|
||||
const documentStore = useDocumentStore()
|
||||
|
|
@ -70,7 +71,7 @@ const docCount = computed(() => documentStore.documents.length)
|
|||
const analysisCount = computed(() => historyStore.analyses.length)
|
||||
const recentDocs = computed(() => documentStore.documents.slice(0, 5))
|
||||
|
||||
function openInStudio(doc) {
|
||||
function openInStudio(doc: Document) {
|
||||
documentStore.select(doc.id)
|
||||
router.push('/studio')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { SettingsPanel } from '../features/settings/index.js'
|
||||
import { useI18n } from '../shared/i18n.js'
|
||||
<script setup lang="ts">
|
||||
import { SettingsPanel } from '../features/settings/index'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -281,16 +281,17 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, reactive } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDocumentStore } from '../features/document/store.js'
|
||||
import { useAnalysisStore } from '../features/analysis/store.js'
|
||||
import { DocumentUpload, DocumentList } from '../features/document/index.js'
|
||||
import { ResultTabs } from '../features/analysis/index.js'
|
||||
import { useDocumentStore } from '../features/document/store'
|
||||
import { useAnalysisStore } from '../features/analysis/store'
|
||||
import { DocumentUpload, DocumentList } from '../features/document/index'
|
||||
import { ResultTabs } from '../features/analysis/index'
|
||||
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
|
||||
import { getPreviewUrl } from '../features/document/api.js'
|
||||
import { useI18n } from '../shared/i18n.js'
|
||||
import { getPreviewUrl } from '../features/document/api'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import type { PipelineOptions } from '../shared/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -302,8 +303,8 @@ const mode = ref('configurer')
|
|||
const currentPage = ref(1)
|
||||
const visualMode = ref(false)
|
||||
const highlightedElementIndex = ref(-1)
|
||||
const pdfImageRef = ref(null)
|
||||
const bboxOverlayRef = ref(null)
|
||||
const pdfImageRef = ref<HTMLImageElement | null>(null)
|
||||
const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null)
|
||||
|
||||
// --- Resizable right panel ---
|
||||
const RIGHT_PANEL_MIN = 280
|
||||
|
|
@ -311,7 +312,7 @@ const RIGHT_PANEL_MAX_RATIO = 0.7
|
|||
const rightPanelWidth = ref(380)
|
||||
let resizing = false
|
||||
|
||||
function onResizeStart(e) {
|
||||
function onResizeStart(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
document.body.style.cursor = 'col-resize'
|
||||
|
|
@ -320,7 +321,7 @@ function onResizeStart(e) {
|
|||
document.addEventListener('mouseup', onResizeEnd)
|
||||
}
|
||||
|
||||
function onResizeMove(e) {
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
if (!resizing) return
|
||||
const maxWidth = window.innerWidth * RIGHT_PANEL_MAX_RATIO
|
||||
const newWidth = window.innerWidth - e.clientX
|
||||
|
|
@ -337,7 +338,7 @@ function onResizeEnd() {
|
|||
nextTick(() => bboxOverlayRef.value?.draw())
|
||||
}
|
||||
|
||||
const pipelineOptions = reactive({
|
||||
const pipelineOptions = reactive<PipelineOptions>({
|
||||
do_ocr: true,
|
||||
do_table_structure: true,
|
||||
table_mode: 'accurate',
|
||||
|
|
@ -372,8 +373,8 @@ const previewUrl = computed(() => {
|
|||
return getPreviewUrl(selectedDoc.value.id, currentPage.value)
|
||||
})
|
||||
|
||||
function onPageInput(e) {
|
||||
const val = parseInt(e.target.value)
|
||||
function onPageInput(e: Event) {
|
||||
const val = parseInt((e.target as HTMLInputElement).value)
|
||||
if (!val || val < 1) return
|
||||
const max = selectedDoc.value?.pageCount || val
|
||||
currentPage.value = Math.min(val, max)
|
||||
|
|
@ -403,7 +404,7 @@ onMounted(async () => {
|
|||
// Restore analysis from history via query param
|
||||
const analysisId = route.query.analysisId
|
||||
if (analysisId) {
|
||||
await analysisStore.select(analysisId)
|
||||
await analysisStore.select(analysisId as string)
|
||||
const analysis = analysisStore.currentAnalysis
|
||||
if (analysis) {
|
||||
documentStore.select(analysis.documentId)
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
export async function apiFetch(url, options = {}) {
|
||||
const headers = { ...options.headers }
|
||||
|
||||
if (!options.skipContentType) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
if (!response.ok) throw new Error(`API error: ${response.status}`)
|
||||
if (response.status === 204) return null
|
||||
return response.json()
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { apiFetch } from './http.js'
|
||||
import { apiFetch } from './http'
|
||||
|
||||
describe('apiFetch', () => {
|
||||
beforeEach(() => {
|
||||
19
frontend/src/shared/api/http.ts
Normal file
19
frontend/src/shared/api/http.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
interface FetchOptions extends RequestInit {
|
||||
skipContentType?: boolean
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(url: string, options: FetchOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) }
|
||||
|
||||
if (!options.skipContentType) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
if (!response.ok) throw new Error(`API error: ${response.status}`)
|
||||
if (response.status === 204) return null as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
/**
|
||||
* Format a byte count as a human-readable size string.
|
||||
* @param {number|null|undefined} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
const mb = bytes / (1024 * 1024)
|
||||
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`
|
||||
}
|
||||
5
frontend/src/shared/format.ts
Normal file
5
frontend/src/shared/format.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function formatSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) return ''
|
||||
const mb = bytes / (1024 * 1024)
|
||||
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`
|
||||
}
|
||||
|
|
@ -2,12 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock settings store before importing i18n
|
||||
vi.mock('../features/settings/store.js', () => ({
|
||||
vi.mock('../features/settings/store', () => ({
|
||||
useSettingsStore: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useSettingsStore } from '../features/settings/store.js'
|
||||
import { useI18n } from './i18n.js'
|
||||
import { useSettingsStore } from '../features/settings/store'
|
||||
import { useI18n } from './i18n'
|
||||
|
||||
describe('useI18n', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { useSettingsStore } from '../features/settings/store.js'
|
||||
import type { Locale } from './types'
|
||||
import { useSettingsStore } from '../features/settings/store'
|
||||
|
||||
const messages = {
|
||||
type MessageMap = Record<string, string>
|
||||
type Messages = Record<Locale, MessageMap>
|
||||
|
||||
const messages: Messages = {
|
||||
fr: {
|
||||
// Sidebar
|
||||
'nav.home': 'Accueil',
|
||||
|
|
@ -188,16 +192,16 @@ const messages = {
|
|||
'settings.themeDark': 'Dark',
|
||||
'settings.themeLight': 'Light',
|
||||
'settings.language': 'Language',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const settings = useSettingsStore()
|
||||
|
||||
function t(key, params = {}) {
|
||||
function t(key: string, params: Record<string, string | number> = {}): string {
|
||||
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
str = str.replace(`{${k}}`, v)
|
||||
str = str.replace(`{${k}}`, String(v))
|
||||
}
|
||||
return str
|
||||
}
|
||||
79
frontend/src/shared/types.ts
Normal file
79
frontend/src/shared/types.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
export interface Document {
|
||||
id: string
|
||||
filename: string
|
||||
contentType: string | null
|
||||
fileSize: number | null
|
||||
pageCount: number | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PipelineOptions {
|
||||
do_ocr?: boolean
|
||||
do_table_structure?: boolean
|
||||
table_mode?: 'accurate' | 'fast'
|
||||
do_code_enrichment?: boolean
|
||||
do_formula_enrichment?: boolean
|
||||
do_picture_classification?: boolean
|
||||
do_picture_description?: boolean
|
||||
generate_picture_images?: boolean
|
||||
generate_page_images?: boolean
|
||||
images_scale?: number
|
||||
}
|
||||
|
||||
export type AnalysisStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED'
|
||||
|
||||
export interface Analysis {
|
||||
id: string
|
||||
documentId: string
|
||||
documentFilename: string | null
|
||||
status: AnalysisStatus
|
||||
contentMarkdown: string | null
|
||||
contentHtml: string | null
|
||||
pagesJson: string | null
|
||||
errorMessage: string | null
|
||||
startedAt: string | null
|
||||
completedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PageElement {
|
||||
type: string
|
||||
bbox: [number, number, number, number]
|
||||
content: string
|
||||
level: number
|
||||
}
|
||||
|
||||
// Backend serializes with snake_case (dataclasses.asdict)
|
||||
export interface Page {
|
||||
page_number: number
|
||||
width: number
|
||||
height: number
|
||||
elements: PageElement[]
|
||||
}
|
||||
|
||||
export type ElementType =
|
||||
| 'title'
|
||||
| 'section_header'
|
||||
| 'text'
|
||||
| 'table'
|
||||
| 'picture'
|
||||
| 'list'
|
||||
| 'formula'
|
||||
| 'code'
|
||||
| 'caption'
|
||||
| 'floating'
|
||||
|
||||
export interface Scale {
|
||||
sx: number
|
||||
sy: number
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export type Locale = 'fr' | 'en'
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
|
@ -33,9 +33,9 @@
|
|||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useI18n } from '../i18n.js'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@
|
|||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": false,
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.js", "src/**/*.vue"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
Loading…
Reference in a new issue