Format all frontend files with Prettier

Batch reformat to eliminate 23 files with inconsistent formatting
that were accumulating across feature branches.
This commit is contained in:
Pier-Jean Malandrino 2026-04-03 15:50:32 +02:00
parent 1c82a80389
commit 226ae5994e
23 changed files with 741 additions and 288 deletions

View file

@ -1,9 +1,17 @@
<template>
<div class="app-layout">
<header class="topbar">
<button class="burger-btn" @click="sidebarOpen = !sidebarOpen" :title="sidebarOpen ? t('nav.collapse') : t('nav.expand')">
<button
class="burger-btn"
@click="sidebarOpen = !sidebarOpen"
:title="sidebarOpen ? t('nav.collapse') : t('nav.expand')"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="burger-icon">
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
<path
fill-rule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd"
/>
</svg>
</button>
<div class="topbar-logo">
@ -12,7 +20,11 @@
</div>
<div class="topbar-spacer" />
<button class="new-analysis-btn" @click="newAnalysis">
<svg viewBox="0 0 20 20" fill="currentColor" class="new-analysis-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>
<svg viewBox="0 0 20 20" fill="currentColor" class="new-analysis-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>
{{ t('topbar.newAnalysis') }}
</button>
</header>
@ -52,26 +64,26 @@ function newAnalysis() {
:root {
/* Dark mode palette - MistralAI Studio inspired */
--bg: #0A0A0B;
--bg: #0a0a0b;
--bg-surface: #111113;
--bg-elevated: #1A1A1D;
--bg-elevated: #1a1a1d;
--bg-hover: #222226;
--accent: #F97316;
--accent-hover: #FB923C;
--accent: #f97316;
--accent-hover: #fb923c;
--accent-muted: rgba(249, 115, 22, 0.15);
--text: #ECECEF;
--text-secondary: #A1A1AA;
--text-muted: #63636E;
--text: #ececef;
--text-secondary: #a1a1aa;
--text-muted: #63636e;
--border: #27272A;
--border-light: #3F3F46;
--border: #27272a;
--border-light: #3f3f46;
--success: #22C55E;
--error: #EF4444;
--warning: #EAB308;
--info: #3B82F6;
--success: #22c55e;
--error: #ef4444;
--warning: #eab308;
--info: #3b82f6;
--radius: 8px;
--radius-sm: 6px;
@ -83,32 +95,40 @@ function newAnalysis() {
}
html.light {
--bg: #FAFAFA;
--bg-surface: #FFFFFF;
--bg-elevated: #F4F4F5;
--bg-hover: #E4E4E7;
--bg: #fafafa;
--bg-surface: #ffffff;
--bg-elevated: #f4f4f5;
--bg-hover: #e4e4e7;
--accent: #F97316;
--accent-hover: #EA580C;
--accent-muted: rgba(249, 115, 22, 0.10);
--accent: #f97316;
--accent-hover: #ea580c;
--accent-muted: rgba(249, 115, 22, 0.1);
--text: #18181B;
--text-secondary: #52525B;
--text-muted: #A1A1AA;
--text: #18181b;
--text-secondary: #52525b;
--text-muted: #a1a1aa;
--border: #E4E4E7;
--border-light: #D4D4D8;
--border: #e4e4e7;
--border-light: #d4d4d8;
--success: #16A34A;
--error: #DC2626;
--warning: #CA8A04;
--info: #2563EB;
--success: #16a34a;
--error: #dc2626;
--warning: #ca8a04;
--info: #2563eb;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;

View file

@ -19,10 +19,7 @@ export function createAnalysis(
})
}
export function rechunkAnalysis(
jobId: string,
chunkingOptions: ChunkingOptions,
): Promise<Chunk[]> {
export function rechunkAnalysis(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/rechunk`, {
method: 'POST',
body: JSON.stringify({ chunkingOptions }),

View file

@ -19,9 +19,10 @@ describe('computeScale', () => {
})
it('sx equals sy when aspect ratio is preserved', () => {
const pageW = 612, pageH = 792
const pageW = 612,
pageH = 792
const displayW = 700
const displayH = displayW * pageH / pageW
const displayH = (displayW * pageH) / pageW
const s = computeScale(displayW, displayH, pageW, pageH)
expect(s.sx).toBeCloseTo(s.sy, 5)
})
@ -165,17 +166,17 @@ describe('pointInRect', () => {
})
it('returns true for point on edge', () => {
expect(pointInRect(10, 20, rect)).toBe(true) // top-left
expect(pointInRect(110, 80, rect)).toBe(true) // bottom-right
expect(pointInRect(10, 80, rect)).toBe(true) // bottom-left
expect(pointInRect(110, 20, rect)).toBe(true) // top-right
expect(pointInRect(10, 20, rect)).toBe(true) // top-left
expect(pointInRect(110, 80, rect)).toBe(true) // bottom-right
expect(pointInRect(10, 80, rect)).toBe(true) // bottom-left
expect(pointInRect(110, 20, rect)).toBe(true) // top-right
})
it('returns false for point outside', () => {
expect(pointInRect(5, 50, rect)).toBe(false) // left
expect(pointInRect(50, 15, rect)).toBe(false) // above
expect(pointInRect(115, 50, rect)).toBe(false) // right
expect(pointInRect(50, 85, rect)).toBe(false) // below
expect(pointInRect(5, 50, rect)).toBe(false) // left
expect(pointInRect(50, 15, rect)).toBe(false) // above
expect(pointInRect(115, 50, rect)).toBe(false) // right
expect(pointInRect(50, 85, rect)).toBe(false) // below
})
it('returns false for EMPTY_RECT (degenerate bbox not hoverable)', () => {
@ -203,8 +204,10 @@ describe('pointInRect', () => {
describe('integration: bbox pipeline', () => {
it('full A4 page: element is clickable at correct position', () => {
// Simulate: A4 page rendered at 700px wide
const pageW = 595.28, pageH = 841.89
const displayW = 700, displayH = 700 * pageH / pageW
const pageW = 595.28,
pageH = 841.89
const displayW = 700,
displayH = (700 * pageH) / pageW
const scale = computeScale(displayW, displayH, pageW, pageH)
// Element at center of page in PDF points
@ -212,8 +215,8 @@ describe('integration: bbox pipeline', () => {
const rect = bboxToRect(bbox, scale)
// Center of the element in display pixels
const cx = (200 + 400) / 2 * scale.sx
const cy = (350 + 500) / 2 * scale.sy
const cx = ((200 + 400) / 2) * scale.sx
const cy = ((350 + 500) / 2) * scale.sy
expect(pointInRect(cx, cy, rect)).toBe(true)
})

View file

@ -20,7 +20,6 @@ import * as api from './api'
// ---------------------------------------------------------------------------
describe('createAnalysis — pipeline options body construction', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset the real module to get the unmocked version
@ -107,7 +106,6 @@ describe('createAnalysis — pipeline options body construction', () => {
})
})
// ---------------------------------------------------------------------------
// Store → API integration — pipeline options forwarding
// ---------------------------------------------------------------------------

View file

@ -20,14 +20,14 @@
</div>
<div class="panel-actions" v-if="selectedDoc">
<button
class="btn-analyze"
:disabled="analysisStore.running"
@click="runAnalysis"
>
<button class="btn-analyze" :disabled="analysisStore.running" @click="runAnalysis">
<div v-if="analysisStore.running" class="spinner" />
<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"/>
<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 ? 'Analyzing...' : 'Analyze' }}
</button>
@ -55,7 +55,7 @@ const analysisStore = useAnalysisStore()
const currentPage = ref(1)
const selectedDoc = computed(() => {
return documentStore.documents.find(d => d.id === documentStore.selectedId)
return documentStore.documents.find((d) => d.id === documentStore.selectedId)
})
const statusClass = computed(() => {
@ -64,7 +64,7 @@ const statusClass = computed(() => {
'status-pending': status === 'PENDING',
'status-running': status === 'RUNNING',
'status-completed': status === 'COMPLETED',
'status-failed': status === 'FAILED'
'status-failed': status === 'FAILED',
}
})
@ -115,20 +115,32 @@ async function runAnalysis() {
transition: background var(--transition);
}
.btn-analyze:hover:not(:disabled) { background: var(--accent-hover); }
.btn-analyze:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-icon { width: 18px; height: 18px; }
.btn-analyze:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-analyze:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-icon {
width: 18px;
height: 18px;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255,255,255,0.3);
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-row {
display: flex;
@ -143,8 +155,20 @@ async function runAnalysis() {
border-radius: 12px;
}
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); }
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); }
.status-completed { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
.status-pending {
background: rgba(234, 179, 8, 0.15);
color: var(--warning);
}
.status-running {
background: rgba(59, 130, 246, 0.15);
color: var(--info);
}
.status-completed {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.status-failed {
background: rgba(239, 68, 68, 0.15);
color: var(--error);
}
</style>

View file

@ -20,14 +20,16 @@
ref="canvasRef"
class="overlay-canvas"
@mousemove="onMouseMove"
@mouseleave="hoveredElement = null; emit('highlight-element', -1)"
@mouseleave="
hoveredElement = null
emit('highlight-element', -1)
"
/>
<div
v-if="hoveredElement"
class="tooltip"
:style="tooltipStyle"
>
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
<span
class="tooltip-type"
:style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }"
>
{{ hoveredElement.type }}
</span>
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
@ -49,7 +51,7 @@ const ELEMENT_COLORS: Record<string, string> = {
list: '#06B6D4',
formula: '#EC4899',
code: '#14B8A6',
caption: '#EAB308'
caption: '#EAB308',
}
const props = withDefaults(
@ -116,7 +118,12 @@ function draw(): void {
if (!props.pageData) return
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
const scale = computeScale(
img.clientWidth,
img.clientHeight,
props.pageData.width,
props.pageData.height,
)
const hasHighlight = props.highlightedIndex >= 0
const hasChunkHighlight = props.highlightedBboxes.length > 0
@ -160,7 +167,12 @@ function onMouseMove(e: MouseEvent): void {
const mx = e.clientX - canvasRect.left
const my = e.clientY - canvasRect.top
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
const scale = computeScale(
img.clientWidth,
img.clientHeight,
props.pageData.width,
props.pageData.height,
)
let found: PageElement | null = null
let foundIdx = -1
@ -177,7 +189,7 @@ function onMouseMove(e: MouseEvent): void {
if (found) {
tooltipStyle.value = {
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
top: `${my + 12}px`
top: `${my + 12}px`,
}
}
}
@ -194,9 +206,18 @@ onBeforeUnmount(() => {
})
// Redraw when data or image changes
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, () => props.highlightedBboxes, hiddenTypes], () => {
nextTick(draw)
})
watch(
[
() => props.pageData,
() => props.imageEl,
() => props.highlightedIndex,
() => props.highlightedBboxes,
hiddenTypes,
],
() => {
nextTick(draw)
},
)
// Expose draw so parent can call it after image load
defineExpose({ draw })
@ -240,8 +261,13 @@ defineExpose({ draw })
opacity: 0.9;
}
.legend-chip:hover { opacity: 1; background: var(--bg-hover); }
.legend-chip.dimmed { opacity: 0.35; }
.legend-chip:hover {
opacity: 1;
background: var(--bg-hover);
}
.legend-chip.dimmed {
opacity: 0.35;
}
.legend-dot {
width: 6px;
@ -273,7 +299,7 @@ defineExpose({ draw })
max-width: 250px;
pointer-events: none;
z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.tooltip-type {

View file

@ -4,11 +4,7 @@
{{ t('results.noImages') }}
</div>
<div v-else class="gallery-grid">
<div
v-for="(img, idx) in images"
:key="idx"
class="gallery-card"
>
<div v-for="(img, idx) in images" :key="idx" class="gallery-card">
<div class="card-header">
<span class="card-type">Picture</span>
<span class="card-page">{{ t('results.page') }} {{ img.page }}</span>
@ -18,7 +14,7 @@
</div>
<div class="card-bbox">
<span class="bbox-label">bbox:</span>
<span class="bbox-value">{{ img.bbox.map(v => Math.round(v)).join(', ') }}</span>
<span class="bbox-value">{{ img.bbox.map((v) => Math.round(v)).join(', ') }}</span>
</div>
</div>
</div>
@ -33,13 +29,13 @@ import type { Page, PageElement } from '../../../shared/types'
const { t } = useI18n()
const props = defineProps({
pages: { type: Array as () => Page[], default: () => [] }
pages: { type: Array as () => Page[], default: () => [] },
})
const images = computed(() => {
const result: (PageElement & { page: number })[] = []
for (const page of props.pages) {
for (const el of (page.elements || [])) {
for (const el of page.elements || []) {
if (el.type === 'picture') {
result.push({ ...el, page: page.page_number })
}
@ -109,5 +105,7 @@ const images = computed(() => {
color: var(--text-muted);
}
.bbox-label { margin-right: 4px; }
.bbox-label {
margin-right: 4px;
}
</style>

View file

@ -33,11 +33,19 @@ const rendered = computed(() => {
font-weight: 600;
}
.markdown-viewer :deep(h1) { font-size: 24px; }
.markdown-viewer :deep(h2) { font-size: 20px; }
.markdown-viewer :deep(h3) { font-size: 16px; }
.markdown-viewer :deep(h1) {
font-size: 24px;
}
.markdown-viewer :deep(h2) {
font-size: 20px;
}
.markdown-viewer :deep(h3) {
font-size: 16px;
}
.markdown-viewer :deep(p) { margin: 8px 0; }
.markdown-viewer :deep(p) {
margin: 8px 0;
}
.markdown-viewer :deep(code) {
background: var(--bg-elevated);

View file

@ -14,7 +14,9 @@
<!-- Page chip -->
<div class="page-indicator" v-if="totalPages > 0">
<span class="page-chip">{{ t('results.pageOf', { current: currentPage, total: totalPages }) }}</span>
<span class="page-chip">{{
t('results.pageOf', { current: currentPage, total: totalPages })
}}</span>
</div>
<div class="tab-content">
@ -32,7 +34,10 @@
@mouseleave="$emit('highlight-element', -1)"
>
<div class="element-header">
<span class="element-type" :style="{ color: ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text }">
<span
class="element-type"
:style="{ color: ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text }"
>
{{ el.type }}
</span>
<span class="element-level" v-if="el.level">L{{ el.level }}</span>
@ -42,12 +47,23 @@
:title="t('results.copy')"
@click.stop="copyElement(idx, el.content)"
>
<svg v-if="!copiedElements[idx]" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/>
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/>
<svg
v-if="!copiedElements[idx]"
viewBox="0 0 20 20"
fill="currentColor"
class="copy-icon"
>
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
<path
d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"
/>
</svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
@ -57,7 +73,7 @@
<span v-else>{{ el.content }}</span>
</div>
<div class="element-bbox">
{{ el.bbox.map(v => Math.round(v)).join(', ') }}
{{ el.bbox.map((v) => Math.round(v)).join(', ') }}
</div>
</div>
</div>
@ -66,11 +82,17 @@
<div v-else-if="activeTab === 'markdown'" class="raw-markdown">
<button class="copy-btn copy-btn-block" :title="t('results.copy')" @click="copyMarkdown">
<svg v-if="!copiedMarkdown" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/>
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/>
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
<path
d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"
/>
</svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</button>
<pre class="raw-content">{{ pageMarkdown }}</pre>
@ -85,12 +107,26 @@
<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>
<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 || 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
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>{{ t('results.runAnalysis') }}</span>
</div>
@ -113,12 +149,12 @@ const ELEMENT_COLORS: Record<string, string> = {
list: '#06B6D4',
formula: '#EC4899',
code: '#14B8A6',
caption: '#EAB308'
caption: '#EAB308',
}
const props = defineProps({
currentPage: { type: Number, default: 1 },
highlightedIndex: { type: Number, default: -1 }
highlightedIndex: { type: Number, default: -1 },
})
defineEmits(['highlight-element'])
@ -130,17 +166,17 @@ const activeTab = ref('elements')
const tabs = computed(() => [
{ id: 'elements', label: t('results.elements') },
{ id: 'markdown', label: t('results.markdown') },
{ id: 'images', label: t('results.images') }
{ id: 'images', label: t('results.images') },
])
const totalPages = computed(() => store.currentPages.length)
const currentPageData = computed(() => {
return store.currentPages.find(p => p.page_number === props.currentPage) || null
return store.currentPages.find((p) => p.page_number === props.currentPage) || null
})
const currentElements = computed(() => {
return (currentPageData.value?.elements || []).filter(el => el.content)
return (currentPageData.value?.elements || []).filter((el) => el.content)
})
const currentPageAsArray = computed(() => {
@ -153,7 +189,7 @@ const pageMarkdown = computed(() => {
if (!page) return ''
return (page.elements || [])
.map(el => formatElement(el))
.map((el) => formatElement(el))
.filter(Boolean)
.join('\n\n')
})
@ -191,16 +227,24 @@ async function copyMarkdown() {
try {
await navigator.clipboard.writeText(pageMarkdown.value)
copiedMarkdown.value = true
setTimeout(() => { copiedMarkdown.value = false }, 1500)
} catch { /* clipboard not available */ }
setTimeout(() => {
copiedMarkdown.value = false
}, 1500)
} catch {
/* clipboard not available */
}
}
async function copyElement(idx: number, content: string) {
try {
await navigator.clipboard.writeText(content)
copiedElements[idx] = true
setTimeout(() => { copiedElements[idx] = false }, 1500)
} catch { /* clipboard not available */ }
setTimeout(() => {
copiedElements[idx] = false
}, 1500)
} catch {
/* clipboard not available */
}
}
</script>
@ -234,7 +278,9 @@ async function copyElement(idx: number, content: string) {
white-space: nowrap;
}
.tab-btn:hover { color: var(--text-secondary); }
.tab-btn:hover {
color: var(--text-secondary);
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
@ -368,8 +414,13 @@ async function copyElement(idx: number, content: string) {
background: var(--accent-muted);
}
.copy-icon { width: 14px; height: 14px; }
.copy-icon.copied { color: var(--success); }
.copy-icon {
width: 14px;
height: 14px;
}
.copy-icon.copied {
color: var(--success);
}
.copy-btn-element {
margin-left: auto;
@ -377,7 +428,9 @@ async function copyElement(idx: number, content: string) {
transition: opacity var(--transition);
}
.element-card:hover .copy-btn-element { opacity: 1; }
.element-card:hover .copy-btn-element {
opacity: 1;
}
.copy-btn-block {
position: absolute;
@ -389,7 +442,9 @@ async function copyElement(idx: number, content: string) {
transition: opacity var(--transition);
}
.raw-markdown:hover .copy-btn-block { opacity: 1; }
.raw-markdown:hover .copy-btn-block {
opacity: 1;
}
/* --- Raw markdown --- */
.raw-markdown {
@ -423,9 +478,18 @@ async function copyElement(idx: number, content: string) {
font-size: 14px;
}
.result-placeholder.error { color: var(--error); }
.error-icon { width: 32px; height: 32px; }
.empty-icon { width: 48px; height: 48px; color: var(--border-light); }
.result-placeholder.error {
color: var(--error);
}
.error-icon {
width: 32px;
height: 32px;
}
.empty-icon {
width: 48px;
height: 48px;
color: var(--border-light);
}
.spinner-large {
width: 32px;
@ -436,5 +500,9 @@ async function copyElement(idx: number, content: string) {
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -44,12 +44,11 @@
@mouseleave="hoveredElement = null"
/>
<!-- Tooltip -->
<div
v-if="hoveredElement"
class="tooltip"
:style="tooltipStyle"
>
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
<span
class="tooltip-type"
:style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }"
>
{{ hoveredElement.type }}
</span>
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
@ -71,12 +70,12 @@ const ELEMENT_COLORS: Record<string, string> = {
picture: '#22C55E',
list: '#06B6D4',
formula: '#EC4899',
caption: '#EAB308'
caption: '#EAB308',
}
const props = defineProps({
pages: { type: Array as () => Page[], default: () => [] },
documentId: String
documentId: String,
})
const selectedPage = ref(1)
@ -89,12 +88,12 @@ const tooltipStyle = ref<Record<string, string>>({})
const imageSize = ref({ width: 0, height: 0 })
const currentPageData = computed(() => {
return props.pages.find(p => p.page_number === selectedPage.value)
return props.pages.find((p) => p.page_number === selectedPage.value)
})
const visibleElements = computed(() => {
if (!currentPageData.value) return []
return currentPageData.value.elements.filter(e => !hiddenTypes.has(e.type))
return currentPageData.value.elements.filter((e) => !hiddenTypes.has(e.type))
})
const previewUrl = computed(() => {
@ -110,7 +109,7 @@ function toggleType(type: string) {
function countElements(type: string) {
if (!currentPageData.value) return 0
return currentPageData.value.elements.filter(e => e.type === type).length
return currentPageData.value.elements.filter((e) => e.type === type).length
}
function onImageLoad() {
@ -174,7 +173,7 @@ function onMouseMove(e: MouseEvent) {
if (found) {
tooltipStyle.value = {
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
top: `${my + 12}px`
top: `${my + 12}px`,
}
}
}
@ -213,7 +212,9 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
transition: all var(--transition);
}
.page-btn:hover { background: var(--bg-hover); }
.page-btn:hover {
background: var(--bg-hover);
}
.page-btn.active {
background: var(--accent-muted);
border-color: var(--accent);
@ -240,8 +241,12 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
transition: all var(--transition);
}
.legend-item:hover { background: var(--bg-hover); }
.legend-item.dimmed { opacity: 0.4; }
.legend-item:hover {
background: var(--bg-hover);
}
.legend-item.dimmed {
opacity: 0.4;
}
.legend-dot {
width: 8px;
@ -286,7 +291,7 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
max-width: 250px;
pointer-events: none;
z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.tooltip-type {

View file

@ -68,9 +68,7 @@
<!-- Chunks list -->
<div class="chunk-results" v-if="pageChunks.length">
<div class="chunk-summary">
{{ pagination.totalItems.value }} {{ t('chunking.chunks') }}
</div>
<div class="chunk-summary">{{ pagination.totalItems.value }} {{ t('chunking.chunks') }}</div>
<div class="chunk-list">
<div
class="chunk-card"
@ -85,9 +83,7 @@
<span class="chunk-tokens" v-if="chunk.tokenCount">
{{ chunk.tokenCount }} tokens
</span>
<span class="chunk-page" v-if="chunk.sourcePage">
p.{{ chunk.sourcePage }}
</span>
<span class="chunk-page" v-if="chunk.sourcePage"> p.{{ chunk.sourcePage }} </span>
</div>
<div class="chunk-headings" v-if="chunk.headings.length">
<span class="chunk-heading" v-for="h in chunk.headings" :key="h">{{ h }}</span>
@ -100,9 +96,7 @@
<div class="chunk-empty" v-else-if="!analysisStore.rechunking">
<p>
{{
analysisStore.currentChunks.length
? t('chunking.noChunksOnPage')
: t('chunking.noChunks')
analysisStore.currentChunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks')
}}
</p>
</div>
@ -356,15 +350,17 @@ async function doRechunk() {
border-radius: var(--radius);
padding: 10px 12px;
cursor: default;
transition: border-color 0.15s, background 0.15s;
transition:
border-color 0.15s,
background 0.15s;
}
.chunk-card:hover {
border-color: #F59E0B;
border-color: #f59e0b;
}
.chunk-card.highlighted {
border-color: #F59E0B;
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.08);
}

View file

@ -24,7 +24,10 @@ describe('useDocumentStore', () => {
})
it('load() fetches and sets documents', async () => {
const docs = [{ id: '1', filename: 'a.pdf' }, { id: '2', filename: 'b.pdf' }]
const docs = [
{ id: '1', filename: 'a.pdf' },
{ id: '2', filename: 'b.pdf' },
]
api.fetchDocuments.mockResolvedValue(docs)
const store = useDocumentStore()
@ -61,7 +64,12 @@ describe('useDocumentStore', () => {
it('upload() sets uploading to true during upload', async () => {
let resolveUpload
api.uploadDocument.mockImplementation(() => new Promise(r => { resolveUpload = r }))
api.uploadDocument.mockImplementation(
() =>
new Promise((r) => {
resolveUpload = r
}),
)
const store = useDocumentStore()
const promise = store.upload(new File([], 'test.pdf'))

View file

@ -9,15 +9,28 @@
>
<div class="doc-info">
<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"/>
<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>
<div class="doc-meta">
<span class="doc-name">{{ doc.filename }}</span>
<span class="doc-size">{{ formatSize(doc.fileSize) }}{{ doc.pageCount ? `${doc.pageCount} pages` : '' }}</span>
<span class="doc-size"
>{{ formatSize(doc.fileSize)
}}{{ doc.pageCount ? `${doc.pageCount} pages` : '' }}</span
>
</div>
</div>
<button class="doc-delete" @click.stop="store.remove(doc.id)" title="Delete">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
@ -101,7 +114,15 @@ const store = useDocumentStore()
transition: all var(--transition);
}
.doc-item:hover .doc-delete { opacity: 1; }
.doc-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
.doc-delete svg { width: 14px; height: 14px; }
.doc-item:hover .doc-delete {
opacity: 1;
}
.doc-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.doc-delete svg {
width: 14px;
height: 14px;
}
</style>

View file

@ -13,7 +13,13 @@
<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">
<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">{{ t('upload.drop') }}</span>
@ -42,10 +48,7 @@ function openFilePicker() {
async function onFileSelect(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (
file &&
(file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))
) {
if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
try {
store.clearError()
const doc = await store.upload(file)
@ -133,6 +136,8 @@ async function onDrop(e: DragEvent) {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -4,10 +4,26 @@
<span class="preview-label">Page {{ page }}</span>
<div class="preview-nav">
<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>
<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)">
<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
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>
</div>
@ -30,7 +46,7 @@ import { getPreviewUrl } from '../api'
const props = defineProps({
documentId: String,
page: { type: Number, default: 1 },
pageCount: Number
pageCount: Number,
})
defineEmits(['update:page', 'imageLoaded'])
@ -76,9 +92,18 @@ const previewUrl = computed(() => {
transition: all var(--transition);
}
.nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
.nav-btn:disabled { opacity: 0.3; cursor: default; }
.nav-btn svg { width: 16px; height: 16px; }
.nav-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
}
.nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
.nav-btn svg {
width: 16px;
height: 16px;
}
.preview-image-wrapper {
background: var(--bg-elevated);

View file

@ -18,7 +18,9 @@
class="item-status status-completed"
:title="analysis.status"
>
<svg class="status-dot" viewBox="0 0 8 8" fill="currentColor"><circle cx="4" cy="4" r="4"/></svg>
<svg class="status-dot" viewBox="0 0 8 8" fill="currentColor">
<circle cx="4" cy="4" r="4" />
</svg>
</span>
<span v-else class="item-status" :class="statusClass(analysis.status)">
{{ analysis.status }}
@ -30,7 +32,13 @@
</div>
</div>
<button class="item-delete" @click.stop="store.remove(analysis.id)" title="Delete">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
@ -56,7 +64,7 @@ function statusClass(status: string) {
'status-pending': status === 'PENDING',
'status-running': status === 'RUNNING',
'status-completed': status === 'COMPLETED',
'status-failed': status === 'FAILED'
'status-failed': status === 'FAILED',
}
}
@ -109,7 +117,10 @@ function duration(analysis: Analysis) {
background: var(--bg-elevated);
}
.item-main { flex: 1; min-width: 0; }
.item-main {
flex: 1;
min-width: 0;
}
.item-header {
display: flex;
@ -140,10 +151,23 @@ function duration(analysis: Analysis) {
height: 8px;
}
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); }
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); }
.status-completed { background: none; color: var(--success); padding: 0; }
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
.status-pending {
background: rgba(234, 179, 8, 0.15);
color: var(--warning);
}
.status-running {
background: rgba(59, 130, 246, 0.15);
color: var(--info);
}
.status-completed {
background: none;
color: var(--success);
padding: 0;
}
.status-failed {
background: rgba(239, 68, 68, 0.15);
color: var(--error);
}
.item-meta {
font-size: 12px;
@ -164,7 +188,15 @@ function duration(analysis: Analysis) {
transition: all var(--transition);
}
.history-item:hover .item-delete { opacity: 1; }
.item-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
.item-delete svg { width: 16px; height: 16px; }
.history-item:hover .item-delete {
opacity: 1;
}
.item-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.item-delete svg {
width: 16px;
height: 16px;
}
</style>

View file

@ -15,8 +15,12 @@
<div class="setting-group">
<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>
<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>

View file

@ -8,14 +8,14 @@
{{ t('history.emptyDocs') }}
</div>
<div v-else class="doc-items">
<div
v-for="doc in docStore.documents"
:key="doc.id"
class="doc-row"
>
<div v-for="doc in docStore.documents" :key="doc.id" class="doc-row">
<div class="doc-row-info">
<svg class="doc-row-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"/>
<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>
<div class="doc-row-meta">
<span class="doc-row-name">{{ doc.filename }}</span>
@ -27,7 +27,13 @@
</div>
</div>
<button class="doc-row-delete" @click="docStore.remove(doc.id)">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
@ -158,7 +164,15 @@ onMounted(() => {
transition: all var(--transition);
}
.doc-row:hover .doc-row-delete { opacity: 1; }
.doc-row-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
.doc-row-delete svg { width: 16px; height: 16px; }
.doc-row:hover .doc-row-delete {
opacity: 1;
}
.doc-row-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.doc-row-delete svg {
width: 16px;
height: 16px;
}
</style>

View file

@ -15,12 +15,24 @@
<!-- Stats -->
<div class="home-stats" v-if="docCount > 0 || analysisCount > 0">
<div class="stat-card" @click="$router.push('/documents')">
<svg class="stat-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>
<svg class="stat-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>
<div class="stat-value">{{ docCount }}</div>
<div class="stat-label">{{ t('home.documents') }}</div>
</div>
<div class="stat-card" @click="$router.push('/history')">
<svg class="stat-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>
<svg class="stat-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>
<div class="stat-value">{{ analysisCount }}</div>
<div class="stat-label">{{ t('home.analyses') }}</div>
</div>
@ -37,14 +49,22 @@
@click="openInStudio(doc)"
>
<svg class="recent-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"/>
<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>
<div class="recent-meta">
<span class="recent-name">{{ doc.filename }}</span>
<span class="recent-detail">{{ doc.pageCount ? doc.pageCount + ' pages' : '' }}</span>
</div>
<svg class="recent-arrow" 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"/>
<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>

View file

@ -25,7 +25,13 @@
:class="{ active: mode === 'configurer' }"
@click="mode = 'configurer'"
>
<svg class="toggle-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>
<svg class="toggle-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>
{{ t('studio.configure') }}
</button>
<button
@ -34,7 +40,13 @@
@click="mode = 'verifier'"
:disabled="!analysisStore.currentAnalysis"
>
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{{ t('studio.verify') }}
</button>
<button
@ -44,14 +56,22 @@
@click="mode = 'preparer'"
:disabled="!analysisStore.currentAnalysis"
>
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
{{ t('studio.prepare') }}
</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>
<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>
{{ t('studio.addFiles') }}
</button>
<button
@ -61,7 +81,13 @@
v-if="mode === 'configurer'"
>
<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>
<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 ? t('studio.analyzing') : t('studio.run') }}
</button>
</div>
@ -70,7 +96,13 @@
<!-- Document info bar -->
<div class="doc-infobar">
<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>
<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">{{ t('studio.loaded') }}</span>
</div>
@ -96,7 +128,13 @@
<div class="pdf-viewer-panel">
<div class="pdf-nav-bar">
<button class="pdf-nav-btn" :disabled="currentPage <= 1" @click="currentPage--">
<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>
<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>
<div class="pdf-page-input-wrap">
<input
@ -109,8 +147,18 @@
/>
</div>
<span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span>
<button class="pdf-nav-btn" :disabled="!selectedDoc.pageCount || currentPage >= selectedDoc.pageCount" @click="currentPage++">
<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
class="pdf-nav-btn"
:disabled="!selectedDoc.pageCount || currentPage >= selectedDoc.pageCount"
@click="currentPage++"
>
<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>
<span class="pdf-separator" />
<span class="pdf-zoom">100%</span>
@ -121,7 +169,14 @@
:class="{ active: visualMode }"
@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>
<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>
{{ t('studio.visual') }}
</button>
</template>
@ -150,10 +205,7 @@
</div>
<!-- Resize handle -->
<div
class="resize-handle"
@mousedown="onResizeStart"
>
<div class="resize-handle" @mousedown="onResizeStart">
<div class="resize-grip" />
</div>
@ -181,16 +233,26 @@
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.ocr') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.ocrHint') }}</span>?</span>
<span class="config-hint"
><span class="config-tooltip">{{ t('config.ocrHint') }}</span
>?</span
>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_table_structure" class="toggle-input" />
<input
type="checkbox"
v-model="pipelineOptions.do_table_structure"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.tableStructure') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span>?</span>
<span class="config-hint"
><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span
>?</span
>
</div>
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
@ -208,20 +270,34 @@
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_code_enrichment" class="toggle-input" />
<input
type="checkbox"
v-model="pipelineOptions.do_code_enrichment"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.codeEnrichmentHint') }}</span>?</span>
<span class="config-hint"
><span class="config-tooltip">{{ t('config.codeEnrichmentHint') }}</span
>?</span
>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_formula_enrichment" class="toggle-input" />
<input
type="checkbox"
v-model="pipelineOptions.do_formula_enrichment"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.formulaEnrichmentHint') }}</span>?</span>
<span class="config-hint"
><span class="config-tooltip">{{ t('config.formulaEnrichmentHint') }}</span
>?</span
>
</div>
</div>
@ -231,41 +307,72 @@
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_picture_classification" class="toggle-input" />
<input
type="checkbox"
v-model="pipelineOptions.do_picture_classification"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.pictureClassification') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureClassificationHint') }}</span>?</span>
<span class="config-hint"
><span class="config-tooltip">{{ t('config.pictureClassificationHint') }}</span
>?</span
>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_picture_description" class="toggle-input" />
<input
type="checkbox"
v-model="pipelineOptions.do_picture_description"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.pictureDescription') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureDescriptionHint') }}</span>?</span>
<span class="config-hint"
><span class="config-tooltip">{{ t('config.pictureDescriptionHint') }}</span
>?</span
>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.generate_picture_images" class="toggle-input" />
<input
type="checkbox"
v-model="pipelineOptions.generate_picture_images"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePictureImagesHint') }}</span>?</span>
<span class="config-hint"
><span class="config-tooltip">{{ t('config.generatePictureImagesHint') }}</span
>?</span
>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.generate_page_images" class="toggle-input" />
<input
type="checkbox"
v-model="pipelineOptions.generate_page_images"
class="toggle-input"
/>
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.generatePageImages') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePageImagesHint') }}</span>?</span>
<span class="config-hint"
><span class="config-tooltip">{{ t('config.generatePageImagesHint') }}</span
>?</span
>
</div>
<div class="config-sub-option" v-if="pipelineOptions.generate_picture_images || pipelineOptions.generate_page_images">
<div
class="config-sub-option"
v-if="pipelineOptions.generate_picture_images || pipelineOptions.generate_page_images"
>
<label class="config-label-sm">{{ t('config.imagesScale') }}</label>
<select class="config-select" v-model.number="pipelineOptions.images_scale">
<option :value="0.5">0.5x</option>
@ -379,12 +486,14 @@ const pipelineOptions = reactive<PipelineOptions>({
})
const hasAnalysisResults = computed(() => {
return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
return (
analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
)
})
const currentPageData = computed(() => {
if (!analysisStore.currentPages) return null
return analysisStore.currentPages.find(p => p.page_number === currentPage.value) || null
return analysisStore.currentPages.find((p) => p.page_number === currentPage.value) || null
})
function onPdfImageLoad() {
@ -392,7 +501,7 @@ function onPdfImageLoad() {
}
const selectedDoc = computed(() => {
return documentStore.documents.find(d => d.id === documentStore.selectedId)
return documentStore.documents.find((d) => d.id === documentStore.selectedId)
})
const previewUrl = computed(() => {
@ -427,12 +536,15 @@ watch(currentPage, () => {
})
// Auto-switch to verifier when analysis completes + refresh document data (pageCount)
watch(() => analysisStore.currentAnalysis?.status, (status) => {
if (status === 'COMPLETED') {
mode.value = 'verifier'
documentStore.load()
}
})
watch(
() => analysisStore.currentAnalysis?.status,
(status) => {
if (status === 'COMPLETED') {
mode.value = 'verifier'
documentStore.load()
}
},
)
onMounted(async () => {
await documentStore.load()
@ -658,7 +770,7 @@ onBeforeUnmount(() => {
.spinner-sm {
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
@ -730,8 +842,12 @@ onBeforeUnmount(() => {
border-radius: 50%;
}
.info-dot.success { background: var(--success); }
.info-dot.error { background: var(--error); }
.info-dot.success {
background: var(--success);
}
.info-dot.error {
background: var(--error);
}
.spinner-xs {
width: 12px;
@ -830,9 +946,18 @@ onBeforeUnmount(() => {
transition: all var(--transition);
}
.pdf-nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
.pdf-nav-btn:disabled { opacity: 0.3; cursor: default; }
.pdf-nav-btn svg { width: 16px; height: 16px; }
.pdf-nav-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
}
.pdf-nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
.pdf-nav-btn svg {
width: 16px;
height: 16px;
}
.pdf-page-input-wrap {
display: flex;
@ -931,7 +1056,7 @@ onBeforeUnmount(() => {
max-width: 100%;
height: auto;
display: block;
box-shadow: 0 2px 20px rgba(0,0,0,0.4);
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.4);
border-radius: 2px;
}
@ -1178,5 +1303,9 @@ onBeforeUnmount(() => {
flex-direction: column;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -15,9 +15,12 @@ describe('apiFetch', () => {
await apiFetch('/api/test')
expect(spy).toHaveBeenCalledWith('/api/test', expect.objectContaining({
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
}))
expect(spy).toHaveBeenCalledWith(
'/api/test',
expect.objectContaining({
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
}),
)
})
it('skips Content-Type when skipContentType is true', async () => {
@ -73,10 +76,13 @@ describe('apiFetch', () => {
const body = JSON.stringify({ documentId: '42' })
await apiFetch('/api/analyses', { method: 'POST', body })
expect(spy).toHaveBeenCalledWith('/api/analyses', expect.objectContaining({
method: 'POST',
body,
}))
expect(spy).toHaveBeenCalledWith(
'/api/analyses',
expect.objectContaining({
method: 'POST',
body,
}),
)
})
it('merges custom headers with Content-Type', async () => {

View file

@ -20,7 +20,8 @@ const messages: Messages = {
// Home
'home.title': 'Docling Studio',
'home.subtitle': 'Analysez, explorez et validez la structure de vos documents PDF grâce à Docling.',
'home.subtitle':
'Analysez, explorez et validez la structure de vos documents PDF grâce à Docling.',
'home.documents': 'Documents',
'home.analyses': 'Analyses',
'home.recentDocs': 'Documents récents',
@ -45,26 +46,34 @@ const messages: Messages = {
'config.model': 'Modèle',
'config.pipeline': 'Pipeline',
'config.ocr': 'OCR',
'config.ocrHint': "Applique la reconnaissance optique de caractères sur les pages scannées ou les images intégrées. Indispensable pour les PDF non-natifs.",
'config.ocrHint':
'Applique la reconnaissance optique de caractères sur les pages scannées ou les images intégrées. Indispensable pour les PDF non-natifs.',
'config.tableStructure': 'Extraction des tableaux',
'config.tableStructureHint': "Détecte les tableaux dans le document et reconstruit leur structure lignes/colonnes via le modèle TableFormer, avec correspondance des cellules.",
'config.tableStructureHint':
'Détecte les tableaux dans le document et reconstruit leur structure lignes/colonnes via le modèle TableFormer, avec correspondance des cellules.',
'config.tableMode': 'Mode tableaux',
'config.tableModeAccurate': 'Précis',
'config.tableModeFast': 'Rapide',
'config.enrichment': 'Enrichissement',
'config.codeEnrichment': 'Code',
'config.codeEnrichmentHint': "Active un modèle OCR spécialisé pour les blocs de code, préservant l'indentation et la syntaxe.",
'config.codeEnrichmentHint':
"Active un modèle OCR spécialisé pour les blocs de code, préservant l'indentation et la syntaxe.",
'config.formulaEnrichment': 'Formules',
'config.formulaEnrichmentHint': "Reconnaît les formules mathématiques et les convertit en LaTeX via un modèle dédié.",
'config.formulaEnrichmentHint':
'Reconnaît les formules mathématiques et les convertit en LaTeX via un modèle dédié.',
'config.pictures': 'Images',
'config.pictureClassification': 'Classification',
'config.pictureClassificationHint': "Classe chaque image détectée par type (graphique, photo, diagramme, logo…) via un modèle de classification.",
'config.pictureClassificationHint':
'Classe chaque image détectée par type (graphique, photo, diagramme, logo…) via un modèle de classification.',
'config.pictureDescription': 'Description',
'config.pictureDescriptionHint': "Génère une description textuelle de chaque image via un Vision Language Model (VLM). Utile pour l'accessibilité et l'indexation.",
'config.pictureDescriptionHint':
"Génère une description textuelle de chaque image via un Vision Language Model (VLM). Utile pour l'accessibilité et l'indexation.",
'config.generatePictureImages': 'Extraire les images',
'config.generatePictureImagesHint': "Extrait les images détectées du document et les sauvegarde en tant que fichiers séparés. Nécessaire pour l'export d'images.",
'config.generatePictureImagesHint':
"Extrait les images détectées du document et les sauvegarde en tant que fichiers séparés. Nécessaire pour l'export d'images.",
'config.generatePageImages': 'Images de pages',
'config.generatePageImagesHint': "Rasterise chaque page du PDF en image. Utile pour la visualisation ou le post-traitement visuel.",
'config.generatePageImagesHint':
'Rasterise chaque page du PDF en image. Utile pour la visualisation ou le post-traitement visuel.',
'config.imagesScale': 'Échelle images',
'config.documents': 'Documents',
@ -133,7 +142,8 @@ const messages: Messages = {
'topbar.newAnalysis': 'New analysis',
'home.title': 'Docling Studio',
'home.subtitle': 'Analyze, explore and validate the structure of your PDF documents with Docling.',
'home.subtitle':
'Analyze, explore and validate the structure of your PDF documents with Docling.',
'home.documents': 'Documents',
'home.analyses': 'Analyses',
'home.recentDocs': 'Recent documents',
@ -155,26 +165,34 @@ const messages: Messages = {
'config.model': 'Model',
'config.pipeline': 'Pipeline',
'config.ocr': 'OCR',
'config.ocrHint': 'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.',
'config.ocrHint':
'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.',
'config.tableStructure': 'Table extraction',
'config.tableStructureHint': 'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.',
'config.tableStructureHint':
'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.',
'config.tableMode': 'Table mode',
'config.tableModeAccurate': 'Accurate',
'config.tableModeFast': 'Fast',
'config.enrichment': 'Enrichment',
'config.codeEnrichment': 'Code',
'config.codeEnrichmentHint': 'Activates a specialized OCR model for code blocks, preserving indentation and syntax.',
'config.codeEnrichmentHint':
'Activates a specialized OCR model for code blocks, preserving indentation and syntax.',
'config.formulaEnrichment': 'Formulas',
'config.formulaEnrichmentHint': 'Recognizes mathematical formulas and converts them to LaTeX using a dedicated model.',
'config.formulaEnrichmentHint':
'Recognizes mathematical formulas and converts them to LaTeX using a dedicated model.',
'config.pictures': 'Pictures',
'config.pictureClassification': 'Classification',
'config.pictureClassificationHint': 'Classifies each detected image by type (chart, photo, diagram, logo…) using a classification model.',
'config.pictureClassificationHint':
'Classifies each detected image by type (chart, photo, diagram, logo…) using a classification model.',
'config.pictureDescription': 'Description',
'config.pictureDescriptionHint': 'Generates a text description for each image using a Vision Language Model (VLM). Useful for accessibility and indexing.',
'config.pictureDescriptionHint':
'Generates a text description for each image using a Vision Language Model (VLM). Useful for accessibility and indexing.',
'config.generatePictureImages': 'Extract pictures',
'config.generatePictureImagesHint': 'Extracts detected images from the document and saves them as separate files. Required for image export.',
'config.generatePictureImagesHint':
'Extracts detected images from the document and saves them as separate files. Required for image export.',
'config.generatePageImages': 'Page images',
'config.generatePageImagesHint': 'Rasterizes each PDF page as an image. Useful for visual preview or post-processing.',
'config.generatePageImagesHint':
'Rasterizes each PDF page as an image. Useful for visual preview or post-processing.',
'config.imagesScale': 'Images scale',
'config.documents': 'Documents',

View file

@ -2,27 +2,53 @@
<aside class="sidebar" :class="{ open }">
<nav class="sidebar-nav">
<RouterLink to="/" class="nav-item" :class="{ active: route.name === 'home' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/></svg>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
/>
</svg>
<span class="nav-label">{{ t('nav.home') }}</span>
</RouterLink>
<RouterLink to="/studio" 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>
<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 class="nav-label">{{ t('nav.studio') }}</span>
</RouterLink>
<RouterLink to="/documents" class="nav-item" :class="{ active: route.name === 'documents' }">
<svg class="nav-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>
<svg class="nav-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="nav-label">{{ t('nav.documents') }}</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>
<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 class="nav-label">{{ 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>
<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 class="nav-label">{{ t('nav.settings') }}</span>
</RouterLink>
</nav>
@ -42,7 +68,7 @@ const route = useRoute()
const { t } = useI18n()
defineProps({
open: { type: Boolean, default: false }
open: { type: Boolean, default: false },
})
</script>
@ -56,7 +82,9 @@ defineProps({
overflow: hidden;
width: 0;
min-width: 0;
transition: width 250ms ease, min-width 250ms ease;
transition:
width 250ms ease,
min-width 250ms ease;
}
.sidebar.open {