Final fixes before first release

This commit is contained in:
pjmalandrino 2026-03-17 17:09:34 +01:00
parent 6522d4ad9e
commit ac1b68ac18
17 changed files with 315 additions and 69 deletions

View file

@ -3,7 +3,7 @@
A visual document analysis studio powered by [Docling](https://github.com/DS4SD/docling).
Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser.
![Docling Studio — Visual Mode](docs/screenshots/visual-mode.png)
![Docling Studio — Execution Result](docs/screenshots/DS-execution-result.png)
## Features
@ -20,7 +20,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
| Import | Configure | Results |
|--------|-----------|---------|
| ![Import](docs/screenshots/import.png) | ![Configure](docs/screenshots/configure.png) | ![Results](docs/screenshots/results.png) |
| ![Import](docs/screenshots/DS-load-document.png) | ![Configure](docs/screenshots/DS-configure-execution.png) | ![Results](docs/screenshots/DS-execution-result.png) |
</details>
@ -79,7 +79,7 @@ docker compose up --build
Open [http://localhost:3000](http://localhost:3000)
> **Note:** First analysis may take a few minutes as Docling downloads its ML models (~40 MB) on first run.
> **Note:** The first analysis takes a bit longer as Docling downloads and caches its ML models (~400 MB). Subsequent runs are fast.
### Local Development
@ -122,13 +122,13 @@ All configuration is done via environment variables. See [`.env.example`](.env.e
## Performance & System Requirements
Docling runs ML models (layout analysis, OCR, table structure) on **CPU by default**. Processing time depends on document size and complexity.
Docling leverages optimized ML models (layout analysis, OCR, table structure) that run efficiently on CPU. The first analysis takes slightly longer as models are downloaded and cached (~400 MB). Subsequent runs are fast, even on large documents.
| Document type | Pages | Approx. time (CPU) |
|---------------|-------|---------------------|
| Simple report | 5-10 | 1-3 min |
| Research paper | 15-30 | 5-10 min |
| Dense PDF with tables | 30+ | 10-20 min |
| Simple report | 5-10 | ~30s-1 min |
| Research paper | 10-30 | ~1-2 min |
| Large document | 100+ | ~2-5 min |
### Docker Desktop settings
@ -144,17 +144,15 @@ The document parser needs **at least 4 GB of RAM**. Recommended Docker Desktop a
### Platform support
All Docker images are **multi-arch** (linux/amd64 + linux/arm64). Works natively on:
All Docker images are **multi-arch** (linux/amd64 + linux/arm64). All processing runs on **CPU** — no GPU required.
| Platform | Architecture | GPU acceleration |
|----------|-------------|-----------------|
| **macOS Apple Silicon** (M1/M2/M3) | arm64 | Not in Docker (MPS unavailable). Run parser locally for GPU. |
| **macOS Intel** | amd64 | N/A |
| **Linux x86_64** | amd64 | NVIDIA GPU via `docker compose --profile gpu` (coming soon) |
| **Linux ARM** (Raspberry Pi 5, Ampere) | arm64 | CPU only |
| **Windows + WSL2** | amd64 | NVIDIA GPU passthrough supported |
> **Tip for Mac users:** For faster processing, run the document parser **locally** (outside Docker) to leverage Apple Silicon's MPS acceleration when supported by PyTorch/Docling.
| Platform | Architecture |
|----------|-------------|
| **macOS Apple Silicon** (M1/M2/M3/M4) | arm64 |
| **macOS Intel** | amd64 |
| **Linux x86_64** | amd64 |
| **Linux ARM** (Raspberry Pi 5, Ampere) | arm64 |
| **Windows + WSL2** | amd64 |
## Tech Stack

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View file

@ -10,6 +10,9 @@
<script setup>
import { RouterView } from 'vue-router'
import { AppSidebar } from '../shared/ui/index.js'
import { useSettingsStore } from '../features/settings/store.js'
useSettingsStore()
</script>
<style>
@ -46,6 +49,29 @@ import { AppSidebar } from '../shared/ui/index.js'
--transition: 150ms ease;
}
html.light {
--bg: #FAFAFA;
--bg-surface: #FFFFFF;
--bg-elevated: #F4F4F5;
--bg-hover: #E4E4E7;
--accent: #F97316;
--accent-hover: #EA580C;
--accent-muted: rgba(249, 115, 22, 0.10);
--text: #18181B;
--text-secondary: #52525B;
--text-muted: #A1A1AA;
--border: #E4E4E7;
--border-light: #D4D4D8;
--success: #16A34A;
--error: #DC2626;
--warning: #CA8A04;
--info: #2563EB;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {

View file

@ -1,7 +1,7 @@
<template>
<div class="image-gallery">
<div v-if="images.length === 0" class="gallery-empty">
No images detected in this document
{{ t('results.noImages') }}
</div>
<div v-else class="gallery-grid">
<div
@ -11,7 +11,7 @@
>
<div class="card-header">
<span class="card-type">Picture</span>
<span class="card-page">Page {{ img.page }}</span>
<span class="card-page">{{ t('results.page') }} {{ img.page }}</span>
</div>
<div class="card-content" v-if="img.content">
{{ img.content.substring(0, 100) }}
@ -27,6 +27,9 @@
<script setup>
import { computed } from 'vue'
import { useI18n } from '../../../shared/i18n.js'
const { t } = useI18n()
const props = defineProps({
pages: { type: Array, default: () => [] }

View file

@ -5,11 +5,13 @@
<script setup>
import { computed } from 'vue'
import { marked } from 'marked'
import { useI18n } from '../../../shared/i18n.js'
const props = defineProps({ content: String })
const { t } = useI18n()
const rendered = computed(() => {
if (!props.content) return '<p class="empty">No markdown content</p>'
if (!props.content) return `<p class="empty">${t('results.noMarkdown')}</p>`
return marked.parse(props.content)
})
</script>

View file

@ -14,7 +14,7 @@
<!-- Page chip -->
<div class="page-indicator" v-if="totalPages > 0">
<span class="page-chip">Page {{ currentPage }} sur {{ totalPages }}</span>
<span class="page-chip">{{ t('results.pageOf', { current: currentPage, total: totalPages }) }}</span>
</div>
<div class="tab-content">
@ -27,17 +27,17 @@
</div>
<div v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder">
<div class="spinner-large" />
<span>Analyse en cours...</span>
<span>{{ t('studio.analysisRunning') }}</span>
</div>
<div v-else-if="store.currentAnalysis?.status === 'FAILED'" class="result-placeholder error">
<svg viewBox="0 0 20 20" fill="currentColor" class="error-icon"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
<span>{{ store.currentAnalysis.errorMessage || 'L\'analyse a échoué' }}</span>
<span>{{ store.currentAnalysis.errorMessage || t('results.analysisFailed') }}</span>
</div>
<div v-else class="result-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
<span>Lancez une analyse pour voir les résultats</span>
<span>{{ t('results.runAnalysis') }}</span>
</div>
</template>
@ -46,19 +46,21 @@ import { ref, computed } from 'vue'
import { useAnalysisStore } from '../store.js'
import MarkdownViewer from './MarkdownViewer.vue'
import ImageGallery from './ImageGallery.vue'
import { useI18n } from '../../../shared/i18n.js'
const props = defineProps({
currentPage: { type: Number, default: 1 }
})
const store = useAnalysisStore()
const { t } = useI18n()
const activeTab = ref('text')
const tabs = [
{ id: 'text', label: 'Résultat du texte' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'images', label: 'Images' }
]
const tabs = computed(() => [
{ id: 'text', label: t('results.textResult') },
{ id: 'markdown', label: t('results.markdown') },
{ id: 'images', label: t('results.images') }
])
const totalPages = computed(() => store.currentPages.length)

View file

@ -10,14 +10,14 @@
<input ref="fileInput" type="file" accept=".pdf" hidden @change="onFileSelect" />
<div v-if="store.uploading" class="upload-state">
<div class="spinner" />
<span>Uploading...</span>
<span>{{ t('upload.uploading') }}</span>
</div>
<div v-else class="upload-state">
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
</svg>
<span class="upload-text">Drop a PDF here or click to upload</span>
<span class="upload-hint">Max 50MB</span>
<span class="upload-text">{{ t('upload.drop') }}</span>
<span class="upload-hint">{{ t('upload.maxSize') }}</span>
</div>
</div>
</template>
@ -25,8 +25,10 @@
<script setup>
import { ref } from 'vue'
import { useDocumentStore } from '../store.js'
import { useI18n } from '../../../shared/i18n.js'
const store = useDocumentStore()
const { t } = useI18n()
const fileInput = ref(null)
const dragging = ref(false)

View file

@ -1,7 +1,7 @@
<template>
<div class="history-list">
<div v-if="store.analyses.length === 0" class="history-empty">
No analyses yet. Go to Studio to analyze your first document.
{{ t('history.empty') }}
</div>
<div v-else class="history-items">
<div
@ -31,8 +31,10 @@
<script setup>
import { useHistoryStore } from '../store.js'
import { useI18n } from '../../../shared/i18n.js'
const store = useHistoryStore()
const { t } = useI18n()
function statusClass(status) {
return {

View file

@ -1,8 +1,20 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, watch, watchEffect } from 'vue'
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')
return { apiUrl }
watch(theme, (v) => localStorage.setItem('docling-theme', v))
watch(locale, (v) => localStorage.setItem('docling-locale', v))
watchEffect(() => {
document.documentElement.classList.toggle('light', theme.value === 'light')
})
function setTheme(t) { theme.value = t }
function setLocale(l) { locale.value = l }
return { apiUrl, theme, locale, setTheme, setLocale }
})

View file

@ -1,11 +1,27 @@
<template>
<div class="settings-panel">
<div class="setting-group">
<label class="setting-label">API URL</label>
<input class="setting-input" v-model="store.apiUrl" readonly />
<label class="setting-label">{{ t('settings.theme') }}</label>
<div class="setting-toggle">
<button :class="{ active: store.theme === 'dark' }" @click="store.setTheme('dark')">
{{ t('settings.themeDark') }}
</button>
<button :class="{ active: store.theme === 'light' }" @click="store.setTheme('light')">
{{ t('settings.themeLight') }}
</button>
</div>
</div>
<div class="setting-group">
<label class="setting-label">Version</label>
<label class="setting-label">{{ t('settings.language') }}</label>
<div class="setting-toggle">
<button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">FR</button>
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">EN</button>
</div>
</div>
<div class="setting-group">
<label class="setting-label">{{ t('settings.version') }}</label>
<span class="setting-value">0.1.0</span>
</div>
</div>
@ -13,7 +29,10 @@
<script setup>
import { useSettingsStore } from '../store.js'
import { useI18n } from '../../../shared/i18n.js'
const store = useSettingsStore()
const { t } = useI18n()
</script>
<style scoped>
@ -38,6 +57,36 @@ const store = useSettingsStore()
letter-spacing: 0.05em;
}
.setting-toggle {
display: flex;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.setting-toggle button {
flex: 1;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
transition: all var(--transition);
}
.setting-toggle button:hover {
color: var(--text);
background: var(--bg-hover);
}
.setting-toggle button.active {
background: var(--accent-muted);
color: var(--accent);
}
.setting-input {
background: var(--bg-elevated);
border: 1px solid var(--border);

View file

@ -1,7 +1,7 @@
<template>
<div class="history-page">
<div class="page-header">
<h1 class="page-title">Analysis History</h1>
<h1 class="page-title">{{ t('history.title') }}</h1>
</div>
<div class="page-content">
<HistoryList />
@ -12,8 +12,10 @@
<script setup>
import { onMounted } from 'vue'
import { HistoryList, useHistoryStore } from '../features/history/index.js'
import { useI18n } from '../shared/i18n.js'
const store = useHistoryStore()
const { t } = useI18n()
onMounted(() => store.load())
</script>

View file

@ -1,7 +1,7 @@
<template>
<div class="settings-page">
<div class="page-header">
<h1 class="page-title">Settings</h1>
<h1 class="page-title">{{ t('settings.title') }}</h1>
</div>
<div class="page-content">
<SettingsPanel />
@ -11,6 +11,9 @@
<script setup>
import { SettingsPanel } from '../features/settings/index.js'
import { useI18n } from '../shared/i18n.js'
const { t } = useI18n()
</script>
<style scoped>

View file

@ -5,11 +5,11 @@
<div class="import-logo">
<span class="logo-icon">D</span>
</div>
<h1 class="import-title">Intelligence des documents</h1>
<p class="import-subtitle">Importez un document PDF pour commencer l'analyse avec Docling</p>
<h1 class="import-title">{{ t('studio.title') }}</h1>
<p class="import-subtitle">{{ t('studio.subtitle') }}</p>
<DocumentUpload />
<div class="import-docs" v-if="documentStore.documents.length">
<label class="section-label">Documents r&eacute;cents</label>
<label class="section-label">{{ t('studio.recentDocs') }}</label>
<DocumentList />
</div>
</div>
@ -20,25 +20,25 @@
<!-- Top bar -->
<div class="studio-topbar">
<div class="topbar-left">
<h1 class="topbar-title">Intelligence des documents</h1>
<h1 class="topbar-title">{{ t('studio.title') }}</h1>
<div class="mode-toggle">
<button
class="toggle-btn"
:class="{ active: mode === 'configurer' }"
@click="mode = 'configurer'"
>Configurer</button>
>{{ t('studio.configure') }}</button>
<button
class="toggle-btn"
:class="{ active: mode === 'verifier' }"
@click="mode = 'verifier'"
:disabled="!analysisStore.currentAnalysis"
>V&eacute;rifier</button>
>{{ t('studio.verify') }}</button>
</div>
</div>
<div class="topbar-actions">
<button class="topbar-btn" @click="addMore">
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/></svg>
Ajouter des fichiers
{{ t('studio.addFiles') }}
</button>
<button
class="topbar-btn primary"
@ -48,7 +48,7 @@
>
<div v-if="analysisStore.running" class="spinner-sm" />
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg>
{{ analysisStore.running ? 'Analyse...' : 'Ex\u00e9cuter' }}
{{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }}
</button>
</div>
</div>
@ -58,7 +58,7 @@
<div class="doc-info-left">
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>
<span class="doc-filename">{{ selectedDoc.filename }}</span>
<span class="doc-status-chip loaded">Charg&eacute;</span>
<span class="doc-status-chip loaded">{{ t('studio.loaded') }}</span>
</div>
<div class="doc-info-right" v-if="analysisStore.currentAnalysis">
<span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'COMPLETED'">
@ -67,11 +67,11 @@
</span>
<span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'RUNNING'">
<div class="spinner-xs" />
Analyse en cours...
{{ t('studio.analysisRunning') }}
</span>
<span class="info-badge error" v-if="analysisStore.currentAnalysis.status === 'FAILED'">
<span class="info-dot error" />
&Eacute;chec
{{ t('studio.failed') }}
</span>
</div>
</div>
@ -108,7 +108,7 @@
@click="visualMode = !visualMode"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
Visuel
{{ t('studio.visual') }}
</button>
</template>
</div>
@ -138,7 +138,7 @@
<div v-if="mode === 'configurer'" class="config-panel">
<div class="config-section">
<label class="config-label">
Mod&egrave;le
{{ t('config.model') }}
<span class="config-hint">?</span>
</label>
<div class="config-select-display">
@ -149,26 +149,26 @@
<div class="config-section">
<label class="config-label">
Pages
{{ t('config.pages') }}
<span class="config-hint">?</span>
</label>
<input type="text" class="config-input" placeholder='par ex. "1-4,8"' v-model="pageRange" />
<input type="text" class="config-input" :placeholder="t('config.pagesPlaceholder')" v-model="pageRange" />
</div>
<div class="config-section">
<label class="config-label">
Extraire les tableaux
{{ t('config.extractTables') }}
<span class="config-hint">?</span>
</label>
<select class="config-select" v-model="tableMode">
<option value="markdown">Markdown int&eacute;gr&eacute;</option>
<option value="markdown">{{ t('config.markdownIntegrated') }}</option>
<option value="html">HTML</option>
<option value="csv">CSV</option>
</select>
</div>
<div class="config-section">
<label class="config-label">Extraire</label>
<label class="config-label">{{ t('config.extract') }}</label>
<div class="extract-options">
<button
v-for="opt in extractOptions"
@ -187,15 +187,15 @@
<div class="config-section">
<label class="config-label">
Annoter les images
{{ t('config.annotateImages') }}
<span class="config-hint">?</span>
</label>
<button class="config-add-btn">Ajouter +</button>
<button class="config-add-btn">{{ t('config.add') }}</button>
</div>
<!-- Documents list at bottom -->
<div class="config-section config-docs">
<label class="config-label">Documents</label>
<label class="config-label">{{ t('config.documents') }}</label>
<DocumentList />
</div>
</div>
@ -217,9 +217,11 @@ import { DocumentUpload, DocumentList } from '../features/document/index.js'
import { ResultTabs } from '../features/analysis/index.js'
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
import { getPreviewUrl } from '../features/document/api.js'
import { useI18n } from '../shared/i18n.js'
const documentStore = useDocumentStore()
const analysisStore = useAnalysisStore()
const { t } = useI18n()
const mode = ref('configurer')
const currentPage = ref(1)
@ -242,11 +244,11 @@ function onPdfImageLoad() {
nextTick(() => bboxOverlayRef.value?.draw())
}
const extractOptions = [
{ id: 'images', label: 'Images', icon: 'image' },
{ id: 'header', label: 'En-t\u00eate', icon: 'header' },
{ id: 'footer', label: 'Pied de page', icon: 'footer' }
]
const extractOptions = computed(() => [
{ id: 'images', label: t('config.images'), icon: 'image' },
{ id: 'header', label: t('config.header'), icon: 'header' },
{ id: 'footer', label: t('config.footer'), icon: 'footer' }
])
const activeExtracts = reactive(new Set(['images']))
function toggleExtract(id) {

140
frontend/src/shared/i18n.js Normal file
View file

@ -0,0 +1,140 @@
import { useSettingsStore } from '../features/settings/store.js'
const messages = {
fr: {
// Sidebar
'nav.studio': 'Studio',
'nav.history': 'Historique',
'nav.settings': 'Paramètres',
// Studio — import
'studio.title': 'Intelligence des documents',
'studio.subtitle': 'Importez un document PDF pour commencer l\'analyse avec Docling',
'studio.recentDocs': 'Documents récents',
// Studio — workspace
'studio.configure': 'Configurer',
'studio.verify': 'Vérifier',
'studio.addFiles': 'Ajouter des fichiers',
'studio.analyzing': 'Analyse...',
'studio.run': 'Exécuter',
'studio.loaded': 'Chargé',
'studio.analysisRunning': 'Analyse en cours...',
'studio.failed': 'Échec',
'studio.visual': 'Visuel',
// Config panel
'config.model': 'Modèle',
'config.pages': 'Pages',
'config.pagesPlaceholder': 'par ex. "1-4,8"',
'config.extractTables': 'Extraire les tableaux',
'config.markdownIntegrated': 'Markdown intégré',
'config.extract': 'Extraire',
'config.images': 'Images',
'config.header': 'En-tête',
'config.footer': 'Pied de page',
'config.annotateImages': 'Annoter les images',
'config.add': 'Ajouter +',
'config.documents': 'Documents',
// Results
'results.textResult': 'Résultat du texte',
'results.markdown': 'Markdown',
'results.images': 'Images',
'results.pageOf': 'Page {current} sur {total}',
'results.noImages': 'Aucune image détectée dans ce document',
'results.noMarkdown': 'Pas de contenu markdown',
'results.runAnalysis': 'Lancez une analyse pour voir les résultats',
'results.analysisFailed': 'L\'analyse a échoué',
'results.page': 'Page',
// Upload
'upload.drop': 'Déposez un PDF ici ou cliquez pour importer',
'upload.uploading': 'Import en cours...',
'upload.maxSize': 'Max 50Mo',
// History
'history.title': 'Historique des analyses',
'history.empty': 'Aucune analyse. Allez dans Studio pour analyser votre premier document.',
// Settings
'settings.title': 'Paramètres',
'settings.apiUrl': 'API URL',
'settings.version': 'Version',
'settings.theme': 'Thème',
'settings.themeDark': 'Sombre',
'settings.themeLight': 'Clair',
'settings.language': 'Langue',
},
en: {
'nav.studio': 'Studio',
'nav.history': 'History',
'nav.settings': 'Settings',
'studio.title': 'Document Intelligence',
'studio.subtitle': 'Upload a PDF document to start analyzing with Docling',
'studio.recentDocs': 'Recent documents',
'studio.configure': 'Configure',
'studio.verify': 'Verify',
'studio.addFiles': 'Add files',
'studio.analyzing': 'Analyzing...',
'studio.run': 'Run',
'studio.loaded': 'Loaded',
'studio.analysisRunning': 'Analysis running...',
'studio.failed': 'Failed',
'studio.visual': 'Visual',
'config.model': 'Model',
'config.pages': 'Pages',
'config.pagesPlaceholder': 'e.g. "1-4,8"',
'config.extractTables': 'Extract tables',
'config.markdownIntegrated': 'Inline Markdown',
'config.extract': 'Extract',
'config.images': 'Images',
'config.header': 'Header',
'config.footer': 'Footer',
'config.annotateImages': 'Annotate images',
'config.add': 'Add +',
'config.documents': 'Documents',
'results.textResult': 'Text result',
'results.markdown': 'Markdown',
'results.images': 'Images',
'results.pageOf': 'Page {current} of {total}',
'results.noImages': 'No images detected in this document',
'results.noMarkdown': 'No markdown content',
'results.runAnalysis': 'Run an analysis to see results',
'results.analysisFailed': 'Analysis failed',
'results.page': 'Page',
'upload.drop': 'Drop a PDF here or click to upload',
'upload.uploading': 'Uploading...',
'upload.maxSize': 'Max 50MB',
'history.title': 'Analysis History',
'history.empty': 'No analyses yet. Go to Studio to analyze your first document.',
'settings.title': 'Settings',
'settings.apiUrl': 'API URL',
'settings.version': 'Version',
'settings.theme': 'Theme',
'settings.themeDark': 'Dark',
'settings.themeLight': 'Light',
'settings.language': 'Language',
}
}
export function useI18n() {
const settings = useSettingsStore()
function t(key, params = {}) {
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
for (const [k, v] of Object.entries(params)) {
str = str.replace(`{${k}}`, v)
}
return str
}
return { t }
}

View file

@ -10,17 +10,17 @@
<nav class="sidebar-nav">
<RouterLink to="/" class="nav-item" :class="{ active: route.name === 'studio' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"/></svg>
<span>Studio</span>
<span>{{ t('nav.studio') }}</span>
</RouterLink>
<RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
<span>History</span>
<span>{{ t('nav.history') }}</span>
</RouterLink>
<RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>
<span>Settings</span>
<span>{{ t('nav.settings') }}</span>
</RouterLink>
</nav>
@ -32,7 +32,10 @@
<script setup>
import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from '../i18n.js'
const route = useRoute()
const { t } = useI18n()
</script>
<style scoped>