Skip to content

Commit 3b9395b

Browse files
committed
feat: v1.23.0 — live byte-level upload progress + reliable completion, conversion fidelity via engine 0.115.0
- Upload: new uploadWithProgress transport (XHR upload.onprogress, fetch-like response, abort/timeout) behind lib/api.ts uploadRequest (Bearer + 401 retry); batch progress in cumulative BYTES (weighted conversion/store windows) instead of files-completed — a single large file no longer sits at 0%; post-upload enrichments time-boxed (60s); the import dialog closes only on full success, failures listed inline (role=alert) with a retry hint; full-screen overlay resets on every path; working Cancel button (AbortController per batch, one batch at a time); dashboard upload gets the same treatment. i18n FR+EN, coarse-pointer 44px targets. +33 tests. - Engine bumped to @qrcommunication/gigapdf-lib 0.115.0: Word bullet lists keep their bullets (Mac Roman cmap polluted the extracted text of ALL converted documents), exports to non-PDF formats keep their formatting and open in Word/LibreOffice again, real-world streaming-zip Office files no longer import empty, decks paginate one slide per page with their images, and the DOCX display path reaches Word parity (char styles, table shading/borders/merges, repeating headers/footers with live page numbers, real multi-column sections).
1 parent e3c8481 commit 3b9395b

18 files changed

Lines changed: 1537 additions & 210 deletions

File tree

CHANGELOG.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,48 @@ All notable changes to GigaPDF are documented here.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.23.0] - 2026-07-03
9+
10+
### Fixed — conversion fidelity, both directions (engine 0.115.0)
11+
12+
- **Word bullet lists show their bullets** — and more broadly, the text layer
13+
of every Office-converted document no longer garbles bullets, accents, € or
14+
dashes (a font table bug declared `` as `¥` in the extracted text of ALL
15+
converted documents). Numbered lists keep their real format (`1.` vs `a)`),
16+
start values and nesting, in imports AND exports.
17+
- **Exports to formats other than PDF keep their formatting** — exported Word
18+
files open in Word/LibreOffice again (they were refused whenever a header
19+
contained a line), margins are sane, a one-column letter no longer comes out
20+
as two columns, images land at their exact position instead of inline at a
21+
default size, page numbers become live fields, and PowerPoint exports carry
22+
positioned images and shapes instead of empty slides.
23+
- **Real-world files open at all**: documents saved with streaming zip
24+
archives (many real Word/Excel/PowerPoint files, and every LibreOffice
25+
ODT/ODP) previously imported as empty; presentations now paginate one slide
26+
per page (an 11-slide deck no longer collapses onto one unreadable page)
27+
with their images and true 16:9 geometry.
28+
- **Opening a Word file looks like Word**: strikethrough, highlight,
29+
superscript/subscript now render; tables get their cell shading, real
30+
borders and merged cells; headers and footers repeat on every page with
31+
live page numbers; two-column sections render in two columns.
32+
33+
### Fixed — file upload: live progress and reliable completion
34+
35+
- **The progress bar actually moves** during upload: progress is now measured
36+
in bytes sent (per file and cumulative across a batch), not per completed
37+
file — a single large file no longer sits at 0 % until the end.
38+
- **The import dialog and full-screen overlay always close**: post-upload
39+
enrichment steps are time-boxed, every path (success, error, cancel) resets
40+
the UI, failures are listed inline with a retry hint instead of locking the
41+
dialog, and a working **Cancel** button aborts the batch.
42+
43+
### Added — zero-downtime deployments (infrastructure)
44+
45+
- Deploys now build in a separate release while the site keeps serving, then
46+
switch atomically (blue/green with health gates and instant rollback).
47+
Measured during the migration itself: 970/970 requests answered 200 —
48+
the brief maintenance flashes some users saw during deploys are gone.
49+
850
## [1.22.0] - 2026-07-03
951

1052
### Added — the editor works on a phone

