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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -33,11 +33,19 @@ const rendered = computed(() => {
font-weight: 600; font-weight: 600;
} }
.markdown-viewer :deep(h1) { font-size: 24px; } .markdown-viewer :deep(h1) {
.markdown-viewer :deep(h2) { font-size: 20px; } font-size: 24px;
.markdown-viewer :deep(h3) { font-size: 16px; } }
.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) { .markdown-viewer :deep(code) {
background: var(--bg-elevated); background: var(--bg-elevated);

View file

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

View file

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

View file

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

View file

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

View file

@ -9,15 +9,28 @@
> >
<div class="doc-info"> <div class="doc-info">
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor"> <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> </svg>
<div class="doc-meta"> <div class="doc-meta">
<span class="doc-name">{{ doc.filename }}</span> <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>
</div> </div>
<button class="doc-delete" @click.stop="store.remove(doc.id)" title="Delete"> <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> </button>
</div> </div>
</div> </div>
@ -101,7 +114,15 @@ const store = useDocumentStore()
transition: all var(--transition); transition: all var(--transition);
} }
.doc-item:hover .doc-delete { opacity: 1; } .doc-item:hover .doc-delete {
.doc-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); } opacity: 1;
.doc-delete svg { width: 14px; height: 14px; } }
.doc-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.doc-delete svg {
width: 14px;
height: 14px;
}
</style> </style>

View file

@ -13,7 +13,13 @@
<span>{{ t('upload.uploading') }}</span> <span>{{ t('upload.uploading') }}</span>
</div> </div>
<div v-else class="upload-state"> <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" /> <path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
</svg> </svg>
<span class="upload-text">{{ t('upload.drop') }}</span> <span class="upload-text">{{ t('upload.drop') }}</span>
@ -42,10 +48,7 @@ function openFilePicker() {
async function onFileSelect(e: Event) { async function onFileSelect(e: Event) {
const target = e.target as HTMLInputElement const target = e.target as HTMLInputElement
const file = target.files?.[0] const file = target.files?.[0]
if ( if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
file &&
(file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))
) {
try { try {
store.clearError() store.clearError()
const doc = await store.upload(file) const doc = await store.upload(file)
@ -133,6 +136,8 @@ async function onDrop(e: DragEvent) {
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
</style> </style>

View file

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

View file

@ -18,7 +18,9 @@
class="item-status status-completed" class="item-status status-completed"
:title="analysis.status" :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>
<span v-else class="item-status" :class="statusClass(analysis.status)"> <span v-else class="item-status" :class="statusClass(analysis.status)">
{{ analysis.status }} {{ analysis.status }}
@ -30,7 +32,13 @@
</div> </div>
</div> </div>
<button class="item-delete" @click.stop="store.remove(analysis.id)" title="Delete"> <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> </button>
</div> </div>
</div> </div>
@ -56,7 +64,7 @@ function statusClass(status: string) {
'status-pending': status === 'PENDING', 'status-pending': status === 'PENDING',
'status-running': status === 'RUNNING', 'status-running': status === 'RUNNING',
'status-completed': status === 'COMPLETED', 'status-completed': status === 'COMPLETED',
'status-failed': status === 'FAILED' 'status-failed': status === 'FAILED',
} }
} }
@ -109,7 +117,10 @@ function duration(analysis: Analysis) {
background: var(--bg-elevated); background: var(--bg-elevated);
} }
.item-main { flex: 1; min-width: 0; } .item-main {
flex: 1;
min-width: 0;
}
.item-header { .item-header {
display: flex; display: flex;
@ -140,10 +151,23 @@ function duration(analysis: Analysis) {
height: 8px; height: 8px;
} }
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); } .status-pending {
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); } background: rgba(234, 179, 8, 0.15);
.status-completed { background: none; color: var(--success); padding: 0; } color: var(--warning);
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); } }
.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 { .item-meta {
font-size: 12px; font-size: 12px;
@ -164,7 +188,15 @@ function duration(analysis: Analysis) {
transition: all var(--transition); transition: all var(--transition);
} }
.history-item:hover .item-delete { opacity: 1; } .history-item:hover .item-delete {
.item-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); } opacity: 1;
.item-delete svg { width: 16px; height: 16px; } }
.item-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.item-delete svg {
width: 16px;
height: 16px;
}
</style> </style>

