ChristopherJKoen commited on
Commit
74b1b27
·
1 Parent(s): 8cc6d9b
frontend/src/components/LoadingBar.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ type LoadingBarProps = {
2
+ active: boolean;
3
+ progress?: number | null;
4
+ label?: string;
5
+ className?: string;
6
+ };
7
+
8
+ function clampProgress(value: number): number {
9
+ if (Number.isNaN(value)) return 0;
10
+ if (value < 0) return 0;
11
+ if (value > 100) return 100;
12
+ return value;
13
+ }
14
+
15
+ export function LoadingBar({
16
+ active,
17
+ progress = null,
18
+ label,
19
+ className = "",
20
+ }: LoadingBarProps) {
21
+ if (!active) return null;
22
+
23
+ const numericProgress = typeof progress === "number" ? clampProgress(progress) : null;
24
+
25
+ return (
26
+ <div className={className}>
27
+ {label ? (
28
+ <div className="mb-1 text-xs font-medium text-gray-600">
29
+ {label}
30
+ {numericProgress !== null ? ` (${Math.round(numericProgress)}%)` : ""}
31
+ </div>
32
+ ) : null}
33
+ <div
34
+ role="progressbar"
35
+ aria-valuemin={0}
36
+ aria-valuemax={100}
37
+ aria-valuenow={numericProgress ?? undefined}
38
+ className="h-2 w-full overflow-hidden rounded-full bg-gray-200"
39
+ >
40
+ <div
41
+ className={[
42
+ "h-full rounded-full bg-blue-600",
43
+ numericProgress === null ? "w-2/5 animate-pulse" : "transition-all duration-200",
44
+ ].join(" ")}
45
+ style={numericProgress === null ? undefined : { width: `${numericProgress}%` }}
46
+ />
47
+ </div>
48
+ </div>
49
+ );
50
+ }
frontend/src/components/SaveBeforeLeaveDialog.tsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ type SaveBeforeLeaveDialogProps = {
2
+ open: boolean;
3
+ onWait: () => void | Promise<void>;
4
+ onProceed: () => void;
5
+ onCancel: () => void;
6
+ };
7
+
8
+ export function SaveBeforeLeaveDialog({
9
+ open,
10
+ onWait,
11
+ onProceed,
12
+ onCancel,
13
+ }: SaveBeforeLeaveDialogProps) {
14
+ if (!open) return null;
15
+
16
+ return (
17
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/35 px-4">
18
+ <div className="w-full max-w-md rounded-xl border border-gray-200 bg-white p-5 shadow-lg">
19
+ <h3 className="text-base font-semibold text-gray-900">
20
+ Changes are still saving
21
+ </h3>
22
+ <p className="mt-2 text-sm text-gray-600">
23
+ Your latest edits may not be saved yet. You can wait for save to
24
+ finish and continue automatically, or leave this page now.
25
+ </p>
26
+ <div className="mt-4 flex flex-wrap justify-end gap-2">
27
+ <button
28
+ type="button"
29
+ onClick={onCancel}
30
+ className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 transition"
31
+ >
32
+ Cancel
33
+ </button>
34
+ <button
35
+ type="button"
36
+ onClick={onProceed}
37
+ className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700 hover:bg-red-100 transition"
38
+ >
39
+ Leave without waiting
40
+ </button>
41
+ <button
42
+ type="button"
43
+ onClick={() => {
44
+ void onWait();
45
+ }}
46
+ className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition"
47
+ >
48
+ Wait and continue
49
+ </button>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ );
54
+ }
frontend/src/lib/version.ts CHANGED
@@ -1 +1 @@
1
- export const APP_VERSION = "V0.1.4";
 
1
+ export const APP_VERSION = "V0.1.5";
frontend/src/pages/EditLayoutsPage.tsx CHANGED
@@ -33,6 +33,7 @@ import { PageHeader } from "../components/PageHeader";
33
  import { PageShell } from "../components/PageShell";
34
  import { InfoMenu } from "../components/InfoMenu";
35
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
 
36
 
