feat: segmented batch progress bar with ring indicator

Replace the basic progress bar with a ring percentage indicator,
segmented batch bar with pulse animation, and an inline mini
progress bar in the top banner visible from any tab.
This commit is contained in:
Pier-Jean Malandrino 2026-04-09 15:51:46 +02:00
parent 26fd46967c
commit f6992d91bc
2 changed files with 208 additions and 25 deletions

View file

@ -109,20 +109,54 @@
</div>
</div>
<div v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder">
<div class="spinner-large" />
<span>{{ t('studio.analysisRunning') }}</span>
<!-- Batch progress: segmented bar -->
<div
v-if="store.currentAnalysis.progressTotal && store.currentAnalysis.progressTotal > 0"
class="batch-progress"
>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progressPercent + '%' }" />
<div class="batch-progress-ring">
<svg viewBox="0 0 48 48" class="progress-ring-svg">
<circle cx="24" cy="24" r="20" fill="none" stroke="var(--border)" stroke-width="3" />
<circle
cx="24"
cy="24"
r="20"
fill="none"
stroke="var(--accent)"
stroke-width="3"
stroke-linecap="round"
:stroke-dasharray="125.66"
:stroke-dashoffset="125.66 - (125.66 * progressPercent) / 100"
class="progress-ring-fill"
/>
</svg>
<span class="progress-ring-label">{{ progressPercent }}%</span>
</div>
<div class="batch-progress-detail">
<span class="batch-progress-title">{{ t('studio.analysisRunning') }}</span>
<div class="batch-segments">
<div
v-for="i in batchSegments"
:key="i"
class="batch-segment"
:class="{
filled: i <= filledSegments,
active: i === filledSegments + 1,
}"
/>
</div>
<span class="batch-progress-sub">
<span class="batch-progress-pages">{{ store.currentAnalysis.progressCurrent ?? 0 }}</span>
<span class="batch-progress-sep">/</span>
<span>{{ store.currentAnalysis.progressTotal }} pages</span>
</span>
</div>
<span class="progress-text">
Pages {{ store.currentAnalysis.progressCurrent ?? 0 }} /
{{ store.currentAnalysis.progressTotal }}
</span>
</div>
<!-- Fallback: no batch info -->
<template v-else>
<div class="spinner-large" />
<span>{{ t('studio.analysisRunning') }}</span>
</template>
</div>
<div
v-else-if="store.currentAnalysis?.status === 'FAILED'"
@ -199,6 +233,31 @@ const progressPercent = computed(() => {
return Math.min(100, Math.round(((a.progressCurrent ?? 0) / a.progressTotal) * 100))
})
/** Number of batch segments to render in the segmented progress bar. */
const batchSegments = computed(() => {
const a = store.currentAnalysis
if (!a?.progressTotal || a.progressTotal <= 0) return 0
// Each segment = batch_page_size pages. We infer count from total & current.
// Backend updates progressCurrent in increments, so we derive segment count.
const current = a.progressCurrent ?? 0
if (current <= 0) return 0
// Guess batch size from the first non-zero progressCurrent value.
// Fallback: assume ~5 segments for a clean visual.
const total = a.progressTotal
// We can't know batch_page_size on frontend, so compute a clean segment count
// by aiming for 3-8 segments (visually optimal).
let count = Math.round(total / Math.max(1, current || total / 5))
if (count < 2) count = Math.ceil(total / Math.ceil(total / 5))
return Math.max(2, Math.min(8, count))
})
/** How many segments are "filled" (completed batches). */
const filledSegments = computed(() => {
const total = batchSegments.value
if (total <= 0) return 0
return Math.round((progressPercent.value / 100) * total)
})
const currentPageData = computed(() => {
return store.currentPages.find((p) => p.page_number === props.currentPage) || null
})
@ -534,29 +593,98 @@ async function copyElement(idx: number, content: string) {
}
}
/* ── Batch progress: ring + segmented bar ── */
.batch-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-top: 8px;
width: 200px;
gap: 20px;
}
.progress-bar {
/* Circular ring */
.batch-progress-ring {
position: relative;
width: 56px;
height: 56px;
flex-shrink: 0;
}
.progress-ring-svg {
width: 100%;
height: 6px;
background: var(--border-light);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.3s ease;
transform: rotate(-90deg);
}
.progress-text {
font-size: 0.8rem;
color: var(--text-secondary);
.progress-ring-fill {
transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4));
}
.progress-ring-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
font-family: 'IBM Plex Mono', monospace;
color: var(--text);
}
/* Right side: text + segments */
.batch-progress-detail {
display: flex;
flex-direction: column;
gap: 6px;
}
.batch-progress-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
/* Segmented bar */
.batch-segments {
display: flex;
gap: 3px;
}
.batch-segment {
width: 28px;
height: 5px;
border-radius: 2.5px;
background: var(--border);
transition:
background 0.4s ease,
box-shadow 0.4s ease;
}
.batch-segment.filled {
background: var(--accent);
box-shadow: 0 0 6px rgba(249, 115, 22, 0.3);
}
.batch-segment.active {
background: var(--accent-muted);
animation: segment-pulse 1.5s ease-in-out infinite;
}
@keyframes segment-pulse {
0%,
100% {
background: var(--accent-muted);
}
50% {
background: var(--accent);
box-shadow: 0 0 8px rgba(249, 115, 22, 0.35);
}
}
/* Page counter */
.batch-progress-sub {
font-size: 12px;
font-family: 'IBM Plex Mono', monospace;
color: var(--text-muted);
}
.batch-progress-pages {
color: var(--accent);
font-weight: 600;
}
.batch-progress-sep {
margin: 0 2px;
opacity: 0.4;
}
</style>

View file

@ -118,6 +118,35 @@
<span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'RUNNING'">
<div class="spinner-xs" />
{{ t('studio.analysisRunning') }}
<span
v-if="
analysisStore.currentAnalysis.progressTotal &&
analysisStore.currentAnalysis.progressTotal > 0
"
class="info-badge-progress"
>
<span class="info-badge-bar">
<span
class="info-badge-fill"
:style="{
width:
Math.min(
100,
Math.round(
((analysisStore.currentAnalysis.progressCurrent ?? 0) /
analysisStore.currentAnalysis.progressTotal) *
100,
),
) + '%',
}"
/>
</span>
<span class="info-badge-count"
>{{ analysisStore.currentAnalysis.progressCurrent ?? 0 }}/{{
analysisStore.currentAnalysis.progressTotal
}}</span
>
</span>
</span>
<span class="info-badge error" v-if="analysisStore.currentAnalysis.status === 'FAILED'">
<span class="info-dot error" />
@ -867,6 +896,32 @@ onBeforeUnmount(() => {
animation: spin 0.6s linear infinite;
}
/* Inline mini progress in top bar */
.info-badge-progress {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 4px;
}
.info-badge-bar {
width: 48px;
height: 3px;
background: var(--border);
border-radius: 1.5px;
overflow: hidden;
}
.info-badge-fill {
display: block;
height: 100%;
background: var(--accent);
border-radius: 1.5px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.info-badge-count {
font-size: 11px;
color: var(--text-muted);
}
/* Main content */
.studio-main {
flex: 1;