View file

@ -15,8 +15,12 @@
<div class="setting-group"> <div class="setting-group">
<label class="setting-label">{{ t('settings.language') }}</label> <label class="setting-label">{{ t('settings.language') }}</label>
<div class="setting-toggle"> <div class="setting-toggle">
<button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">FR</button> <button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">EN</button> FR
</button>
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">
EN
</button>
</div> </div>
</div> </div>

View file

@ -8,14 +8,14 @@
{{ t('history.emptyDocs') }} {{ t('history.emptyDocs') }}
</div> </div>
<div v-else class="doc-items"> <div v-else class="doc-items">
<div <div v-for="doc in docStore.documents" :key="doc.id" class="doc-row">
v-for="doc in docStore.documents"
:key="doc.id"
class="doc-row"
>
<div class="doc-row-info"> <div class="doc-row-info">
<svg class="doc-row-icon" viewBox="0 0 20 20" fill="currentColor"> <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> </svg>
<div class="doc-row-meta"> <div class="doc-row-meta">
<span class="doc-row-name">{{ doc.filename }}</span> <span class="doc-row-name">{{ doc.filename }}</span>
@ -27,7 +27,13 @@
</div> </div>
</div> </div>
<button class="doc-row-delete" @click="docStore.remove(doc.id)"> <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> </button>
</div> </div>
</div> </div>
@ -158,7 +164,15 @@ onMounted(() => {
transition: all var(--transition); transition: all var(--transition);
} }
.doc-row:hover .doc-row-delete { opacity: 1; } .doc-row:hover .doc-row-delete {
.doc-row-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); } opacity: 1;
.doc-row-delete svg { width: 16px; height: 16px; } }
.doc-row-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.doc-row-delete svg {
width: 16px;
height: 16px;
}
</style> </style>

View file

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

View file