apps/web/messages/en.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1057,7 +1057,12 @@
10571057
"image": {
10581058
"conversionFailed": "Could not convert this image to PDF",
10591059
"unavailable": "Image conversion is temporarily unavailable"
1060-
}
1060+
},
1061+
"cancel": "Cancel",
1062+
"cancelled": "Import cancelled",
1063+
"currentFile": "Uploading {name}…",
1064+
"failuresTitle": "Files not imported",
1065+
"retryHint": "Drop your files again or browse to retry."
10611066
},
10621067
"ocr": {
10631068
"title": "Make searchable (OCR)",

apps/web/messages/fr.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1057,7 +1057,12 @@
10571057
"image": {
10581058
"conversionFailed": "Impossible de convertir cette image en PDF",
10591059
"unavailable": "La conversion d'image est temporairement indisponible"
1060-
}
1060+
},
1061+
"cancel": "Annuler",
1062+
"cancelled": "Import annulé",
1063+
"currentFile": "Envoi de {name}…",
1064+
"failuresTitle": "Fichiers non importés",
1065+
"retryHint": "Déposez à nouveau vos fichiers ou parcourez pour réessayer."
10611066
},
10621067
"ocr": {
10631068
"title": "Rendre cherchable (OCR)",

apps/web/src/app/(app)/(dashboard)/dashboard/page.tsx

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,24 @@ import { useRouter } from "next/navigation";
55
import { useTranslations } from "next-intl";
66
import { StatsCards } from "@/components/dashboard/stats-cards";
77
import { DocumentGrid } from "@/components/dashboard/document-grid";
8-
import { Button, Skeleton } from "@giga-pdf/ui";
9-
import { Plus, Upload } from "lucide-react";
8+
import { Button, Progress, Skeleton, useToast } from "@giga-pdf/ui";
9+
import { Plus, Upload, X } from "lucide-react";
1010
import { api, StoredDocument, QuotaSummary } from "@/lib/api";
1111
import { clientLogger } from "@/lib/client-logger";
12+
import {
13+
isAbortError,
14+
type UploadProgressEvent,
15+
} from "@/lib/upload-with-progress";
16+
17+
/** Map a byte-level progress event onto a [base, base+span] percent window. */
18+
function percentInWindow(
19+
event: UploadProgressEvent,
20+
base: number,
21+
span: number,
22+
): number | null {
23+
if (event.total === null || event.total <= 0) return null;
24+
return Math.round(base + span * Math.min(1, event.loaded / event.total));
25+
}
1226

1327
interface DashboardDocument {
1428
id: string;
@@ -22,11 +36,16 @@ export default function DashboardPage() {
2236
const t = useTranslations("dashboard");
2337
const tDocs = useTranslations("documents");
2438
const router = useRouter();
39+
const { toast } = useToast();
2540
const fileInputRef = useRef<HTMLInputElement>(null);
2641
const [documents, setDocuments] = useState<DashboardDocument[]>([]);
2742
const [quota, setQuota] = useState<QuotaSummary | null>(null);
2843
const [loading, setLoading] = useState(true);
2944
const [uploading, setUploading] = useState(false);
45+
// Real byte-level percent (0–100) across the two transfers, null = unknown.
46+
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
47+
// Cancels the in-flight upload (Annuler button while uploading).
48+
const uploadAbortRef = useRef<AbortController | null>(null);
3049
const [error, setError] = useState<string | null>(null);
3150

3251
useEffect(() => {
@@ -76,12 +95,21 @@ export default function DashboardPage() {
7695
return;
7796
}
7897

98+
const controller = new AbortController();
99+
uploadAbortRef.current = controller;
100+
79101
try {
80102
setUploading(true);
103+
setUploadPercent(0);
81104
setError(null);
82105

83-
// Upload the document
84-
const uploadResult = await api.uploadDocument(file);
106+
// Upload the document — XHR transport reports real byte progress.
107+
// Two sequential transfers: session upload maps to 0–50%, storage save
108+
// to 50–100% (the intermediate download is not byte-tracked).
109+
const uploadResult = await api.uploadDocument(file, {
110+
signal: controller.signal,
111+
onProgress: (ev) => setUploadPercent(percentInWindow(ev, 0, 50)),
112+
});
85113

86114
// Fetch the PDF Blob from the server before saving
87115
const { getAuthToken } = await import("@/lib/api");
@@ -91,35 +119,58 @@ export default function DashboardPage() {
91119
{
92120
credentials: "include",
93121
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
122+
signal: controller.signal,
94123
}
95124
);
96125
if (!downloadRes.ok) {
97126
throw new Error(`Failed to download PDF: ${downloadRes.status}`);
98127
}
99128
const pdfBlob = await downloadRes.blob();
129+
setUploadPercent(50);
100130

101131
// Save to storage with the PDF Blob
102132
await api.saveDocument({
103133
file: pdfBlob,
104134
name: file.name.replace(".pdf", ""),
105135
tags: [],
136+
signal: controller.signal,
137+
onProgress: (ev) => setUploadPercent(percentInWindow(ev, 50, 50)),
106138
});
139+
setUploadPercent(100);
140+
141+
toast({ title: tDocs("upload.success") });
107142

108143
// Reload dashboard data
109144
await loadDashboardData();
110145

111146
} catch (err) {
112-
clientLogger.error("dashboard.upload-failed", err);
113-
setError(err instanceof Error ? err.message : tDocs("upload.error"));
147+
if (isAbortError(err)) {
148+
// User cancel: neutral feedback, no error banner.
149+
toast({ title: tDocs("import.cancelled") });
150+
} else {
151+
clientLogger.error("dashboard.upload-failed", err);
152+
const message =
153+
err instanceof Error ? err.message : tDocs("upload.error");
154+
setError(message);
155+
toast({ variant: "destructive", title: tDocs("upload.error"), description: message });
156+
}
114157
} finally {
158+
// ALL paths (success, failure, cancel) reset the uploading state.
159+
uploadAbortRef.current = null;
115160
setUploading(false);
161+
setUploadPercent(null);
116162
// Reset file input
117163
if (fileInputRef.current) {
118164
fileInputRef.current.value = "";
119165
}
120166
}
121167
};
122168

169+
// Cancel the in-flight upload (aborts every transfer).
170+
const handleCancelUpload = () => {
171+
uploadAbortRef.current?.abort();
172+
};
173+
123174
const totalDocuments = quota?.documents.count ?? documents.length;
124175
const totalSize = quota?.storage.used_bytes ?? documents.reduce((acc, doc) => acc + doc.size, 0);
125176
const recentDocuments = documents.filter(
@@ -171,7 +222,7 @@ export default function DashboardPage() {
171222
</p>
172223
</div>
173224
<Button
174-
className="gap-2"
225+
className="gap-2 pointer-coarse:min-h-11"
175226
onClick={handleNewDocument}
176227
disabled={uploading}
177228
>
@@ -189,6 +240,36 @@ export default function DashboardPage() {
189240
</Button>
190241
</div>
191242

243+
{/* Real byte-level upload progress + cancel. The upload flow's `finally`
244+
resets uploading/uploadPercent on success, error AND cancel, so this
245+
block always dismisses on its own. */}
246+
{uploading && (
247+
<div
248+
className="flex items-center gap-3 rounded-lg border bg-muted/50 p-3"
249+
aria-live="polite"
250+
>
251+
<div className="min-w-0 flex-1 space-y-1">
252+
<div className="flex items-center justify-between text-sm text-muted-foreground">
253+
<span>{tDocs("upload.uploading")}</span>
254+
{uploadPercent !== null && <span>{uploadPercent}%</span>}
255+
</div>
256+
{uploadPercent !== null ? (
257+
<Progress value={uploadPercent} />
258+
) : (
259+
<Progress value={100} className="animate-pulse" />
260+
)}
261+
</div>
262+
<Button
263+
variant="outline"
264+
onClick={handleCancelUpload}
265+
className="pointer-coarse:min-h-11 pointer-coarse:min-w-11"
266+
>
267+
<X className="mr-2 h-4 w-4" aria-hidden="true" />
268+
{tDocs("import.cancel")}
269+
</Button>
270+
</div>
271+
)}
272+
192273
{error && (
193274
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200">
194275
{error}

0 commit comments

Comments
 (0)