37
  export default function EditLayoutsPage() {
38
  const [searchParams] = useSearchParams();
@@ -56,6 +57,9 @@ export default function EditLayoutsPage() {
56
  const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
57
  {},
58
  );
 
 
 
59
  const canModify = Boolean(sessionId) && !isSaving;
60
  const saveTimerRef = useRef<number | null>(null);
61
  const savePromiseRef = useRef<Promise<void> | null>(null);
@@ -387,6 +391,28 @@ export default function EditLayoutsPage() {
387
  navigate(target);
388
  }
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  const saveIndicator = useMemo(() => {
391
  const base =
392
  "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
@@ -434,7 +460,7 @@ export default function EditLayoutsPage() {
434
  to={`/report-viewer${sessionQuery}`}
435
  onClick={(event) => {
436
  event.preventDefault();
437
- void saveAndNavigate(`/report-viewer${sessionQuery}`);
438
  }}
439
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
440
  >
@@ -452,7 +478,7 @@ export default function EditLayoutsPage() {
452
  to={`/input-data${sessionQuery}`}
453
  onClick={(event) => {
454
  event.preventDefault();
455
- void saveAndNavigate(`/input-data${sessionQuery}`);
456
  }}
457
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
458
  >
@@ -464,7 +490,7 @@ export default function EditLayoutsPage() {
464
  to={`/report-viewer${sessionQuery}`}
465
  onClick={(event) => {
466
  event.preventDefault();
467
- void saveAndNavigate(`/report-viewer${sessionQuery}`);
468
  }}
469
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
470
  >
@@ -476,7 +502,7 @@ export default function EditLayoutsPage() {
476
  to={`/image-placement${sessionQuery}`}
477
  onClick={(event) => {
478
  event.preventDefault();
479
- void saveAndNavigate(`/image-placement${sessionQuery}`);
480
  }}
481
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
482
  >
@@ -488,7 +514,7 @@ export default function EditLayoutsPage() {
488
  to={`/edit-report${sessionQuery}`}
489
  onClick={(event) => {
490
  event.preventDefault();
491
- void saveAndNavigate(`/edit-report${sessionQuery}`);
492
  }}
493
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
494
  >
@@ -505,7 +531,7 @@ export default function EditLayoutsPage() {
505
  to={`/edit-layouts/templates${sessionQuery}`}
506
  onClick={(event) => {
507
  event.preventDefault();
508
- void saveAndNavigate(`/edit-layouts/templates${sessionQuery}`);
509
  }}
510
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
511
  >
@@ -516,7 +542,7 @@ export default function EditLayoutsPage() {
516
  to={`/export${sessionQuery}`}
517
  onClick={(event) => {
518
  event.preventDefault();
519
- void saveAndNavigate(`/export${sessionQuery}`);
520
  }}
521
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
522
  >
@@ -653,7 +679,7 @@ export default function EditLayoutsPage() {
653
  to={`/edit-layouts/templates${sessionQuery}`}
654
  onClick={(event) => {
655
  event.preventDefault();
656
- void saveAndNavigate(`/edit-layouts/templates${sessionQuery}`);
657
  }}
658
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"
659
  >
@@ -775,6 +801,12 @@ export default function EditLayoutsPage() {
775
  </section>
776
 
777
  <PageFooter note="Tip: reorder pages here and remove empty pages before export." />
 
 
 
 
 
 
778
  </PageShell>
779
  );
780
  }
 
33
  import { PageShell } from "../components/PageShell";
34
  import { InfoMenu } from "../components/InfoMenu";
35
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
36
+ import { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog";
37
 
38
  export default function EditLayoutsPage() {
39
  const [searchParams] = useSearchParams();
 
57
  const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
58
  {},
59
  );
60
+ const [pendingNavigationTarget, setPendingNavigationTarget] = useState<
61
+ string | null
62
+ >(null);
63
  const canModify = Boolean(sessionId) && !isSaving;
64
  const saveTimerRef = useRef<number | null>(null);
65
  const savePromiseRef = useRef<Promise<void> | null>(null);
 
391
  navigate(target);
392
  }
393
 
394
+ function requestNavigation(target: string) {
395
+ if (saveState === "saving" || saveState === "pending" || isSaving) {
396
+ setPendingNavigationTarget(target);
397
+ return;
398
+ }
399
+ void saveAndNavigate(target);
400
+ }
401
+
402
+ async function waitForSaveAndContinue() {
403
+ if (!pendingNavigationTarget) return;
404
+ const target = pendingNavigationTarget;
405
+ setPendingNavigationTarget(null);
406
+ await saveAndNavigate(target);
407
+ }
408
+
409
+ function leaveWithoutWaiting() {
410
+ if (!pendingNavigationTarget) return;
411
+ const target = pendingNavigationTarget;
412
+ setPendingNavigationTarget(null);
413
+ navigate(target);
414
+ }
415
+
416
  const saveIndicator = useMemo(() => {
417
  const base =
418
  "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
 
460
  to={`/report-viewer${sessionQuery}`}
461
  onClick={(event) => {
462
  event.preventDefault();
463
+ requestNavigation(`/report-viewer${sessionQuery}`);
464
  }}
465
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
466
  >
 
478
  to={`/input-data${sessionQuery}`}
479
  onClick={(event) => {
480
  event.preventDefault();
481
+ requestNavigation(`/input-data${sessionQuery}`);
482
  }}
483
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
484
  >
 
490
  to={`/report-viewer${sessionQuery}`}
491
  onClick={(event) => {
492
  event.preventDefault();
493
+ requestNavigation(`/report-viewer${sessionQuery}`);
494
  }}
495
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
496
  >
 
502
  to={`/image-placement${sessionQuery}`}
503
  onClick={(event) => {
504
  event.preventDefault();
505
+ requestNavigation(`/image-placement${sessionQuery}`);
506
  }}
507
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
508
  >
 
514
  to={`/edit-report${sessionQuery}`}
515
  onClick={(event) => {
516
  event.preventDefault();
517
+ requestNavigation(`/edit-report${sessionQuery}`);
518
  }}
519
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
520
  >
 
531
  to={`/edit-layouts/templates${sessionQuery}`}
532
  onClick={(event) => {
533
  event.preventDefault();
534
+ requestNavigation(`/edit-layouts/templates${sessionQuery}`);
535
  }}
536
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
537
  >
 
542
  to={`/export${sessionQuery}`}
543
  onClick={(event) => {
544
  event.preventDefault();
545
+ requestNavigation(`/export${sessionQuery}`);
546
  }}
547
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
548
  >
 
679
  to={`/edit-layouts/templates${sessionQuery}`}
680
  onClick={(event) => {
681
  event.preventDefault();
682
+ requestNavigation(`/edit-layouts/templates${sessionQuery}`);
683
  }}
684
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"
685
  >
 
801
  </section>
802
 
803
  <PageFooter note="Tip: reorder pages here and remove empty pages before export." />
804
+ <SaveBeforeLeaveDialog
805
+ open={Boolean(pendingNavigationTarget)}
806
+ onCancel={() => setPendingNavigationTarget(null)}
807
+ onProceed={leaveWithoutWaiting}
808
+ onWait={waitForSaveAndContinue}
809
+ />
810
  </PageShell>
811
  );
812
  }
frontend/src/pages/EditReportPage.tsx CHANGED
@@ -10,6 +10,7 @@ import { PageFooter } from "../components/PageFooter";
10
  import { PageHeader } from "../components/PageHeader";
11
  import { PageShell } from "../components/PageShell";
12
  import { InfoMenu } from "../components/InfoMenu";
 
13
 
14
  export default function EditReportPage() {
15
  const [searchParams] = useSearchParams();
@@ -23,6 +24,9 @@ export default function EditReportPage() {
23
  const [saveState, setSaveState] = useState<
24
  "saved" | "saving" | "pending" | "error"
25
  >("saved");
 
 
 
26
 
27
  const [editorEl, setEditorEl] = useState<ReportEditorElement | null>(null);
28
  const editorRef = useCallback((node: ReportEditorElement | null) => {
@@ -45,6 +49,31 @@ export default function EditReportPage() {
45
  [editorEl, navigate],
46
  );
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  useEffect(() => {
49
  if (!sessionId) {
50
  setError("No active session found. Return to upload to continue.");
@@ -93,13 +122,13 @@ export default function EditReportPage() {
93
  useEffect(() => {
94
  if (!editorEl) return;
95
  const handleClose = () => {
96
- void saveAndNavigate(`/report-viewer${sessionQuery}`);
97
  };
98
  editorEl.addEventListener("editor-closed", handleClose);
99
  return () => {
100
  editorEl.removeEventListener("editor-closed", handleClose);
101
  };
102
- }, [editorEl, saveAndNavigate, sessionQuery]);
103
 
104
  useEffect(() => {
105
  if (!editorEl) return;
@@ -172,7 +201,7 @@ export default function EditReportPage() {
172
  to={`/report-viewer${sessionQuery}`}
173
  onClick={(event) => {
174
  event.preventDefault();
175
- void saveAndNavigate(`/report-viewer${sessionQuery}`);
176
  }}
177
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
178
  >
@@ -190,7 +219,7 @@ export default function EditReportPage() {
190
  to={`/input-data${sessionQuery}`}
191
  onClick={(event) => {
192
  event.preventDefault();
193
- void saveAndNavigate(`/input-data${sessionQuery}`);
194
  }}
195
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
196
  >
@@ -202,7 +231,7 @@ export default function EditReportPage() {
202
  to={`/report-viewer${sessionQuery}`}
203
  onClick={(event) => {
204
  event.preventDefault();
205
- void saveAndNavigate(`/report-viewer${sessionQuery}`);
206
  }}
207
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
208
  >
@@ -214,7 +243,7 @@ export default function EditReportPage() {
214
  to={`/image-placement${sessionQuery}`}
215
  onClick={(event) => {
216
  event.preventDefault();
217
- void saveAndNavigate(`/image-placement${sessionQuery}`);
218
  }}
219
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
220
  >
@@ -231,7 +260,7 @@ export default function EditReportPage() {
231
  to={`/edit-layouts${sessionQuery}`}
232
  onClick={(event) => {
233
  event.preventDefault();
234
- void saveAndNavigate(`/edit-layouts${sessionQuery}`);
235
  }}
236
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
237
  >
@@ -243,7 +272,7 @@ export default function EditReportPage() {
243
  to={`/export${sessionQuery}`}
244
  onClick={(event) => {
245
  event.preventDefault();
246
- void saveAndNavigate(`/export${sessionQuery}`);
247
  }}
248
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
249
  >
@@ -268,6 +297,12 @@ export default function EditReportPage() {
268
  <report-editor ref={editorRef} data-mode="page" />
269
 
270
  <PageFooter note={`Editing page ${pageIndex + 1}. Changes save automatically.`} />
 
 
 
 
 
 
271
  </PageShell>
272
  );
273
  }
 
10
  import { PageHeader } from "../components/PageHeader";
11
  import { PageShell } from "../components/PageShell";
12
  import { InfoMenu } from "../components/InfoMenu";
13
+ import { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog";
14
 
15
  export default function EditReportPage() {
16
  const [searchParams] = useSearchParams();
 
24
  const [saveState, setSaveState] = useState<
25
  "saved" | "saving" | "pending" | "error"
26
  >("saved");
27
+ const [pendingNavigationTarget, setPendingNavigationTarget] = useState<
28
+ string | null
29
+ >(null);
30
 
31
  const [editorEl, setEditorEl] = useState<ReportEditorElement | null>(null);
32
  const editorRef = useCallback((node: ReportEditorElement | null) => {
 
49
  [editorEl, navigate],
50
  );
51
 
52
+ const requestNavigation = useCallback(
53
+ (target: string) => {
54
+ if (saveState === "saving" || saveState === "pending") {
55
+ setPendingNavigationTarget(target);
56
+ return;
57
+ }
58
+ void saveAndNavigate(target);
59
+ },
60
+ [saveAndNavigate, saveState],
61
+ );
62
+
63
+ const waitForSaveAndContinue = useCallback(async () => {
64
+ if (!pendingNavigationTarget) return;
65
+ const target = pendingNavigationTarget;
66
+ setPendingNavigationTarget(null);
67
+ await saveAndNavigate(target);
68
+ }, [pendingNavigationTarget, saveAndNavigate]);
69
+
70
+ const leaveWithoutWaiting = useCallback(() => {
71
+ if (!pendingNavigationTarget) return;
72
+ const target = pendingNavigationTarget;
73
+ setPendingNavigationTarget(null);
74
+ navigate(target);
75
+ }, [navigate, pendingNavigationTarget]);
76
+
77
  useEffect(() => {
78
  if (!sessionId) {
79
  setError("No active session found. Return to upload to continue.");
 
122
  useEffect(() => {
123
  if (!editorEl) return;
124
  const handleClose = () => {
125
+ requestNavigation(`/report-viewer${sessionQuery}`);
126
  };
127
  editorEl.addEventListener("editor-closed", handleClose);
128
  return () => {
129
  editorEl.removeEventListener("editor-closed", handleClose);
130
  };
131
+ }, [editorEl, requestNavigation, sessionQuery]);
132
 
133
  useEffect(() => {
134
  if (!editorEl) return;
 
201
  to={`/report-viewer${sessionQuery}`}
202
  onClick={(event) => {
203
  event.preventDefault();
204
+ requestNavigation(`/report-viewer${sessionQuery}`);
205
  }}
206
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
207
  >
 
219
  to={`/input-data${sessionQuery}`}
220
  onClick={(event) => {
221
  event.preventDefault();
222
+ requestNavigation(`/input-data${sessionQuery}`);
223
  }}
224
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
225
  >
 
231
  to={`/report-viewer${sessionQuery}`}
232
  onClick={(event) => {
233
  event.preventDefault();
234
+ requestNavigation(`/report-viewer${sessionQuery}`);
235
  }}
236
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
237
  >
 
243
  to={`/image-placement${sessionQuery}`}
244
  onClick={(event) => {
245
  event.preventDefault();
246
+ requestNavigation(`/image-placement${sessionQuery}`);
247
  }}
248
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
249
  >
 
260
  to={`/edit-layouts${sessionQuery}`}
261
  onClick={(event) => {
262
  event.preventDefault();
263
+ requestNavigation(`/edit-layouts${sessionQuery}`);
264
  }}
265
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
266
  >
 
272
  to={`/export${sessionQuery}`}
273
  onClick={(event) => {
274
  event.preventDefault();
275
+ requestNavigation(`/export${sessionQuery}`);
276
  }}
277
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
278
  >
 
297
  <report-editor ref={editorRef} data-mode="page" />
298
 
299
  <PageFooter note={`Editing page ${pageIndex + 1}. Changes save automatically.`} />
300
+ <SaveBeforeLeaveDialog
301
+ open={Boolean(pendingNavigationTarget)}
302
+ onCancel={() => setPendingNavigationTarget(null)}
303
+ onProceed={leaveWithoutWaiting}
304
+ onWait={waitForSaveAndContinue}
305
+ />
306
  </PageShell>
307
  );
308
  }
frontend/src/pages/ExportPage.tsx CHANGED
@@ -11,8 +11,22 @@ import { PageFooter } from "../components/PageFooter";
11
  import { PageHeader } from "../components/PageHeader";
12
  import { PageShell } from "../components/PageShell";
13
  import { InfoMenu } from "../components/InfoMenu";
 
14
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  export default function ExportPage() {
17
  const [searchParams] = useSearchParams();
18
  const sessionId = getSessionId(searchParams.toString());
@@ -28,6 +42,88 @@ export default function ExportPage() {
28
  const [incTimestamp, setIncTimestamp] = useState(true);
29
  const [previewScale, setPreviewScale] = useState(1);
30
  const previewRef = useRef<HTMLDivElement | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  useEffect(() => {
33
  if (!sessionId) {
@@ -262,18 +358,33 @@ export default function ExportPage() {
262
  Download JSON package
263
  </button>
264
 
265
- <a
266
- href={serverExportUrl}
267
- target="_blank"
268
- rel="noreferrer"
 
 
 
 
 
269
  className={[
270
  "mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition",
271
- !sessionId ? "pointer-events-none opacity-50" : "",
272
  ].join(" ")}
 
273
  >
274
  <Download className="h-4 w-4" />
275
  Download from server
276
- </a>
 
 
 
 
 
 
 
 
 
277
  </div>
278
 
279
  <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
@@ -287,17 +398,23 @@ export default function ExportPage() {
287
  </div>
288
 
289
  <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
290
- <a
291
- href={serverPdfUrl}
292
- target="_blank"
293
- rel="noreferrer"
 
 
 
 
 
294
  className={[
295
  "inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-white font-semibold hover:bg-blue-700 transition",
296
- !sessionId ? "pointer-events-none opacity-50" : "",
297
  ].join(" ")}
 
298
  >
299
  Download PDF (server)
300
- </a>
301
  <button
302
  type="button"
303
  onClick={handlePrint}
@@ -306,6 +423,15 @@ export default function ExportPage() {
306
  Print / Save PDF (browser)
307
  </button>
308
  </div>
 
 
 
 
 
 
 
 
 
309
  </div>
310
 
311
  </div>
 
11
  import { PageHeader } from "../components/PageHeader";
12
  import { PageShell } from "../components/PageShell";
13
  import { InfoMenu } from "../components/InfoMenu";
14
+ import { LoadingBar } from "../components/LoadingBar";
15
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
16
 
17
+ type DownloadKind = "serverJson" | "serverPdf";
18
+
19
+ type DownloadState = {
20
+ active: boolean;
21
+ progress: number | null;
22
+ error: string;
23
+ };
24
+
25
+ const INITIAL_DOWNLOAD_STATE: Record<DownloadKind, DownloadState> = {
26
+ serverJson: { active: false, progress: null, error: "" },
27
+ serverPdf: { active: false, progress: null, error: "" },
28
+ };
29
+
30
  export default function ExportPage() {
31
  const [searchParams] = useSearchParams();
32
  const sessionId = getSessionId(searchParams.toString());
 
42
  const [incTimestamp, setIncTimestamp] = useState(true);
43
  const [previewScale, setPreviewScale] = useState(1);
44
  const previewRef = useRef<HTMLDivElement | null>(null);
45
+ const [downloadState, setDownloadState] = useState(INITIAL_DOWNLOAD_STATE);
46
+
47
+ function setDownloadPartial(kind: DownloadKind, patch: Partial<DownloadState>) {
48
+ setDownloadState((prev) => ({
49
+ ...prev,
50
+ [kind]: { ...prev[kind], ...patch },
51
+ }));
52
+ }
53
+
54
+ async function fetchAndDownload(
55
+ url: string,
56
+ fallbackFilename: string,
57
+ kind: DownloadKind,
58
+ ) {
59
+ if (!sessionId) return;
60
+ setDownloadPartial(kind, { active: true, progress: null, error: "" });
61
+
62
+ try {
63
+ const response = await fetch(url, { credentials: "same-origin" });
64
+ if (!response.ok) {
65
+ let detail = response.statusText || `Request failed (${response.status})`;
66
+ try {
67
+ const data = (await response.json()) as { detail?: string };
68
+ if (data?.detail) detail = data.detail;
69
+ } catch {
70
+ // keep default
71
+ }
72
+ throw new Error(detail);
73
+ }
74
+
75
+ const contentLengthRaw = response.headers.get("content-length");
76
+ const totalBytes = contentLengthRaw ? Number(contentLengthRaw) : 0;
77
+ let receivedBytes = 0;
78
+
79
+ let blob: Blob;
80
+ if (response.body) {
81
+ const reader = response.body.getReader();
82
+ const chunks: Uint8Array[] = [];
83
+
84
+ while (true) {
85
+ const { done, value } = await reader.read();
86
+ if (done) break;
87
+ if (!value) continue;
88
+ chunks.push(value);
89
+ receivedBytes += value.length;
90
+ if (totalBytes > 0) {
91
+ setDownloadPartial(kind, {
92
+ progress: Math.min(100, (receivedBytes / totalBytes) * 100),
93
+ });
94
+ }
95
+ }
96
+
97
+ blob = new Blob(chunks, {
98
+ type: response.headers.get("content-type") || "application/octet-stream",
99
+ });
100
+ } else {
101
+ blob = await response.blob();
102
+ }
103
+
104
+ const disposition = response.headers.get("content-disposition") || "";
105
+ const match = disposition.match(/filename="?([^"]+)"?/i);
106
+ const filename = match?.[1]?.trim() || fallbackFilename;
107
+
108
+ const objectUrl = URL.createObjectURL(blob);
109
+ const link = document.createElement("a");
110
+ link.href = objectUrl;
111
+ link.download = filename;
112
+ document.body.appendChild(link);
113
+ link.click();
114
+ link.remove();
115
+ URL.revokeObjectURL(objectUrl);
116
+
117
+ setDownloadPartial(kind, { active: false, progress: 100 });
118
+ window.setTimeout(() => {
119
+ setDownloadPartial(kind, { progress: null });
120
+ }, 500);
121
+ } catch (err) {
122
+ const message =
123
+ err instanceof Error ? err.message : "Download failed. Please try again.";
124
+ setDownloadPartial(kind, { active: false, progress: null, error: message });
125
+ }
126
+ }
127
 
128
  useEffect(() => {
129
  if (!sessionId) {
 
358
  Download JSON package
359
  </button>
360
 
361
+ <button
362
+ type="button"
363
+ onClick={() => {
364
+ void fetchAndDownload(
365
+ serverExportUrl,
366
+ `repex_report_package_${sessionId}.json`,
367
+ "serverJson",
368
+ );
369
+ }}
370
  className={[
371
  "mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition",
372
+ !sessionId || downloadState.serverJson.active ? "opacity-50" : "",
373
  ].join(" ")}
374
+ disabled={!sessionId || downloadState.serverJson.active}
375
  >
376
  <Download className="h-4 w-4" />
377
  Download from server
378
+ </button>
379
+ <LoadingBar
380
+ active={downloadState.serverJson.active}
381
+ progress={downloadState.serverJson.progress}
382
+ label="Downloading JSON package"
383
+ className="mt-3"
384
+ />
385
+ {downloadState.serverJson.error ? (
386
+ <p className="mt-2 text-xs text-red-600">{downloadState.serverJson.error}</p>
387
+ ) : null}
388
  </div>
389
 
390
  <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
 
398
  </div>
399
 
400
  <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
401
+ <button
402
+ type="button"
403
+ onClick={() => {
404
+ void fetchAndDownload(
405
+ serverPdfUrl,
406
+ `repex_report_${sessionId}.pdf`,
407
+ "serverPdf",
408
+ );
409
+ }}
410
  className={[
411
  "inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-white font-semibold hover:bg-blue-700 transition",
412
+ !sessionId || downloadState.serverPdf.active ? "opacity-50" : "",
413
  ].join(" ")}
414
+ disabled={!sessionId || downloadState.serverPdf.active}
415
  >
416
  Download PDF (server)
417
+ </button>
418
  <button
419
  type="button"
420
  onClick={handlePrint}
 
423
  Print / Save PDF (browser)
424
  </button>
425
  </div>
426
+ <LoadingBar
427
+ active={downloadState.serverPdf.active}
428
+ progress={downloadState.serverPdf.progress}
429
+ label="Generating and downloading PDF"
430
+ className="mt-3"
431
+ />
432
+ {downloadState.serverPdf.error ? (
433
+ <p className="mt-2 text-xs text-red-600">{downloadState.serverPdf.error}</p>
434
+ ) : null}
435
  </div>
436
 
437
  </div>
frontend/src/pages/InputDataPage.tsx CHANGED
@@ -17,6 +17,8 @@ import { PageFooter } from "../components/PageFooter";
17
  import { PageHeader } from "../components/PageHeader";
18
  import { PageShell } from "../components/PageShell";
19
  import { InfoMenu } from "../components/InfoMenu";
 
 
20
 
21
  type FieldDef = {
22
  key: keyof TemplateFields;
@@ -82,6 +84,10 @@ export default function InputDataPage() {
82
  const [showGeneralColumns, setShowGeneralColumns] = useState(false);
83
  const [generalDirty, setGeneralDirty] = useState(false);
84
  const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
 
 
 
 
85
  const [photoSelections, setPhotoSelections] = useState<Record<number, string>>(
86
  {},
87
  );
@@ -96,6 +102,26 @@ export default function InputDataPage() {
96
  const excelInputRef = useRef<HTMLInputElement | null>(null);
97
  const jsonInputRef = useRef<HTMLInputElement | null>(null);
98
  const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  useEffect(() => {
101
  if (!sessionId) {
@@ -128,6 +154,13 @@ export default function InputDataPage() {
128
  load();
129
  }, [sessionId]);
130
 
 
 
 
 
 
 
 
131
  async function refreshSession() {
132
  if (!sessionId) return;
133
  const data = await request<Session>(`/sessions/${sessionId}`);
@@ -593,6 +626,7 @@ export default function InputDataPage() {
593
  if (!sessionId) return;
594
  setIsUploading(true);
595
  setStatus("Uploading data file...");
 
596
  try {
597
  const form = new FormData();
598
  form.append("file", file);
@@ -600,12 +634,19 @@ export default function InputDataPage() {
600
  await refreshSession();
601
  setStatus("Data file imported.");
602
  setGeneralDirty(false);
 
 
603
  } catch (err) {
604
  const message =
605
  err instanceof Error ? err.message : "Failed to import data file.";
606
  setStatus(message);
 
 
607
  } finally {
608
  setIsUploading(false);
 
 
 
609
  }
610
  }
611
 
@@ -613,6 +654,7 @@ export default function InputDataPage() {
613
  if (!sessionId) return;
614
  setIsUploading(true);
615
  setStatus("Uploading JSON package...");
 
616
  try {
617
  const form = new FormData();
618
  form.append("file", file);
@@ -620,12 +662,19 @@ export default function InputDataPage() {
620
  await refreshSession();
621
  setStatus("JSON package imported.");
622
  setGeneralDirty(false);
 
 
623
  } catch (err) {
624
  const message =
625
  err instanceof Error ? err.message : "Failed to import JSON package.";
626
  setStatus(message);
 
 
627
  } finally {
628
  setIsUploading(false);
 
 
 
629
  }
630
  }
631
 
@@ -633,6 +682,7 @@ export default function InputDataPage() {
633
  if (!sessionId) return;
634
  setIsUploading(true);
635
  setStatus(`Uploading image ${file.name}...`);
 
636
  const existing = new Set(
637
  (session?.uploads?.photos ?? []).map((photo) => photo.id),
638
  );
@@ -653,12 +703,19 @@ export default function InputDataPage() {
653
  } else {
654
  setStatus("Uploaded image. Refresh to see new file.");
655
  }
 
 
656
  } catch (err) {
657
  const message =
658
  err instanceof Error ? err.message : "Failed to upload image.";
659
  setStatus(message);
 
 
660
  } finally {
661
  setIsUploading(false);
 
 
 
662
  }
663
  }
664
 
@@ -689,6 +746,28 @@ export default function InputDataPage() {
689
  navigate(target);
690
  }
691
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
  const saveIndicator = useMemo(() => {
693
  const base =
694
  "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
@@ -736,7 +815,7 @@ export default function InputDataPage() {
736
  to={`/report-viewer${sessionQuery}`}
737
  onClick={(event) => {
738
  event.preventDefault();
739
- void saveAndNavigate(`/report-viewer${sessionQuery}`);
740
  }}
741
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
742
  >
@@ -759,7 +838,7 @@ export default function InputDataPage() {
759
  to={`/report-viewer${sessionQuery}`}
760
  onClick={(event) => {
761
  event.preventDefault();
762
- void saveAndNavigate(`/report-viewer${sessionQuery}`);
763
  }}
764
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
765
  >
@@ -771,7 +850,7 @@ export default function InputDataPage() {
771
  to={`/image-placement${sessionQuery}`}
772
  onClick={(event) => {
773
  event.preventDefault();
774
- void saveAndNavigate(`/image-placement${sessionQuery}`);
775
  }}
776
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
777
  >
@@ -783,7 +862,7 @@ export default function InputDataPage() {
783
  to={`/edit-report${sessionQuery}`}
784
  onClick={(event) => {
785
  event.preventDefault();
786
- void saveAndNavigate(`/edit-report${sessionQuery}`);
787
  }}
788
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
789
  >
@@ -795,7 +874,7 @@ export default function InputDataPage() {
795
  to={`/edit-layouts${sessionQuery}`}
796
  onClick={(event) => {
797
  event.preventDefault();
798
- void saveAndNavigate(`/edit-layouts${sessionQuery}`);
799
  }}
800
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
801
  >
@@ -807,7 +886,7 @@ export default function InputDataPage() {
807
  to={`/export${sessionQuery}`}
808
  onClick={(event) => {
809
  event.preventDefault();
810
- void saveAndNavigate(`/export${sessionQuery}`);
811
  }}
812
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
813
  >
@@ -860,6 +939,18 @@ export default function InputDataPage() {
860
  </div>
861
  </div>
862
  {status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
 
 
 
 
 
 
 
 
 
 
 
 
863
  </section>
864
 
865
  <section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
@@ -1482,6 +1573,12 @@ export default function InputDataPage() {
1482
  </div>
1483
 
1484
  <PageFooter note="Tip: changes save automatically. Use apply row to keep pages consistent." />
 
 
 
 
 
 
1485
  </PageShell>
1486
  );
1487
  }
 
17
  import { PageHeader } from "../components/PageHeader";
18
  import { PageShell } from "../components/PageShell";
19
  import { InfoMenu } from "../components/InfoMenu";
20
+ import { LoadingBar } from "../components/LoadingBar";
21
+ import { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog";
22
 
23
  type FieldDef = {
24
  key: keyof TemplateFields;
 
84
  const [showGeneralColumns, setShowGeneralColumns] = useState(false);
85
  const [generalDirty, setGeneralDirty] = useState(false);
86
  const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
87
+ const [uploadProgress, setUploadProgress] = useState<number | null>(null);
88
+ const [pendingNavigationTarget, setPendingNavigationTarget] = useState<
89
+ string | null
90
+ >(null);
91
  const [photoSelections, setPhotoSelections] = useState<Record<number, string>>(
92
  {},
93
  );
 
102
  const excelInputRef = useRef<HTMLInputElement | null>(null);
103
  const jsonInputRef = useRef<HTMLInputElement | null>(null);
104
  const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
105
+ const uploadTimerRef = useRef<number | null>(null);
106
+
107
+ function clearUploadTimer() {
108
+ if (uploadTimerRef.current !== null) {
109
+ window.clearInterval(uploadTimerRef.current);
110
+ uploadTimerRef.current = null;
111
+ }
112
+ }
113
+
114
+ function startUploadProgress() {
115
+ clearUploadTimer();
116
+ setUploadProgress(6);
117
+ uploadTimerRef.current = window.setInterval(() => {
118
+ setUploadProgress((prev) => {
119
+ const value = typeof prev === "number" ? prev : 0;
120
+ if (value >= 92) return value;
121
+ return Math.min(92, value + Math.max(1, (92 - value) * 0.08));
122
+ });
123
+ }, 350);
124
+ }
125
 
126
  useEffect(() => {
127
  if (!sessionId) {
 
154
  load();
155
  }, [sessionId]);
156
 
157
+ useEffect(
158
+ () => () => {
159
+ clearUploadTimer();
160
+ },
161
+ [],
162
+ );
163
+
164
  async function refreshSession() {
165
  if (!sessionId) return;
166
  const data = await request<Session>(`/sessions/${sessionId}`);
 
626
  if (!sessionId) return;
627
  setIsUploading(true);
628
  setStatus("Uploading data file...");
629
+ startUploadProgress();
630
  try {
631
  const form = new FormData();
632
  form.append("file", file);
 
634
  await refreshSession();
635
  setStatus("Data file imported.");
636
  setGeneralDirty(false);
637
+ clearUploadTimer();
638
+ setUploadProgress(100);
639
  } catch (err) {
640
  const message =
641
  err instanceof Error ? err.message : "Failed to import data file.";
642
  setStatus(message);
643
+ clearUploadTimer();
644
+ setUploadProgress(null);
645
  } finally {
646
  setIsUploading(false);
647
+ window.setTimeout(() => {
648
+ setUploadProgress(null);
649
+ }, 500);
650
  }
651
  }
652
 
 
654
  if (!sessionId) return;
655
  setIsUploading(true);
656
  setStatus("Uploading JSON package...");
657
+ startUploadProgress();
658
  try {
659
  const form = new FormData();
660
  form.append("file", file);
 
662
  await refreshSession();
663
  setStatus("JSON package imported.");
664
  setGeneralDirty(false);
665
+ clearUploadTimer();
666
+ setUploadProgress(100);
667
  } catch (err) {
668
  const message =
669
  err instanceof Error ? err.message : "Failed to import JSON package.";
670
  setStatus(message);
671
+ clearUploadTimer();
672
+ setUploadProgress(null);
673
  } finally {
674
  setIsUploading(false);
675
+ window.setTimeout(() => {
676
+ setUploadProgress(null);
677
+ }, 500);
678
  }
679
  }
680
 
 
682
  if (!sessionId) return;
683
  setIsUploading(true);
684
  setStatus(`Uploading image ${file.name}...`);
685
+ startUploadProgress();
686
  const existing = new Set(
687
  (session?.uploads?.photos ?? []).map((photo) => photo.id),
688
  );
 
703
  } else {
704
  setStatus("Uploaded image. Refresh to see new file.");
705
  }
706
+ clearUploadTimer();
707
+ setUploadProgress(100);
708
  } catch (err) {
709
  const message =
710
  err instanceof Error ? err.message : "Failed to upload image.";
711
  setStatus(message);
712
+ clearUploadTimer();
713
+ setUploadProgress(null);
714
  } finally {
715
  setIsUploading(false);
716
+ window.setTimeout(() => {
717
+ setUploadProgress(null);
718
+ }, 500);
719
  }
720
  }
721
 
 
746
  navigate(target);
747
  }
748
 
749
+ function requestNavigation(target: string) {
750
+ if (saveState === "saving" || saveState === "pending" || isSaving) {
751
+ setPendingNavigationTarget(target);
752
+ return;
753
+ }
754
+ void saveAndNavigate(target);
755
+ }
756
+
757
+ async function waitForSaveAndContinue() {
758
+ if (!pendingNavigationTarget) return;
759
+ const target = pendingNavigationTarget;
760
+ setPendingNavigationTarget(null);
761
+ await saveAndNavigate(target);
762
+ }
763
+
764
+ function leaveWithoutWaiting() {
765
+ if (!pendingNavigationTarget) return;
766
+ const target = pendingNavigationTarget;
767
+ setPendingNavigationTarget(null);
768
+ navigate(target);
769
+ }
770
+
771
  const saveIndicator = useMemo(() => {
772
  const base =
773
  "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
 
815
  to={`/report-viewer${sessionQuery}`}
816
  onClick={(event) => {
817
  event.preventDefault();
818
+ requestNavigation(`/report-viewer${sessionQuery}`);
819
  }}
820
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
821
  >
 
838
  to={`/report-viewer${sessionQuery}`}
839
  onClick={(event) => {
840
  event.preventDefault();
841
+ requestNavigation(`/report-viewer${sessionQuery}`);
842
  }}
843
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
844
  >
 
850
  to={`/image-placement${sessionQuery}`}
851
  onClick={(event) => {
852
  event.preventDefault();
853
+ requestNavigation(`/image-placement${sessionQuery}`);
854
  }}
855
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
856
  >
 
862
  to={`/edit-report${sessionQuery}`}
863
  onClick={(event) => {
864
  event.preventDefault();
865
+ requestNavigation(`/edit-report${sessionQuery}`);
866
  }}
867
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
868
  >
 
874
  to={`/edit-layouts${sessionQuery}`}
875
  onClick={(event) => {
876
  event.preventDefault();
877
+ requestNavigation(`/edit-layouts${sessionQuery}`);
878
  }}
879
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
880
  >
 
886
  to={`/export${sessionQuery}`}
887
  onClick={(event) => {
888
  event.preventDefault();
889
+ requestNavigation(`/export${sessionQuery}`);
890
  }}
891
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
892
  >
 
939
  </div>
940
  </div>
941
  {status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
942
+ <LoadingBar
943
+ active={isSaving}
944
+ progress={null}
945
+ label="Saving changes"
946
+ className="mt-3"
947
+ />
948
+ <LoadingBar
949
+ active={isUploading}
950
+ progress={uploadProgress}
951
+ label={status || "Uploading files"}
952
+ className="mt-3"
953
+ />
954
  </section>
955
 
956
  <section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
 
1573
  </div>
1574
 
1575
  <PageFooter note="Tip: changes save automatically. Use apply row to keep pages consistent." />
1576
+ <SaveBeforeLeaveDialog
1577
+ open={Boolean(pendingNavigationTarget)}
1578
+ onCancel={() => setPendingNavigationTarget(null)}
1579
+ onProceed={leaveWithoutWaiting}
1580
+ onWait={waitForSaveAndContinue}
1581
+ />
1582
  </PageShell>
1583
  );
1584
  }
frontend/src/pages/ReviewSetupPage.tsx CHANGED
@@ -46,8 +46,7 @@ export default function ReviewSetupPage() {
46
  setSession(data);
47
  const initial = new Set(data.selected_photo_ids || []);
48
  const photoIds = (data.uploads?.photos ?? []).map((photo) => photo.id);
49
- const hasDataFiles = (data.uploads?.data_files ?? []).length > 0;
50
- if (!hasDataFiles && photoIds.length > 0 && initial.size < photoIds.length) {
51
  photoIds.forEach((photoId) => initial.add(photoId));
52
  }
53
  setSelectedPhotoIds(initial);
@@ -128,9 +127,12 @@ export default function ReviewSetupPage() {
128
  formData,
129
  );
130
  setSession(updated);
131
- if (updated.selected_photo_ids) {
132
- setSelectedPhotoIds(new Set(updated.selected_photo_ids));
 
 
133
  }
 
134
  } catch (err) {
135
  const message =
136
  err instanceof Error ? err.message : "Failed to upload data file.";
 
46
  setSession(data);
47
  const initial = new Set(data.selected_photo_ids || []);
48
  const photoIds = (data.uploads?.photos ?? []).map((photo) => photo.id);
49
+ if (photoIds.length > 0 && initial.size < photoIds.length) {
 
50
  photoIds.forEach((photoId) => initial.add(photoId));
51
  }
52
  setSelectedPhotoIds(initial);
 
127
  formData,
128
  );
129
  setSession(updated);
130
+ const nextSelected = new Set(updated.selected_photo_ids || []);
131
+ const allPhotoIds = (updated.uploads?.photos ?? []).map((photo) => photo.id);
132
+ if (nextSelected.size < allPhotoIds.length) {
133
+ allPhotoIds.forEach((photoId) => nextSelected.add(photoId));
134
  }
135
+ setSelectedPhotoIds(nextSelected);
136
  } catch (err) {
137
  const message =
138
  err instanceof Error ? err.message : "Failed to upload data file.";
frontend/src/pages/UploadPage.tsx CHANGED
@@ -16,6 +16,7 @@ import { formatBytes } from "../lib/format";
16
  import { formatDocNumber } from "../lib/report";
17
  import { setStoredSessionId } from "../lib/session";
18
  import { APP_VERSION } from "../lib/version";
 
19
  import type { Session } from "../types/session";
20
 
21
  type StatusTone = "idle" | "info" | "error";
@@ -31,6 +32,27 @@ export default function UploadPage() {
31
  const [recentSessions, setRecentSessions] = useState<Session[]>([]);
32
  const [dragActive, setDragActive] = useState(false);
33
  const [isSubmitting, setIsSubmitting] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  const fileSummary = useMemo(() => {
36
  if (!selectedFiles.length) {
@@ -55,6 +77,13 @@ export default function UploadPage() {
55
  loadSessions();
56
  }, []);
57
 
 
 
 
 
 
 
 
58
  function handleBrowseClick() {
59
  inputRef.current?.click();
60
  }
@@ -84,6 +113,7 @@ export default function UploadPage() {
84
  setIsSubmitting(true);
85
  setUploadStatus("Uploading files...");
86
  setStatusTone("idle");
 
87
 
88
  const formData = new FormData();
89
  formData.append("document_no", documentNo.trim());
@@ -92,9 +122,15 @@ export default function UploadPage() {
92
 
93
  try {
94
  const session = await postForm<Session>("/sessions", formData);
 
 
95
  setStoredSessionId(session.id);
96
- navigate(`/processing?session=${encodeURIComponent(session.id)}`);
 
 
97
  } catch (err) {
 
 
98
  const message =
99
  err instanceof Error ? err.message : "Upload failed. Please try again.";
100
  setUploadStatus(message);
@@ -335,6 +371,12 @@ export default function UploadPage() {
335
  {uploadStatus}
336
  </p>
337
  ) : null}
 
 
 
 
 
 
338
  </div>
339
  </section>
340
 
 
16
  import { formatDocNumber } from "../lib/report";
17
  import { setStoredSessionId } from "../lib/session";
18
  import { APP_VERSION } from "../lib/version";
19
+ import { LoadingBar } from "../components/LoadingBar";
20
  import type { Session } from "../types/session";
21
 
22
  type StatusTone = "idle" | "info" | "error";
 
32
  const [recentSessions, setRecentSessions] = useState<Session[]>([]);
33
  const [dragActive, setDragActive] = useState(false);
34
  const [isSubmitting, setIsSubmitting] = useState(false);
35
+ const [uploadProgress, setUploadProgress] = useState<number | null>(null);
36
+ const uploadTimerRef = useRef<number | null>(null);
37
+
38
+ function clearUploadTimer() {
39
+ if (uploadTimerRef.current !== null) {
40
+ window.clearInterval(uploadTimerRef.current);
41
+ uploadTimerRef.current = null;
42
+ }
43
+ }
44
+
45
+ function startUploadProgress() {
46
+ clearUploadTimer();
47
+ setUploadProgress(6);
48
+ uploadTimerRef.current = window.setInterval(() => {
49
+ setUploadProgress((prev) => {
50
+ const value = typeof prev === "number" ? prev : 0;
51
+ if (value >= 92) return value;
52
+ return Math.min(92, value + Math.max(1, (92 - value) * 0.08));
53
+ });
54
+ }, 350);
55
+ }
56
 
57
  const fileSummary = useMemo(() => {
58
  if (!selectedFiles.length) {
 
77
  loadSessions();
78
  }, []);
79
 
80
+ useEffect(
81
+ () => () => {
82
+ clearUploadTimer();
83
+ },
84
+ [],
85
+ );
86
+
87
  function handleBrowseClick() {
88
  inputRef.current?.click();
89
  }
 
113
  setIsSubmitting(true);
114
  setUploadStatus("Uploading files...");
115
  setStatusTone("idle");
116
+ startUploadProgress();
117
 
118
  const formData = new FormData();
119
  formData.append("document_no", documentNo.trim());
 
122
 
123
  try {
124
  const session = await postForm<Session>("/sessions", formData);
125
+ clearUploadTimer();
126
+ setUploadProgress(100);
127
  setStoredSessionId(session.id);
128
+ window.setTimeout(() => {
129
+ navigate(`/processing?session=${encodeURIComponent(session.id)}`);
130
+ }, 120);
131
  } catch (err) {
132
+ clearUploadTimer();
133
+ setUploadProgress(null);
134
  const message =
135
  err instanceof Error ? err.message : "Upload failed. Please try again.";
136
  setUploadStatus(message);
 
371
  {uploadStatus}
372
  </p>
373
  ) : null}
374
+ <LoadingBar
375
+ active={isSubmitting}
376
+ progress={uploadProgress}
377
+ label={uploadStatus || "Uploading files"}
378
+ className="mt-3"
379
+ />
380
  </div>
381
  </section>
382
 
server/app/api/routes/sessions.py CHANGED
@@ -25,7 +25,10 @@ from ..schemas import (
25
  from ...services import SessionStore
26
  from ...services.session_store import DATA_EXTS, IMAGE_EXTS
27
 
28
- from ...services.data_import import populate_session_from_data_files
 
 
 
29
  from ...services.pdf_reportlab import render_report_pdf
30
 
31
 
@@ -101,6 +104,7 @@ def create_session(
101
  session = store.add_uploads(session, saved_files)
102
  try:
103
  session = populate_session_from_data_files(store, session)
 
104
  except Exception:
105
  # Do not block upload if data parsing fails.
106
  pass
@@ -114,6 +118,10 @@ def get_session(session_id: str, store: SessionStore = Depends(get_session_store
114
  if not session:
115
  raise HTTPException(status_code=404, detail="Session not found.")
116
  store.ensure_sections(session)
 
 
 
 
117
  return _attach_urls(session)
118
 
119
 
@@ -150,6 +158,10 @@ def get_pages(session_id: str, store: SessionStore = Depends(get_session_store))
150
  session = store.get_session(session_id)
151
  if not session:
152
  raise HTTPException(status_code=404, detail="Session not found.")
 
 
 
 
153
  pages = store.ensure_pages(session)
154
  return PagesResponse(pages=pages)
155
 
@@ -176,6 +188,10 @@ def get_sections(
176
  session = store.get_session(session_id)
177
  if not session:
178
  raise HTTPException(status_code=404, detail="Session not found.")
 
 
 
 
179
  sections = store.ensure_sections(session)
180
  return SectionsResponse(sections=sections)
181
 
@@ -265,6 +281,7 @@ def upload_data_file(
265
  session = store.add_uploads(session, [saved_file])
266
  try:
267
  session = populate_session_from_data_files(store, session)
 
268
  except Exception:
269
  pass
270
  return _attach_urls(session)
@@ -295,6 +312,10 @@ def upload_photo(
295
  pass
296
 
297
  session = store.add_uploads(session, [saved_file])
 
 
 
 
298
  return _attach_urls(session)
299
 
300
 
@@ -387,6 +408,10 @@ def export_pdf(
387
  session = store.get_session(session_id)
388
  if not session:
389
  raise HTTPException(status_code=404, detail="Session not found.")
 
 
 
 
390
  sections = store.ensure_sections(session)
391
  export_path = Path(store.session_dir(session_id)) / "export.pdf"
392
  render_report_pdf(store, session, sections, export_path)
 
25
  from ...services import SessionStore
26
  from ...services.session_store import DATA_EXTS, IMAGE_EXTS
27
 
28
+ from ...services.data_import import (
29
+ populate_session_from_data_files,
30
+ reconcile_session_image_links,
31
+ )
32
  from ...services.pdf_reportlab import render_report_pdf
33
 
34
 
 
104
  session = store.add_uploads(session, saved_files)
105
  try:
106
  session = populate_session_from_data_files(store, session)
107
+ session = reconcile_session_image_links(store, session)
108
  except Exception:
109
  # Do not block upload if data parsing fails.
110
  pass
 
118
  if not session:
119
  raise HTTPException(status_code=404, detail="Session not found.")
120
  store.ensure_sections(session)
121
+ try:
122
+ session = reconcile_session_image_links(store, session)
123
+ except Exception:
124
+ pass
125
  return _attach_urls(session)
126
 
127
 
 
158
  session = store.get_session(session_id)
159
  if not session:
160
  raise HTTPException(status_code=404, detail="Session not found.")
161
+ try:
162
+ session = reconcile_session_image_links(store, session)
163
+ except Exception:
164
+ pass
165
  pages = store.ensure_pages(session)
166
  return PagesResponse(pages=pages)
167
 
 
188
  session = store.get_session(session_id)
189
  if not session:
190
  raise HTTPException(status_code=404, detail="Session not found.")
191
+ try:
192
+ session = reconcile_session_image_links(store, session)
193
+ except Exception:
194
+ pass
195
  sections = store.ensure_sections(session)
196
  return SectionsResponse(sections=sections)
197
 
 
281
  session = store.add_uploads(session, [saved_file])
282
  try:
283
  session = populate_session_from_data_files(store, session)
284
+ session = reconcile_session_image_links(store, session)
285
  except Exception:
286
  pass
287
  return _attach_urls(session)
 
312
  pass
313
 
314
  session = store.add_uploads(session, [saved_file])
315
+ try:
316
+ session = reconcile_session_image_links(store, session)
317
+ except Exception:
318
+ pass
319
  return _attach_urls(session)
320
 
321
 
 
408
  session = store.get_session(session_id)
409
  if not session:
410
  raise HTTPException(status_code=404, detail="Session not found.")
411
+ try:
412
+ session = reconcile_session_image_links(store, session)
413
+ except Exception:
414
+ pass
415
  sections = store.ensure_sections(session)
416
  export_path = Path(store.session_dir(session_id)) / "export.pdf"
417
  render_report_pdf(store, session, sections, export_path)
server/app/services/data_import.py CHANGED
@@ -2,9 +2,11 @@ from __future__ import annotations
2
 
3
  import csv
4
  import re
 
 
5
  from datetime import date, datetime
6
  from pathlib import Path
7
- from typing import Dict, Iterable, List, Optional
8
 
9
  from openpyxl import load_workbook
10
  import xlrd
@@ -16,6 +18,18 @@ GENERAL_SHEET = "general information"
16
  HEADINGS_SHEET = "headings"
17
  ITEMS_SHEET = "item spesific"
18
  ITEMS_SHEET_ALT = "item specific"
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
 
21
  def _normalize_text(value: str) -> str:
@@ -131,15 +145,43 @@ def _header_map(headers: List[str]) -> Dict[str, List[int]]:
131
  return mapping
132
 
133
 
134
- def _looks_like_image_name(value: str) -> bool:
135
- return bool(re.search(r"\.(jpg|jpeg|png|gif|webp)$", value.strip(), re.IGNORECASE))
 
 
 
 
 
136
 
137
 
138
- def _extract_image_names(value: str) -> List[str]:
139
  if not value:
140
  return []
141
- matches = re.findall(r"[^\s,;]+\\.(?:jpg|jpeg|png|gif|webp)", value, re.IGNORECASE)
142
- return [match.strip() for match in matches if match.strip()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
 
145
  def _find_reference_value(cells: List[object]) -> str:
@@ -251,21 +293,30 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
251
  figure_caption = " - ".join(
252
  [value for value in [figure_caption, figure_description] if value]
253
  )
254
- image_names = [
255
- _row_value(cells, image_index(1)),
256
- _row_value(cells, image_index(2)),
257
- _row_value(cells, image_index(3)),
258
- _row_value(cells, image_index(4)),
259
- _row_value(cells, image_index(5)),
260
- _row_value(cells, image_index(6)),
261
- ]
262
- image_names = [name for name in image_names if name]
 
 
 
 
 
 
 
263
  if len(image_names) < 2:
264
  for cell in cells:
265
  value = _cell_to_str(cell)
266
  if not value:
267
  continue
268
- candidates = _extract_image_names(value) if not _looks_like_image_name(value) else [value]
 
 
269
  for candidate in candidates:
270
  if candidate in image_names:
271
  continue
@@ -348,9 +399,13 @@ def _parse_xls(path: Path) -> Dict[str, object]:
348
 
349
 
350
  def _normalize_key(value: str) -> str:
351
- cleaned = _normalize_text(value)
352
- cleaned = re.sub(r"[^a-z0-9]", "", cleaned)
353
- return cleaned
 
 
 
 
354
 
355
 
356
  def _normalize_name(name: str) -> str:
@@ -358,35 +413,79 @@ def _normalize_name(name: str) -> str:
358
 
359
 
360
  def _normalize_stem(name: str) -> str:
361
- return _normalize_key(Path(name).stem)
 
 
 
 
 
 
362
 
363
 
364
- def _build_photo_lookup(uploads: List[dict]) -> Dict[str, str]:
365
- lookup: Dict[str, str] = {}
 
 
 
 
 
 
 
 
366
  for item in uploads:
367
  name = item.get("name") or ""
368
  file_id = item.get("id")
369
  if not name or not file_id:
370
  continue
371
- lookup.setdefault(_normalize_name(name), file_id)
372
- lookup.setdefault(_normalize_stem(name), file_id)
373
- return lookup
374
 
375
 
376
- def _photo_ids_for_names(names: List[str], lookup: Dict[str, str]) -> List[str]:
377
- ids: List[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  for raw in names:
379
- if not raw:
380
- continue
381
- for part in re.split(r"[;,]", str(raw)):
382
- part = part.strip()
383
- if not part:
384
- continue
385
- key = _normalize_name(part)
386
- match = lookup.get(key) or lookup.get(_normalize_stem(part))
387
- if match and match not in ids:
388
- ids.append(match)
389
- return ids
 
 
 
 
 
 
 
 
 
390
 
391
 
392
  def populate_session_from_data_files(
@@ -463,7 +562,12 @@ def populate_session_from_data_files(
463
  "figure_caption": item.get("figure_caption", ""),
464
  }
465
  image_names = item.get("image_names", []) or []
466
- photo_ids = _photo_ids_for_names(image_names, photo_lookup)
 
 
 
 
 
467
  for photo_id in photo_ids:
468
  if photo_id not in selected_photo_ids:
469
  selected_photo_ids.append(photo_id)
@@ -484,3 +588,64 @@ def populate_session_from_data_files(
484
  store.set_sections(session, sections)
485
 
486
  return session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import csv
4
  import re
5
+ import unicodedata
6
+ from dataclasses import dataclass
7
  from datetime import date, datetime
8
  from pathlib import Path
9
+ from typing import Dict, Iterable, List, Optional, Set, Tuple
10
 
11
  from openpyxl import load_workbook
12
  import xlrd
 
18
  HEADINGS_SHEET = "headings"
19
  ITEMS_SHEET = "item spesific"
20
  ITEMS_SHEET_ALT = "item specific"
21
+ IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
22
+ IMAGE_NAME_RE = re.compile(
23
+ r"(?i)([^,;\n\r]+?\.(?:jpe?g|png|gif|webp|bmp|tiff?))"
24
+ )
25
+ IMAGE_REF_SPLIT_RE = re.compile(r"[;\n\r]+")
26
+ IMAGE_REF_PREFIX_RE = re.compile(r"^(?:fig(?:ure)?|image)\s*\d*\s*[:\-]\s*", re.IGNORECASE)
27
+
28
+
29
+ @dataclass
30
+ class PhotoLookup:
31
+ by_exact: Dict[str, Set[str]]
32
+ by_stem: Dict[str, Set[str]]
33
 
34
 
35
  def _normalize_text(value: str) -> str:
 
145
  return mapping
146
 
147
 
148
+ def _clean_image_ref(value: str) -> str:
149
+ text = str(value or "").strip().strip(" \t\r\n'\"[](){}")
150
+ if not text:
151
+ return ""
152
+ text = text.replace("\\", "/").split("/")[-1]
153
+ text = IMAGE_REF_PREFIX_RE.sub("", text).strip()
154
+ return text
155
 
156
 
157
+ def _extract_image_names(value: str, *, allow_stem_without_ext: bool) -> List[str]:
158
  if not value:
159
  return []
160
+
161
+ found: List[str] = []
162
+ for section in IMAGE_REF_SPLIT_RE.split(str(value)):
163
+ chunks = [section]
164
+ if "," in section:
165
+ chunks = section.split(",")
166
+
167
+ for chunk in chunks:
168
+ cleaned = _clean_image_ref(chunk)
169
+ if not cleaned:
170
+ continue
171
+
172
+ explicit = IMAGE_NAME_RE.findall(cleaned)
173
+ if explicit:
174
+ for match in explicit:
175
+ candidate = _clean_image_ref(match)
176
+ if candidate and candidate not in found:
177
+ found.append(candidate)
178
+ continue
179
+
180
+ if allow_stem_without_ext and re.search(r"[A-Za-z0-9]", cleaned):
181
+ if cleaned not in found:
182
+ found.append(cleaned)
183
+
184
+ return found
185
 
186
 
187
  def _find_reference_value(cells: List[object]) -> str:
 
293
  figure_caption = " - ".join(
294
  [value for value in [figure_caption, figure_description] if value]
295
  )
296
+ image_names: List[str] = []
297
+ for number in range(1, 7):
298
+ raw_value = _row_value(cells, image_index(number))
299
+ if not raw_value:
300
+ continue
301
+ for candidate in _extract_image_names(
302
+ raw_value, allow_stem_without_ext=True
303
+ ):
304
+ if candidate in image_names:
305
+ continue
306
+ image_names.append(candidate)
307
+ if len(image_names) >= 6:
308
+ break
309
+ if len(image_names) >= 6:
310
+ break
311
+
312
  if len(image_names) < 2:
313
  for cell in cells:
314
  value = _cell_to_str(cell)
315
  if not value:
316
  continue
317
+ candidates = _extract_image_names(
318
+ value, allow_stem_without_ext=False
319
+ )
320
  for candidate in candidates:
321
  if candidate in image_names:
322
  continue
 
399
 
400
 
401
  def _normalize_key(value: str) -> str:
402
+ text = str(value or "").strip().replace("\\", "/").split("/")[-1]
403
+ if not text:
404
+ return ""
405
+ text = unicodedata.normalize("NFKD", text)
406
+ text = "".join(ch for ch in text if not unicodedata.combining(ch))
407
+ text = re.sub(r"\s+", " ", text).strip().lower()
408
+ return re.sub(r"[^a-z0-9]", "", text)
409
 
410
 
411
  def _normalize_name(name: str) -> str:
 
413
 
414
 
415
  def _normalize_stem(name: str) -> str:
416
+ normalized = str(name or "").replace("\\", "/").split("/")[-1].strip()
417
+ if not normalized:
418
+ return ""
419
+ suffix = Path(normalized).suffix.lower()
420
+ if suffix in IMAGE_EXTENSIONS:
421
+ normalized = normalized[: -len(suffix)]
422
+ return _normalize_key(normalized)
423
 
424
 
425
+ def _add_lookup_value(mapping: Dict[str, Set[str]], key: str, file_id: str) -> None:
426
+ if not key:
427
+ return
428
+ values = mapping.setdefault(key, set())
429
+ values.add(file_id)
430
+
431
+
432
+ def _build_photo_lookup(uploads: List[dict]) -> PhotoLookup:
433
+ exact: Dict[str, Set[str]] = {}
434
+ stem: Dict[str, Set[str]] = {}
435
  for item in uploads:
436
  name = item.get("name") or ""
437
  file_id = item.get("id")
438
  if not name or not file_id:
439
  continue
440
+ _add_lookup_value(exact, _normalize_name(name), file_id)
441
+ _add_lookup_value(stem, _normalize_stem(name), file_id)
442
+ return PhotoLookup(by_exact=exact, by_stem=stem)
443
 
444
 
445
+ def _resolve_photo_id(name: str, lookup: PhotoLookup) -> Optional[str]:
446
+ name = str(name or "").strip()
447
+ if not name:
448
+ return None
449
+
450
+ exact_key = _normalize_name(name)
451
+ stem_key = _normalize_stem(name)
452
+ has_suffix = Path(str(name).replace("\\", "/").split("/")[-1]).suffix.lower() in IMAGE_EXTENSIONS
453
+
454
+ exact_matches = lookup.by_exact.get(exact_key, set())
455
+ stem_matches = lookup.by_stem.get(stem_key, set())
456
+
457
+ if has_suffix and len(exact_matches) == 1:
458
+ return next(iter(exact_matches))
459
+ if len(stem_matches) == 1:
460
+ return next(iter(stem_matches))
461
+ if not has_suffix and len(exact_matches) == 1:
462
+ return next(iter(exact_matches))
463
+ return None
464
+
465
+
466
+ def _collect_image_refs(names: List[str]) -> List[str]:
467
+ refs: List[str] = []
468
  for raw in names:
469
+ for candidate in _extract_image_names(
470
+ str(raw), allow_stem_without_ext=True
471
+ ):
472
+ if candidate and candidate not in refs:
473
+ refs.append(candidate)
474
+ return refs
475
+
476
+
477
+ def _photo_ids_for_names(names: List[str], lookup: PhotoLookup) -> Tuple[List[str], List[str], List[str]]:
478
+ refs = _collect_image_refs(names)
479
+ ids: List[str] = []
480
+ unresolved: List[str] = []
481
+ for ref in refs:
482
+ resolved = _resolve_photo_id(ref, lookup)
483
+ if resolved:
484
+ if resolved not in ids:
485
+ ids.append(resolved)
486
+ elif ref not in unresolved:
487
+ unresolved.append(ref)
488
+ return ids, unresolved, refs
489
 
490
 
491
  def populate_session_from_data_files(
 
562
  "figure_caption": item.get("figure_caption", ""),
563
  }
564
  image_names = item.get("image_names", []) or []
565
+ photo_ids, unresolved_refs, normalized_refs = _photo_ids_for_names(
566
+ image_names, photo_lookup
567
+ )
568
+ template["image_name_refs"] = normalized_refs
569
+ if unresolved_refs:
570
+ template["unresolved_image_refs"] = unresolved_refs
571
  for photo_id in photo_ids:
572
  if photo_id not in selected_photo_ids:
573
  selected_photo_ids.append(photo_id)
 
588
  store.set_sections(session, sections)
589
 
590
  return session
591
+
592
+
593
+ def reconcile_session_image_links(store: SessionStore, session: dict) -> dict:
594
+ uploads = session.get("uploads", {}).get("photos", []) or []
595
+ if not uploads:
596
+ return session
597
+
598
+ lookup = _build_photo_lookup(uploads)
599
+ sections = store.ensure_sections(session)
600
+ changed = False
601
+ selected_photo_ids = list(session.get("selected_photo_ids") or [])
602
+
603
+ for section in sections:
604
+ pages = section.get("pages") or []
605
+ for page in pages:
606
+ template = page.get("template")
607
+ if not isinstance(template, dict):
608
+ continue
609
+
610
+ refs_raw = template.get("image_name_refs") or []
611
+ refs: List[str]
612
+ if isinstance(refs_raw, str):
613
+ refs = _collect_image_refs([refs_raw])
614
+ elif isinstance(refs_raw, list):
615
+ refs = _collect_image_refs([str(value) for value in refs_raw if value])
616
+ else:
617
+ refs = []
618
+
619
+ if not refs:
620
+ continue
621
+
622
+ resolved_ids, unresolved_refs, normalized_refs = _photo_ids_for_names(
623
+ refs, lookup
624
+ )
625
+ existing = [value for value in (page.get("photo_ids") or []) if isinstance(value, str) and value]
626
+ merged = existing + [photo_id for photo_id in resolved_ids if photo_id not in existing]
627
+
628
+ if merged != existing:
629
+ page["photo_ids"] = merged
630
+ changed = True
631
+ for photo_id in merged:
632
+ if photo_id not in selected_photo_ids:
633
+ selected_photo_ids.append(photo_id)
634
+ if template.get("image_name_refs") != normalized_refs:
635
+ template["image_name_refs"] = normalized_refs
636
+ changed = True
637
+ if unresolved_refs:
638
+ if template.get("unresolved_image_refs") != unresolved_refs:
639
+ template["unresolved_image_refs"] = unresolved_refs
640
+ changed = True
641
+ elif "unresolved_image_refs" in template:
642
+ template.pop("unresolved_image_refs", None)
643
+ changed = True
644
+
645
+ if selected_photo_ids != list(session.get("selected_photo_ids") or []):
646
+ session["selected_photo_ids"] = selected_photo_ids
647
+ changed = True
648
+
649
+ if changed:
650
+ store.update_session(session)
651
+ return session
server/app/services/pdf_reportlab.py CHANGED
@@ -1,9 +1,10 @@
1
  from __future__ import annotations
2
 
 
3
  import math
4
  from pathlib import Path
5
  import re
6
- from typing import Iterable, List, Optional
7
 
8
  from reportlab.lib import colors
9
  from reportlab.lib.pagesizes import A4
@@ -15,6 +16,12 @@ from reportlab.pdfgen import canvas
15
  from .session_store import SessionStore
16
 
17
 
 
 
 
 
 
 
18
  def _has_template_content(template: dict | None) -> bool:
19
  if not template:
20
  return False
@@ -40,7 +47,7 @@ def _wrap_lines(
40
  pdf: canvas.Canvas,
41
  text: str,
42
  width: float,
43
- max_lines: int,
44
  font: str,
45
  size: int,
46
  ) -> List[str]:
@@ -55,10 +62,10 @@ def _wrap_lines(
55
  if current:
56
  lines.append(" ".join(current))
57
  current = []
58
- if len(lines) >= max_lines:
59
  break
60
  remaining = word
61
- while remaining and len(lines) < max_lines:
62
  cut = len(remaining)
63
  while cut > 1 and pdf.stringWidth(remaining[:cut], font, size) > width:
64
  cut -= 1
@@ -73,10 +80,10 @@ def _wrap_lines(
73
  else:
74
  lines.append(" ".join(current))
75
  current = [word]
76
- if len(lines) >= max_lines:
77
  current = []
78
  break
79
- if current and len(lines) < max_lines:
80
  lines.append(" ".join(current))
81
  return lines
82
 
@@ -88,7 +95,7 @@ def _draw_wrapped(
88
  y: float,
89
  width: float,
90
  leading: float,
91
- max_lines: int,
92
  font: str,
93
  size: int,
94
  ) -> float:
@@ -102,6 +109,53 @@ def _draw_wrapped(
102
  return y
103
 
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  def _draw_centered_block(
106
  pdf: canvas.Canvas,
107
  lines: List[str],
@@ -193,18 +247,22 @@ def _resolve_logo_path(store: SessionStore, session: dict, raw: str) -> Optional
193
  value = _safe_text(raw)
194
  if not value:
195
  return None
 
 
 
 
196
  uploads = (session.get("uploads") or {}).get("photos") or []
197
- lower = value.lower()
198
  for item in uploads:
199
  if value == (item.get("id") or ""):
200
  path = store.resolve_upload_path(session, item.get("id"))
201
  if path and path.exists():
202
  return path
203
- name = (item.get("name") or "").lower()
204
  if not name:
205
  continue
206
  stem = name.rsplit(".", 1)[0]
207
- if lower == name or lower == stem:
208
  path = store.resolve_upload_path(session, item.get("id"))
209
  if path and path.exists():
210
  return path
@@ -218,32 +276,116 @@ def _draw_image_fit(
218
  y: float,
219
  width: float,
220
  height: float,
 
221
  ) -> bool:
222
- try:
223
- reader = ImageReader(str(image_path))
224
- iw, ih = reader.getSize()
225
- except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  try:
227
- img = Image.open(image_path)
228
- img.load()
229
- if img.mode not in ("RGB", "L"):
230
- img = img.convert("RGB")
231
- reader = ImageReader(img)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  iw, ih = reader.getSize()
233
  except Exception:
234
  try:
235
- pdf.drawImage(
236
- str(image_path),
237
- x,
238
- y,
239
- width,
240
- height,
241
- preserveAspectRatio=True,
242
- mask="auto",
243
- )
244
- return True
245
  except Exception:
246
- return False
 
 
 
 
 
 
 
 
 
 
 
 
247
  if iw <= 0 or ih <= 0:
248
  return False
249
  scale = min(width / iw, height / ih)
@@ -309,7 +451,8 @@ def render_report_pdf(
309
  red_200 = colors.HexColor("#fecaca")
310
 
311
  output_path.parent.mkdir(parents=True, exist_ok=True)
312
- pdf = canvas.Canvas(str(output_path), pagesize=A4)
 
313
 
314
  uploads = (session.get("uploads") or {}).get("photos") or []
315
  by_id = {item.get("id"): item for item in uploads if item.get("id")}
@@ -435,7 +578,15 @@ def render_report_pdf(
435
  logo_y = header_y - (header_h + logo_h) / 2
436
  logo_drawn = False
437
  if default_logo:
438
- logo_drawn = _draw_image_fit(pdf, default_logo, logo_x, logo_y, logo_w, logo_h)
 
 
 
 
 
 
 
 
439
  if not logo_drawn:
440
  pdf.setStrokeColor(colors.red)
441
  pdf.setLineWidth(1)
@@ -460,6 +611,7 @@ def render_report_pdf(
460
  client_logo_y,
461
  client_logo_w,
462
  client_logo_h,
 
463
  )
464
  else:
465
  pdf.setStrokeColor(gray_200)
@@ -660,15 +812,14 @@ def render_report_pdf(
660
  y -= 3 * mm
661
  pdf.setFillColor(gray_50)
662
  pdf.setStrokeColor(gray_200)
663
- cond_lines = _wrap_lines(
664
  pdf,
665
  condition or "",
666
- width - 2 * margin - 4 * mm,
667
- 4,
668
- "Helvetica",
669
- 9,
670
  )
671
- cond_h = max(10 * mm, (len(cond_lines) or 1) * leading + 4 * mm)
672
  cond_bottom = y - cond_h
673
  pdf.rect(margin, cond_bottom, width - 2 * margin, cond_h, stroke=1, fill=1)
674
  pdf.setLineWidth(2)
@@ -682,9 +833,9 @@ def render_report_pdf(
682
  text_center_x,
683
  cond_bottom,
684
  cond_h,
685
- leading,
686
  "Helvetica-Bold",
687
- 10,
688
  )
689
  y = cond_bottom - 4 * mm
690
 
@@ -696,15 +847,14 @@ def render_report_pdf(
696
  y -= 3 * mm
697
  pdf.setFillColor(gray_50)
698
  pdf.setStrokeColor(gray_200)
699
- action_lines = _wrap_lines(
700
  pdf,
701
  action or "",
702
- width - 2 * margin - 4 * mm,
703
- 4,
704
- "Helvetica",
705
- 9,
706
  )
707
- action_h = max(10 * mm, (len(action_lines) or 1) * leading + 4 * mm)
708
  action_bottom = y - action_h
709
  pdf.rect(margin, action_bottom, width - 2 * margin, action_h, stroke=1, fill=1)
710
  pdf.setLineWidth(2)
@@ -718,9 +868,9 @@ def render_report_pdf(
718
  text_center_x,
719
  action_bottom,
720
  action_h,
721
- leading,
722
  "Helvetica-Bold",
723
- 10,
724
  )
725
  y = action_bottom - 4 * mm
726
  else:
@@ -784,6 +934,7 @@ def render_report_pdf(
784
  image_y,
785
  cell_w - 4 * mm,
786
  image_h,
 
787
  )
788
  if caption_lines:
789
  pdf.setFillColor(gray_500)
 
1
  from __future__ import annotations
2
 
3
+ from io import BytesIO
4
  import math
5
  from pathlib import Path
6
  import re
7
+ from typing import Dict, Iterable, List, Optional, Tuple
8
 
9
  from reportlab.lib import colors
10
  from reportlab.lib.pagesizes import A4
 
16
  from .session_store import SessionStore
17
 
18
 
19
+ PDF_IMAGE_TARGET_DPI = 120
20
+ PDF_IMAGE_JPEG_QUALITY = 70
21
+ PDF_IMAGE_MAX_LONG_EDGE_PX = 1800
22
+ PDF_IMAGE_PNG_PRESERVE_MAX_PX = 640
23
+
24
+
25
  def _has_template_content(template: dict | None) -> bool:
26
  if not template:
27
  return False
 
47
  pdf: canvas.Canvas,
48
  text: str,
49
  width: float,
50
+ max_lines: Optional[int],
51
  font: str,
52
  size: int,
53
  ) -> List[str]:
 
62
  if current:
63
  lines.append(" ".join(current))
64
  current = []
65
+ if max_lines is not None and len(lines) >= max_lines:
66
  break
67
  remaining = word
68
+ while remaining and (max_lines is None or len(lines) < max_lines):
69
  cut = len(remaining)
70
  while cut > 1 and pdf.stringWidth(remaining[:cut], font, size) > width:
71
  cut -= 1
 
80
  else:
81
  lines.append(" ".join(current))
82
  current = [word]
83
+ if max_lines is not None and len(lines) >= max_lines:
84
  current = []
85
  break
86
+ if current and (max_lines is None or len(lines) < max_lines):
87
  lines.append(" ".join(current))
88
  return lines
89
 
 
95
  y: float,
96
  width: float,
97
  leading: float,
98
+ max_lines: Optional[int],
99
  font: str,
100
  size: int,
101
  ) -> float:
 
109
  return y
110
 
111
 
112
+ def _truncate_to_width(
113
+ pdf: canvas.Canvas,
114
+ text: str,
115
+ width: float,
116
+ font: str,
117
+ size: float,
118
+ ) -> str:
119
+ if pdf.stringWidth(text, font, size) <= width:
120
+ return text
121
+ ellipsis = "..."
122
+ if pdf.stringWidth(ellipsis, font, size) >= width:
123
+ return ""
124
+ cut = len(text)
125
+ while cut > 0:
126
+ candidate = f"{text[:cut].rstrip()}{ellipsis}"
127
+ if pdf.stringWidth(candidate, font, size) <= width:
128
+ return candidate
129
+ cut -= 1
130
+ return ""
131
+
132
+
133
+ def _fit_wrapped_text(
134
+ pdf: canvas.Canvas,
135
+ text: str,
136
+ width: float,
137
+ font: str,
138
+ preferred_size: float,
139
+ min_size: float = 7.0,
140
+ ) -> Tuple[List[str], float, float]:
141
+ size = float(preferred_size)
142
+ while size >= min_size:
143
+ lines = _wrap_lines(pdf, text, width, None, font, size)
144
+ if not lines:
145
+ return [], size, max(9.0, size + 1.0)
146
+ if max(pdf.stringWidth(line, font, size) for line in lines) <= width + 0.1:
147
+ leading = max(9.0, size + 1.0)
148
+ return lines, size, leading
149
+ size -= 0.5
150
+
151
+ lines = _wrap_lines(pdf, text, width, None, font, min_size)
152
+ safe_lines = [
153
+ _truncate_to_width(pdf, line, width, font, min_size) for line in lines
154
+ ]
155
+ leading = max(9.0, min_size + 1.0)
156
+ return safe_lines, min_size, leading
157
+
158
+
159
  def _draw_centered_block(
160
  pdf: canvas.Canvas,
161
  lines: List[str],
 
247
  value = _safe_text(raw)
248
  if not value:
249
  return None
250
+
251
+ def normalize_lookup(value_raw: str) -> str:
252
+ return re.sub(r"[^a-z0-9]", "", str(value_raw or "").strip().lower())
253
+
254
  uploads = (session.get("uploads") or {}).get("photos") or []
255
+ lookup_key = normalize_lookup(value)
256
  for item in uploads:
257
  if value == (item.get("id") or ""):
258
  path = store.resolve_upload_path(session, item.get("id"))
259
  if path and path.exists():
260
  return path
261
+ name = item.get("name") or ""
262
  if not name:
263
  continue
264
  stem = name.rsplit(".", 1)[0]
265
+ if lookup_key in {normalize_lookup(name), normalize_lookup(stem)}:
266
  path = store.resolve_upload_path(session, item.get("id"))
267
  if path and path.exists():
268
  return path
 
276
  y: float,
277
  width: float,
278
  height: float,
279
+ image_cache: Optional[Dict[Tuple[str, int, int], Tuple[ImageReader, int, int, BytesIO]]] = None,
280
  ) -> bool:
281
+ def _resample_filter() -> int:
282
+ resampling = getattr(Image, "Resampling", None)
283
+ if resampling is not None:
284
+ return int(resampling.LANCZOS)
285
+ return int(Image.LANCZOS)
286
+
287
+ def _prepare_reader() -> Optional[Tuple[ImageReader, int, int]]:
288
+ if image_cache is None:
289
+ return None
290
+
291
+ key = (
292
+ str(image_path.resolve()),
293
+ max(1, int(round(width))),
294
+ max(1, int(round(height))),
295
+ )
296
+ cached = image_cache.get(key)
297
+ if cached:
298
+ return cached[0], cached[1], cached[2]
299
+
300
  try:
301
+ with Image.open(image_path) as source:
302
+ source.load()
303
+ image = source.copy()
304
+ except Exception:
305
+ return None
306
+
307
+ if image.width <= 0 or image.height <= 0:
308
+ return None
309
+
310
+ resample = _resample_filter()
311
+ target_w_px = max(1, int(round(width * PDF_IMAGE_TARGET_DPI / 72.0)))
312
+ target_h_px = max(1, int(round(height * PDF_IMAGE_TARGET_DPI / 72.0)))
313
+ image.thumbnail((target_w_px, target_h_px), resample)
314
+
315
+ long_edge = max(image.width, image.height)
316
+ if long_edge > PDF_IMAGE_MAX_LONG_EDGE_PX:
317
+ ratio = PDF_IMAGE_MAX_LONG_EDGE_PX / float(long_edge)
318
+ image = image.resize(
319
+ (
320
+ max(1, int(round(image.width * ratio))),
321
+ max(1, int(round(image.height * ratio))),
322
+ ),
323
+ resample,
324
+ )
325
+
326
+ suffix = image_path.suffix.lower()
327
+ buffer = BytesIO()
328
+ try:
329
+ if suffix == ".png" and max(image.width, image.height) <= PDF_IMAGE_PNG_PRESERVE_MAX_PX:
330
+ image.save(buffer, format="PNG", optimize=True)
331
+ else:
332
+ if image.mode in ("RGBA", "LA", "P"):
333
+ alpha_source = image.convert("RGBA")
334
+ flattened = Image.new("RGB", alpha_source.size, (255, 255, 255))
335
+ flattened.paste(alpha_source, mask=alpha_source.split()[-1])
336
+ image = flattened
337
+ elif image.mode not in ("RGB", "L"):
338
+ image = image.convert("RGB")
339
+ image.save(
340
+ buffer,
341
+ format="JPEG",
342
+ quality=PDF_IMAGE_JPEG_QUALITY,
343
+ optimize=True,
344
+ progressive=True,
345
+ )
346
+ buffer.seek(0)
347
+ reader = ImageReader(buffer)
348
+ iw, ih = reader.getSize()
349
+ image_cache[key] = (reader, iw, ih, buffer)
350
+ return reader, iw, ih
351
+ except Exception:
352
+ buffer.close()
353
+ return None
354
+
355
+ prepared = _prepare_reader()
356
+ if prepared:
357
+ reader, iw, ih = prepared
358
+ else:
359
+ reader = None
360
+ iw = 0
361
+ ih = 0
362
+
363
+ if reader is None:
364
+ try:
365
+ reader = ImageReader(str(image_path))
366
  iw, ih = reader.getSize()
367
  except Exception:
368
  try:
369
+ img = Image.open(image_path)
370
+ img.load()
371
+ if img.mode not in ("RGB", "L"):
372
+ img = img.convert("RGB")
373
+ reader = ImageReader(img)
374
+ iw, ih = reader.getSize()
 
 
 
 
375
  except Exception:
376
+ try:
377
+ pdf.drawImage(
378
+ str(image_path),
379
+ x,
380
+ y,
381
+ width,
382
+ height,
383
+ preserveAspectRatio=True,
384
+ mask="auto",
385
+ )
386
+ return True
387
+ except Exception:
388
+ return False
389
  if iw <= 0 or ih <= 0:
390
  return False
391
  scale = min(width / iw, height / ih)
 
451
  red_200 = colors.HexColor("#fecaca")
452
 
453
  output_path.parent.mkdir(parents=True, exist_ok=True)
454
+ pdf = canvas.Canvas(str(output_path), pagesize=A4, pageCompression=1)
455
+ image_cache: Dict[Tuple[str, int, int], Tuple[ImageReader, int, int, BytesIO]] = {}
456
 
457
  uploads = (session.get("uploads") or {}).get("photos") or []
458
  by_id = {item.get("id"): item for item in uploads if item.get("id")}
 
578
  logo_y = header_y - (header_h + logo_h) / 2
579
  logo_drawn = False
580
  if default_logo:
581
+ logo_drawn = _draw_image_fit(
582
+ pdf,
583
+ default_logo,
584
+ logo_x,
585
+ logo_y,
586
+ logo_w,
587
+ logo_h,
588
+ image_cache=image_cache,
589
+ )
590
  if not logo_drawn:
591
  pdf.setStrokeColor(colors.red)
592
  pdf.setLineWidth(1)
 
611
  client_logo_y,
612
  client_logo_w,
613
  client_logo_h,
614
+ image_cache=image_cache,
615
  )
616
  else:
617
  pdf.setStrokeColor(gray_200)
 
812
  y -= 3 * mm
813
  pdf.setFillColor(gray_50)
814
  pdf.setStrokeColor(gray_200)
815
+ cond_lines, cond_font_size, cond_leading = _fit_wrapped_text(
816
  pdf,
817
  condition or "",
818
+ width - 2 * margin - 8 * mm,
819
+ "Helvetica-Bold",
820
+ 10.0,
 
821
  )
822
+ cond_h = max(10 * mm, (len(cond_lines) or 1) * cond_leading + 4 * mm)
823
  cond_bottom = y - cond_h
824
  pdf.rect(margin, cond_bottom, width - 2 * margin, cond_h, stroke=1, fill=1)
825
  pdf.setLineWidth(2)
 
833
  text_center_x,
834
  cond_bottom,
835
  cond_h,
836
+ cond_leading,
837
  "Helvetica-Bold",
838
+ cond_font_size,
839
  )
840
  y = cond_bottom - 4 * mm
841
 
 
847
  y -= 3 * mm
848
  pdf.setFillColor(gray_50)
849
  pdf.setStrokeColor(gray_200)
850
+ action_lines, action_font_size, action_leading = _fit_wrapped_text(
851
  pdf,
852
  action or "",
853
+ width - 2 * margin - 8 * mm,
854
+ "Helvetica-Bold",
855
+ 10.0,
 
856
  )
857
+ action_h = max(10 * mm, (len(action_lines) or 1) * action_leading + 4 * mm)
858
  action_bottom = y - action_h
859
  pdf.rect(margin, action_bottom, width - 2 * margin, action_h, stroke=1, fill=1)
860
  pdf.setLineWidth(2)
 
868
  text_center_x,
869
  action_bottom,
870
  action_h,
871
+ action_leading,
872
  "Helvetica-Bold",
873
+ action_font_size,
874
  )
875
  y = action_bottom - 4 * mm
876
  else:
 
934
  image_y,
935
  cell_w - 4 * mm,
936
  image_h,
937
+ image_cache=image_cache,
938
  )
939
  if caption_lines:
940
  pdf.setFillColor(gray_500)
server/app/services/session_store.py CHANGED
@@ -20,6 +20,9 @@ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
20
  DOC_EXTS = {".pdf", ".doc", ".docx"}
21
  DATA_EXTS = {".csv", ".xls", ".xlsx"}
22
  EXIF_NORMALIZE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
 
 
 
23
  SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
24
  BUILTIN_PAGE_TEMPLATES = [
25
  {
@@ -92,14 +95,42 @@ def _normalize_uploaded_photo(path: Path) -> None:
92
  try:
93
  with Image.open(path) as image:
94
  normalized = ImageOps.exif_transpose(image)
95
- format_name = image.format
96
- if normalized.mode in ("RGBA", "LA", "P") and format_name in {"JPEG", "JPG"}:
97
- normalized = normalized.convert("RGB")
98
- save_kwargs = {"exif": b""}
99
- if format_name in {"JPEG", "JPG"}:
100
- save_kwargs["quality"] = 95
101
- save_kwargs["optimize"] = True
102
- normalized.save(path, format=format_name, **save_kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  except (UnidentifiedImageError, OSError, ValueError, TypeError):
104
  # Keep original bytes if the file cannot be decoded by Pillow.
105
  return
 
20
  DOC_EXTS = {".pdf", ".doc", ".docx"}
21
  DATA_EXTS = {".csv", ".xls", ".xlsx"}
22
  EXIF_NORMALIZE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
23
+ UPLOAD_IMAGE_MAX_LONG_EDGE_PX = 2400
24
+ UPLOAD_JPEG_QUALITY = 82
25
+ UPLOAD_WEBP_QUALITY = 80
26
  SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
27
  BUILTIN_PAGE_TEMPLATES = [
28
  {
 
95
  try:
96
  with Image.open(path) as image:
97
  normalized = ImageOps.exif_transpose(image)
98
+
99
+ resampling = getattr(Image, "Resampling", None)
100
+ lanczos = resampling.LANCZOS if resampling is not None else Image.LANCZOS
101
+ long_edge = max(normalized.width, normalized.height)
102
+ if long_edge > UPLOAD_IMAGE_MAX_LONG_EDGE_PX:
103
+ ratio = UPLOAD_IMAGE_MAX_LONG_EDGE_PX / float(long_edge)
104
+ normalized = normalized.resize(
105
+ (
106
+ max(1, int(round(normalized.width * ratio))),
107
+ max(1, int(round(normalized.height * ratio))),
108
+ ),
109
+ lanczos,
110
+ )
111
+
112
+ if ext in {".jpg", ".jpeg"}:
113
+ if normalized.mode in ("RGBA", "LA", "P"):
114
+ normalized = normalized.convert("RGB")
115
+ normalized.save(
116
+ path,
117
+ format="JPEG",
118
+ quality=UPLOAD_JPEG_QUALITY,
119
+ optimize=True,
120
+ progressive=True,
121
+ exif=b"",
122
+ )
123
+ elif ext == ".webp":
124
+ if normalized.mode not in ("RGB", "RGBA"):
125
+ normalized = normalized.convert("RGB")
126
+ normalized.save(
127
+ path,
128
+ format="WEBP",
129
+ quality=UPLOAD_WEBP_QUALITY,
130
+ method=6,
131
+ )
132
+ else: # png
133
+ normalized.save(path, format="PNG", optimize=True)
134
  except (UnidentifiedImageError, OSError, ValueError, TypeError):
135
  # Keep original bytes if the file cannot be decoded by Pillow.
136
  return