@ -25,7 +25,13 @@
:class="{ active: mode === 'configurer' }" :class="{ active: mode === 'configurer' }"
@click="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') }} {{ t('studio.configure') }}
</button> </button>
<button <button
@ -34,7 +40,13 @@
@click="mode = 'verifier'" @click="mode = 'verifier'"
:disabled="!analysisStore.currentAnalysis" :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') }} {{ t('studio.verify') }}
</button> </button>
<button <button
@ -44,14 +56,22 @@
@click="mode = 'preparer'" @click="mode = 'preparer'"
:disabled="!analysisStore.currentAnalysis" :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') }} {{ t('studio.prepare') }}
</button> </button>
</div> </div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<button class="topbar-btn" @click="addMore"> <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') }} {{ t('studio.addFiles') }}
</button> </button>
<button <button
@ -61,7 +81,13 @@
v-if="mode === 'configurer'" v-if="mode === 'configurer'"
> >
<div v-if="analysisStore.running" class="spinner-sm" /> <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') }} {{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }}
</button> </button>
</div> </div>
@ -70,7 +96,13 @@
<!-- Document info bar --> <!-- Document info bar -->
<div class="doc-infobar"> <div class="doc-infobar">
<div class="doc-info-left"> <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-filename">{{ selectedDoc.filename }}</span>
<span class="doc-status-chip loaded">{{ t('studio.loaded') }}</span> <span class="doc-status-chip loaded">{{ t('studio.loaded') }}</span>
</div> </div>
@ -96,7 +128,13 @@
<div class="pdf-viewer-panel"> <div class="pdf-viewer-panel">
<div class="pdf-nav-bar"> <div class="pdf-nav-bar">
<button class="pdf-nav-btn" :disabled="currentPage <= 1" @click="currentPage--"> <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> </button>
<div class="pdf-page-input-wrap"> <div class="pdf-page-input-wrap">
<input <input
@ -109,8 +147,18 @@
/> />
</div> </div>
<span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span> <span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span>
<button class="pdf-nav-btn" :disabled="!selectedDoc.pageCount || currentPage >= selectedDoc.pageCount" @click="currentPage++"> <button
<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> 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> </button>
<span class="pdf-separator" /> <span class="pdf-separator" />
<span class="pdf-zoom">100%</span> <span class="pdf-zoom">100%</span>
@ -121,7 +169,14 @@
:class="{ active: visualMode }" :class="{ active: visualMode }"
@click="visualMode = !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') }} {{ t('studio.visual') }}
</button> </button>
</template> </template>
@ -150,10 +205,7 @@
</div> </div>
<!-- Resize handle --> <!-- Resize handle -->
<div <div class="resize-handle" @mousedown="onResizeStart">
class="resize-handle"
@mousedown="onResizeStart"
>
<div class="resize-grip" /> <div class="resize-grip" />
</div> </div>
@ -181,16 +233,26 @@
<span class="toggle-switch" /> <span class="toggle-switch" />
<span class="toggle-text">{{ t('config.ocr') }}</span> <span class="toggle-text">{{ t('config.ocr') }}</span>
</label> </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>
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <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-switch" />
<span class="toggle-text">{{ t('config.tableStructure') }}</span> <span class="toggle-text">{{ t('config.tableStructure') }}</span>
</label> </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>
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure"> <div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
@ -208,20 +270,34 @@
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <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-switch" />
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span> <span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
</label> </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>
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <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-switch" />
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span> <span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
</label> </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>
</div> </div>
@ -231,41 +307,72 @@
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <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-switch" />
<span class="toggle-text">{{ t('config.pictureClassification') }}</span> <span class="toggle-text">{{ t('config.pictureClassification') }}</span>
</label> </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>
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <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-switch" />
<span class="toggle-text">{{ t('config.pictureDescription') }}</span> <span class="toggle-text">{{ t('config.pictureDescription') }}</span>
</label> </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>
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <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-switch" />
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span> <span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
</label> </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>
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <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-switch" />
<span class="toggle-text">{{ t('config.generatePageImages') }}</span> <span class="toggle-text">{{ t('config.generatePageImages') }}</span>
</label> </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>
<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> <label class="config-label-sm">{{ t('config.imagesScale') }}</label>
<select class="config-select" v-model.number="pipelineOptions.images_scale"> <select class="config-select" v-model.number="pipelineOptions.images_scale">
<option :value="0.5">0.5x</option> <option :value="0.5">0.5x</option>
@ -379,12 +486,14 @@ const pipelineOptions = reactive<PipelineOptions>({
}) })
const hasAnalysisResults = computed(() => { const hasAnalysisResults = computed(() => {
return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0 return (
analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
)
}) })
const currentPageData = computed(() => { const currentPageData = computed(() => {
if (!analysisStore.currentPages) return null 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() { function onPdfImageLoad() {
@ -392,7 +501,7 @@ function onPdfImageLoad() {
} }
const selectedDoc = computed(() => { const selectedDoc = computed(() => {
return documentStore.documents.find(d => d.id === documentStore.selectedId) return documentStore.documents.find((d) => d.id === documentStore.selectedId)
}) })
const previewUrl = computed(() => { const previewUrl = computed(() => {
@ -427,12 +536,15 @@ watch(currentPage, () => {
}) })
// Auto-switch to verifier when analysis completes + refresh document data (pageCount) // Auto-switch to verifier when analysis completes + refresh document data (pageCount)
watch(() => analysisStore.currentAnalysis?.status, (status) => { watch(
if (status === 'COMPLETED') { () => analysisStore.currentAnalysis?.status,
mode.value = 'verifier' (status) => {
documentStore.load() if (status === 'COMPLETED') {
} mode.value = 'verifier'
}) documentStore.load()
}
},
)
onMounted(async () => { onMounted(async () => {
await documentStore.load() await documentStore.load()
@ -658,7 +770,7 @@ onBeforeUnmount(() => {
.spinner-sm { .spinner-sm {
width: 14px; width: 14px;
height: 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-top-color: white;
border-radius: 50%; border-radius: 50%;
animation: spin 0.6s linear infinite; animation: spin 0.6s linear infinite;
@ -730,8 +842,12 @@ onBeforeUnmount(() => {
border-radius: 50%; border-radius: 50%;
} }
.info-dot.success { background: var(--success); } .info-dot.success {
.info-dot.error { background: var(--error); } background: var(--success);
}
.info-dot.error {
background: var(--error);
}
.spinner-xs { .spinner-xs {
width: 12px; width: 12px;
@ -830,9 +946,18 @@ onBeforeUnmount(() => {
transition: all var(--transition); transition: all var(--transition);
} }
.pdf-nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); } .pdf-nav-btn:hover:not(:disabled) {
.pdf-nav-btn:disabled { opacity: 0.3; cursor: default; } background: var(--bg-hover);
.pdf-nav-btn svg { width: 16px; height: 16px; } color: var(--text);
}
.pdf-nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
.pdf-nav-btn svg {
width: 16px;
height: 16px;
}
.pdf-page-input-wrap { .pdf-page-input-wrap {
display: flex; display: flex;
@ -931,7 +1056,7 @@ onBeforeUnmount(() => {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
display: block; 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; border-radius: 2px;
} }
@ -1178,5 +1303,9 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>

View file

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

View file

@ -20,7 +20,8 @@ const messages: Messages = {
// Home // Home
'home.title': 'Docling Studio', '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.documents': 'Documents',
'home.analyses': 'Analyses', 'home.analyses': 'Analyses',
'home.recentDocs': 'Documents récents', 'home.recentDocs': 'Documents récents',
@ -45,26 +46,34 @@ const messages: Messages = {
'config.model': 'Modèle', 'config.model': 'Modèle',
'config.pipeline': 'Pipeline', 'config.pipeline': 'Pipeline',
'config.ocr': 'OCR', '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.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.tableMode': 'Mode tableaux',
'config.tableModeAccurate': 'Précis', 'config.tableModeAccurate': 'Précis',
'config.tableModeFast': 'Rapide', 'config.tableModeFast': 'Rapide',
'config.enrichment': 'Enrichissement', 'config.enrichment': 'Enrichissement',
'config.codeEnrichment': 'Code', '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.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.pictures': 'Images',
'config.pictureClassification': 'Classification', '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.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.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.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.imagesScale': 'Échelle images',
'config.documents': 'Documents', 'config.documents': 'Documents',
@ -133,7 +142,8 @@ const messages: Messages = {
'topbar.newAnalysis': 'New analysis', 'topbar.newAnalysis': 'New analysis',
'home.title': 'Docling Studio', '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.documents': 'Documents',
'home.analyses': 'Analyses', 'home.analyses': 'Analyses',
'home.recentDocs': 'Recent documents', 'home.recentDocs': 'Recent documents',
@ -155,26 +165,34 @@ const messages: Messages = {
'config.model': 'Model', 'config.model': 'Model',
'config.pipeline': 'Pipeline', 'config.pipeline': 'Pipeline',
'config.ocr': 'OCR', '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.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.tableMode': 'Table mode',
'config.tableModeAccurate': 'Accurate', 'config.tableModeAccurate': 'Accurate',
'config.tableModeFast': 'Fast', 'config.tableModeFast': 'Fast',
'config.enrichment': 'Enrichment', 'config.enrichment': 'Enrichment',
'config.codeEnrichment': 'Code', '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.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.pictures': 'Pictures',
'config.pictureClassification': 'Classification', '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.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.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.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.imagesScale': 'Images scale',
'config.documents': 'Documents', 'config.documents': 'Documents',

View file

@ -2,27 +2,53 @@
<aside class="sidebar" :class="{ open }"> <aside class="sidebar" :class="{ open }">
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<RouterLink to="/" class="nav-item" :class="{ active: route.name === 'home' }"> <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> <span class="nav-label">{{ t('nav.home') }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/studio" class="nav-item" :class="{ active: route.name === 'studio' }"> <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> <span class="nav-label">{{ t('nav.studio') }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/documents" class="nav-item" :class="{ active: route.name === 'documents' }"> <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> <span class="nav-label">{{ t('nav.documents') }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }"> <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> <span class="nav-label">{{ t('nav.history') }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }"> <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> <span class="nav-label">{{ t('nav.settings') }}</span>
</RouterLink> </RouterLink>
</nav> </nav>
@ -42,7 +68,7 @@ const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
defineProps({ defineProps({
open: { type: Boolean, default: false } open: { type: Boolean, default: false },
}) })
</script> </script>
@ -56,7 +82,9 @@ defineProps({
overflow: hidden; overflow: hidden;
width: 0; width: 0;
min-width: 0; min-width: 0;
transition: width 250ms ease, min-width 250ms ease; transition:
width 250ms ease,
min-width 250ms ease;
} }
.sidebar.open { .sidebar.open {