ChristopherJKoen commited on
Commit
25058c7
·
1 Parent(s): 28157f5

Add page template system and UI

Browse files

Introduce a reusable page template feature and related UX. Adds a new LayoutTemplatesPage for creating, editing and persisting custom page templates and a built-in template list (frontend/src/lib/pageTemplates.ts). Integrates templates into EditLayoutsPage (select defaults per-section, pick template per-page, apply templates when adding pages) and exposes a Templates navigation link. Adds page_template field and PageTemplateDefinition types to session types. Enhance report editor to support selecting/uploading a company logo for page templates and update photo-splitting logic to use primary (2) and continuation (6) photo limits. ImagePlacementPage now offers photo-layout presets and drag/drop application. UploadPage includes an Excel data template download (public/templates/repex-data-input-template.xlsx) and guidance. Server API/schemas/services updated to handle page_templates and related import/upload flows. Misc: various UI and state wiring to merge, persist and apply templates across the app.

frontend/public/templates/repex-data-input-template.xlsx ADDED
Binary file (6.09 kB). View file
 
frontend/src/App.tsx CHANGED
@@ -8,6 +8,7 @@ import ImagePlacementPage from "./pages/ImagePlacementPage";
8
  import InputDataPage from "./pages/InputDataPage";
9
  import EditReportPage from "./pages/EditReportPage";
10
  import EditLayoutsPage from "./pages/EditLayoutsPage";
 
11
  import ExportPage from "./pages/ExportPage";
12
  import RatingsInfoPage from "./pages/RatingsInfoPage";
13
 
@@ -23,6 +24,7 @@ export default function App() {
23
  <Route path="/input-data" element={<InputDataPage />} />
24
  <Route path="/edit-report" element={<EditReportPage />} />
25
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
 
26
  <Route path="/export" element={<ExportPage />} />
27
  <Route path="/info/ratings" element={<RatingsInfoPage />} />
28
  <Route path="*" element={<Navigate to="/" replace />} />
 
8
  import InputDataPage from "./pages/InputDataPage";
9
  import EditReportPage from "./pages/EditReportPage";
10
  import EditLayoutsPage from "./pages/EditLayoutsPage";
11
+ import LayoutTemplatesPage from "./pages/LayoutTemplatesPage";
12
  import ExportPage from "./pages/ExportPage";
13
  import RatingsInfoPage from "./pages/RatingsInfoPage";
14
 
 
24
  <Route path="/input-data" element={<InputDataPage />} />
25
  <Route path="/edit-report" element={<EditReportPage />} />
26
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
27
+ <Route path="/edit-layouts/templates" element={<LayoutTemplatesPage />} />
28
  <Route path="/export" element={<ExportPage />} />
29
  <Route path="/info/ratings" element={<RatingsInfoPage />} />
30
  <Route path="*" element={<Navigate to="/" replace />} />
frontend/src/components/report-editor.js CHANGED
@@ -18,6 +18,9 @@ const PRIORITY_SCALE = {
18
  M: { label: "Monitor", className: "bg-blue-200 text-blue-800 border-blue-200" },
19
  };
20
 
 
 
 
21
  function parseScaleCode(value) {
22
  const match = String(value || "").trim().match(/^[A-Za-z0-9]+/);
23
  return match ? match[0].toUpperCase() : String(value || "").trim().toUpperCase();
@@ -409,6 +412,22 @@ class ReportEditor extends HTMLElement {
409
  </div>
410
  </div>
411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  <div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
413
  <div class="font-semibold text-gray-800 mb-1">Keyboard shortcuts</div>
414
  <ul class="list-disc pl-4 space-y-1">
@@ -437,9 +456,11 @@ class ReportEditor extends HTMLElement {
437
  this.$propsText = this.querySelector("[data-props-text]");
438
  this.$propsRect = this.querySelector("[data-props-rect]");
439
  this.$propsImage = this.querySelector("[data-props-image]");
 
440
 
441
  this.$imgFile = this.querySelector('[data-file="image"]');
442
  this.$replaceFile = this.querySelector('[data-file="replace"]');
 
443
 
444
  if (this.$canvas && "ResizeObserver" in window) {
445
  this._resizeObserver = new ResizeObserver(() => {
@@ -500,6 +521,24 @@ class ReportEditor extends HTMLElement {
500
  this.querySelector('[data-btn="replace-image"]').addEventListener("click", () => this.$replaceFile.click());
501
  this.$replaceFile.addEventListener("change", (e) => this._handleImageUpload(e, "replace"));
502
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  // canvas interactions
504
  this.$canvas.addEventListener("pointerdown", (e) => this.onCanvasPointerDown(e));
505
  window.addEventListener("pointermove", (e) => this.onPointerMove(e));
@@ -561,6 +600,7 @@ class ReportEditor extends HTMLElement {
561
  this.renderCanvas();
562
  this.updateCanvasScale();
563
  this.updatePropsPanel();
 
564
  this.updateUndoRedoButtons();
565
  this._refreshIcons();
566
  }
@@ -742,6 +782,75 @@ class ReportEditor extends HTMLElement {
742
  return "";
743
  }
744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
745
  _photoKey(photo) {
746
  if (!photo) return "";
747
  return photo.id || photo.url || photo.name || "";
@@ -1300,16 +1409,14 @@ class ReportEditor extends HTMLElement {
1300
  : [];
1301
  if (!photoIds.length) return [normalized];
1302
 
1303
- const chunks = [];
1304
- for (let i = 0; i < photoIds.length; i += 2) {
1305
- chunks.push(photoIds.slice(i, i + 2));
1306
- }
1307
-
1308
- if (chunks.length <= 1) {
1309
- return [{ ...normalized, photo_ids: chunks[0] || [], variant: normalized.variant }];
1310
- }
1311
-
1312
  if (normalized.variant === "photos") {
 
 
 
 
 
 
 
1313
  return chunks.map((chunk, idx) => {
1314
  if (idx === 0) {
1315
  return { ...normalized, photo_ids: chunk, variant: "photos" };
@@ -1318,12 +1425,20 @@ class ReportEditor extends HTMLElement {
1318
  });
1319
  }
1320
 
 
 
 
 
 
 
 
1321
  const basePage = {
1322
  ...normalized,
1323
- photo_ids: chunks[0],
1324
  variant: normalized.variant || "full",
1325
  };
1326
- const extraPages = chunks.slice(1).map((chunk) =>
 
1327
  this._buildPhotoContinuation(normalized, chunk),
1328
  );
1329
  return [basePage, ...extraPages];
 
18
  M: { label: "Monitor", className: "bg-blue-200 text-blue-800 border-blue-200" },
19
  };
20
 
21
+ const MAX_PHOTOS_PRIMARY_PAGE = 2;
22
+ const MAX_PHOTOS_CONTINUATION_PAGE = 6;
23
+
24
  function parseScaleCode(value) {
25
  const match = String(value || "").trim().match(/^[A-Za-z0-9]+/);
26
  return match ? match[0].toUpperCase() : String(value || "").trim().toUpperCase();
 
412
  </div>
413
  </div>
414
 
415
+ <div class="mt-4 rounded-lg border border-gray-200 p-3">
416
+ <div class="text-xs font-semibold text-gray-600 mb-2">Company Logo (Top Right)</div>
417
+ <p class="text-[11px] text-gray-500 mb-2">
418
+ Select an uploaded image or upload a new one for this page.
419
+ </p>
420
+ <select data-prop-template-logo
421
+ class="w-full rounded-lg border border-gray-200 bg-white px-2 py-2 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200">
422
+ <option value="">No logo (show placeholder)</option>
423
+ </select>
424
+ <button data-btn="upload-template-logo"
425
+ class="mt-2 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">
426
+ <i data-feather="upload" class="h-4 w-4"></i> Upload logo
427
+ </button>
428
+ <input data-file="template-logo" type="file" accept="image/*" class="hidden" />
429
+ </div>
430
+
431
  <div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
432
  <div class="font-semibold text-gray-800 mb-1">Keyboard shortcuts</div>
433
  <ul class="list-disc pl-4 space-y-1">
 
456
  this.$propsText = this.querySelector("[data-props-text]");
457
  this.$propsRect = this.querySelector("[data-props-rect]");
458
  this.$propsImage = this.querySelector("[data-props-image]");
459
+ this.$templateLogoSelect = this.querySelector("[data-prop-template-logo]");
460
 
461
  this.$imgFile = this.querySelector('[data-file="image"]');
462
  this.$replaceFile = this.querySelector('[data-file="replace"]');
463
+ this.$templateLogoFile = this.querySelector('[data-file="template-logo"]');
464
 
465
  if (this.$canvas && "ResizeObserver" in window) {
466
  this._resizeObserver = new ResizeObserver(() => {
 
521
  this.querySelector('[data-btn="replace-image"]').addEventListener("click", () => this.$replaceFile.click());
522
  this.$replaceFile.addEventListener("change", (e) => this._handleImageUpload(e, "replace"));
523
 
524
+ // company logo controls
525
+ this.querySelector('[data-btn="upload-template-logo"]').addEventListener("click", () => {
526
+ this.$templateLogoFile.click();
527
+ });
528
+ this.$templateLogoFile.addEventListener("change", (e) => {
529
+ const file = e.target.files && e.target.files[0];
530
+ if (file) {
531
+ this._uploadTemplateLogo(file);
532
+ }
533
+ e.target.value = "";
534
+ });
535
+ this.$templateLogoSelect.addEventListener("change", () => {
536
+ const template = this._getTemplate();
537
+ template.company_logo = this.$templateLogoSelect.value;
538
+ this._savePages();
539
+ this.renderCanvas();
540
+ });
541
+
542
  // canvas interactions
543
  this.$canvas.addEventListener("pointerdown", (e) => this.onCanvasPointerDown(e));
544
  window.addEventListener("pointermove", (e) => this.onPointerMove(e));
 
600
  this.renderCanvas();
601
  this.updateCanvasScale();
602
  this.updatePropsPanel();
603
+ this._syncTemplateLogoOptions();
604
  this.updateUndoRedoButtons();
605
  this._refreshIcons();
606
  }
 
782
  return "";
783
  }
784
 
785
+ _syncTemplateLogoOptions() {
786
+ if (!this.$templateLogoSelect) return;
787
+ const session = this.state.payload || {};
788
+ const template = this._getTemplate();
789
+ const current = String(template.company_logo || "");
790
+ const uploads = (session && session.uploads && session.uploads.photos) || [];
791
+
792
+ const previousValue = this.$templateLogoSelect.value;
793
+ this.$templateLogoSelect.innerHTML = "";
794
+
795
+ const noneOption = document.createElement("option");
796
+ noneOption.value = "";
797
+ noneOption.textContent = "No logo (show placeholder)";
798
+ this.$templateLogoSelect.appendChild(noneOption);
799
+
800
+ uploads.forEach((photo) => {
801
+ const option = document.createElement("option");
802
+ option.value = String(photo.name || photo.id || "");
803
+ option.textContent = String(photo.name || photo.id || "Unnamed image");
804
+ if (option.value) {
805
+ this.$templateLogoSelect.appendChild(option);
806
+ }
807
+ });
808
+
809
+ const nextValue = current || previousValue || "";
810
+ this.$templateLogoSelect.value = nextValue;
811
+ }
812
+
813
+ async _uploadTemplateLogo(file) {
814
+ const base = this._apiRoot();
815
+ if (!base || !this.sessionId) {
816
+ this._toast("Missing session");
817
+ return;
818
+ }
819
+ const payload = this.state.payload || {};
820
+ const existing = new Set(
821
+ ((payload.uploads && payload.uploads.photos) || []).map((photo) => photo.id),
822
+ );
823
+
824
+ try {
825
+ this._toast("Uploading logo...");
826
+ const form = new FormData();
827
+ form.append("file", file);
828
+ const res = await fetch(`${base}/sessions/${this.sessionId}/uploads`, {
829
+ method: "POST",
830
+ body: form,
831
+ });
832
+ if (!res.ok) {
833
+ throw new Error("Upload failed");
834
+ }
835
+ const updated = await res.json();
836
+ this.state.payload = updated;
837
+ const photos = (updated.uploads && updated.uploads.photos) || [];
838
+ const uploaded =
839
+ photos.find((photo) => !existing.has(photo.id)) ||
840
+ photos.find((photo) => photo.name === file.name);
841
+ const template = this._getTemplate();
842
+ template.company_logo = uploaded
843
+ ? String(uploaded.name || uploaded.id || "")
844
+ : template.company_logo || "";
845
+ this._savePages();
846
+ this.renderCanvas();
847
+ this._syncTemplateLogoOptions();
848
+ this._toast("Logo uploaded");
849
+ } catch {
850
+ this._toast("Logo upload failed");
851
+ }
852
+ }
853
+
854
  _photoKey(photo) {
855
  if (!photo) return "";
856
  return photo.id || photo.url || photo.name || "";
 
1409
  : [];
1410
  if (!photoIds.length) return [normalized];
1411
 
 
 
 
 
 
 
 
 
 
1412
  if (normalized.variant === "photos") {
1413
+ const chunks = [];
1414
+ for (let i = 0; i < photoIds.length; i += MAX_PHOTOS_CONTINUATION_PAGE) {
1415
+ chunks.push(photoIds.slice(i, i + MAX_PHOTOS_CONTINUATION_PAGE));
1416
+ }
1417
+ if (chunks.length <= 1) {
1418
+ return [{ ...normalized, photo_ids: chunks[0] || [], variant: "photos" }];
1419
+ }
1420
  return chunks.map((chunk, idx) => {
1421
  if (idx === 0) {
1422
  return { ...normalized, photo_ids: chunk, variant: "photos" };
 
1425
  });
1426
  }
1427
 
1428
+ const baseChunk = photoIds.slice(0, MAX_PHOTOS_PRIMARY_PAGE);
1429
+ const extraIds = photoIds.slice(MAX_PHOTOS_PRIMARY_PAGE);
1430
+ const continuationChunks = [];
1431
+ for (let i = 0; i < extraIds.length; i += MAX_PHOTOS_CONTINUATION_PAGE) {
1432
+ continuationChunks.push(extraIds.slice(i, i + MAX_PHOTOS_CONTINUATION_PAGE));
1433
+ }
1434
+
1435
  const basePage = {
1436
  ...normalized,
1437
+ photo_ids: baseChunk,
1438
  variant: normalized.variant || "full",
1439
  };
1440
+ if (!continuationChunks.length) return [basePage];
1441
+ const extraPages = continuationChunks.map((chunk) =>
1442
  this._buildPhotoContinuation(normalized, chunk),
1443
  );
1444
  return [basePage, ...extraPages];
frontend/src/lib/pageTemplates.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, PageTemplateDefinition } from "../types/session";
2
+
3
+ export const BUILTIN_PAGE_TEMPLATES: PageTemplateDefinition[] = [
4
+ {
5
+ id: "repex:standard",
6
+ name: "Standard Job Sheet",
7
+ description: "Observations + up to two photos.",
8
+ blank: false,
9
+ variant: "full",
10
+ source: "builtin",
11
+ },
12
+ {
13
+ id: "repex:photos",
14
+ name: "Photo Continuation",
15
+ description: "Photo-only continuation page.",
16
+ blank: false,
17
+ variant: "photos",
18
+ source: "builtin",
19
+ },
20
+ {
21
+ id: "repex:blank",
22
+ name: "Blank Canvas",
23
+ description: "Blank white page.",
24
+ blank: true,
25
+ variant: "full",
26
+ source: "builtin",
27
+ },
28
+ ];
29
+
30
+ export function mergePageTemplates(
31
+ customTemplates?: PageTemplateDefinition[],
32
+ ): PageTemplateDefinition[] {
33
+ const byId = new Map<string, PageTemplateDefinition>();
34
+ BUILTIN_PAGE_TEMPLATES.forEach((template) => {
35
+ byId.set(template.id, template);
36
+ });
37
+ (customTemplates ?? []).forEach((template) => {
38
+ if (!template?.id) return;
39
+ byId.set(template.id, { ...template, source: "custom" });
40
+ });
41
+ return Array.from(byId.values());
42
+ }
43
+
44
+ export function inferPageTemplateId(page?: Page | null): string {
45
+ if (page?.page_template) return page.page_template;
46
+ if (page?.blank) return "repex:blank";
47
+ if (page?.variant === "photos") return "repex:photos";
48
+ return "repex:standard";
49
+ }
50
+
51
+ export function resolvePageTemplate(
52
+ page: Page | null | undefined,
53
+ templates: PageTemplateDefinition[],
54
+ ): PageTemplateDefinition {
55
+ const id = inferPageTemplateId(page);
56
+ return (
57
+ templates.find((template) => template.id === id) ??
58
+ BUILTIN_PAGE_TEMPLATES[0]
59
+ );
60
+ }
61
+
62
+ export function applyPageTemplateToPage(
63
+ page: Page,
64
+ template: PageTemplateDefinition,
65
+ ): Page {
66
+ const next: Page = {
67
+ ...page,
68
+ page_template: template.id,
69
+ blank: Boolean(template.blank),
70
+ variant: template.variant ?? "full",
71
+ };
72
+ if (template.photo_layout) {
73
+ next.photo_layout = template.photo_layout;
74
+ }
75
+ return next;
76
+ }
77
+
78
+ export function createCustomTemplateId() {
79
+ return `tpl_${crypto.randomUUID().replace(/-/g, "")}`;
80
+ }
frontend/src/lib/sections.ts CHANGED
@@ -9,7 +9,8 @@ export type FlatPage = {
9
  flatIndex: number;
10
  };
11
 
12
- const MAX_PHOTOS_PER_PAGE = 2;
 
13
 
14
  function cloneTemplate(template?: Page["template"]): Page["template"] {
15
  if (!template) return undefined;
@@ -45,36 +46,42 @@ function splitPagePhotos(page: Page): Page[] {
45
  return [normalized];
46
  }
47
 
48
- const chunks: string[][] = [];
49
- for (let i = 0; i < photoIds.length; i += MAX_PHOTOS_PER_PAGE) {
50
- chunks.push(photoIds.slice(i, i + MAX_PHOTOS_PER_PAGE));
51
- }
52
-
53
- if (chunks.length <= 1) {
54
- return [
55
- {
56
- ...normalized,
57
- photo_ids: chunks[0] ?? [],
58
- variant: normalized.variant,
59
- },
60
- ];
61
- }
62
-
63
  if (normalized.variant === "photos") {
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  return chunks.map((chunk, idx) => {
65
- if (idx === 0) {
66
- return { ...normalized, photo_ids: chunk, variant: "photos" };
67
- }
68
  return buildPhotoContinuation(normalized, chunk);
69
  });
70
  }
71
 
 
 
 
 
 
 
 
72
  const basePage: Page = {
73
  ...normalized,
74
- photo_ids: chunks[0],
75
  variant: normalized.variant ?? "full",
76
  };
77
- const extraPages = chunks.slice(1).map((chunk) =>
 
 
 
78
  buildPhotoContinuation(normalized, chunk),
79
  );
80
  return [basePage, ...extraPages];
 
9
  flatIndex: number;
10
  };
11
 
12
+ const MAX_PHOTOS_PRIMARY_PAGE = 2;
13
+ const MAX_PHOTOS_CONTINUATION_PAGE = 6;
14
 
15
  function cloneTemplate(template?: Page["template"]): Page["template"] {
16
  if (!template) return undefined;
 
46
  return [normalized];
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  if (normalized.variant === "photos") {
50
+ const chunks: string[][] = [];
51
+ for (let i = 0; i < photoIds.length; i += MAX_PHOTOS_CONTINUATION_PAGE) {
52
+ chunks.push(photoIds.slice(i, i + MAX_PHOTOS_CONTINUATION_PAGE));
53
+ }
54
+ if (chunks.length <= 1) {
55
+ return [
56
+ {
57
+ ...normalized,
58
+ photo_ids: chunks[0] ?? [],
59
+ variant: "photos",
60
+ },
61
+ ];
62
+ }
63
  return chunks.map((chunk, idx) => {
64
+ if (idx === 0) return { ...normalized, photo_ids: chunk, variant: "photos" };
 
 
65
  return buildPhotoContinuation(normalized, chunk);
66
  });
67
  }
68
 
69
+ const baseChunk = photoIds.slice(0, MAX_PHOTOS_PRIMARY_PAGE);
70
+ const extraIds = photoIds.slice(MAX_PHOTOS_PRIMARY_PAGE);
71
+ const continuationChunks: string[][] = [];
72
+ for (let i = 0; i < extraIds.length; i += MAX_PHOTOS_CONTINUATION_PAGE) {
73
+ continuationChunks.push(extraIds.slice(i, i + MAX_PHOTOS_CONTINUATION_PAGE));
74
+ }
75
+
76
  const basePage: Page = {
77
  ...normalized,
78
+ photo_ids: baseChunk,
79
  variant: normalized.variant ?? "full",
80
  };
81
+ if (!continuationChunks.length) {
82
+ return [basePage];
83
+ }
84
+ const extraPages = continuationChunks.map((chunk) =>
85
  buildPhotoContinuation(normalized, chunk),
86
  );
87
  return [basePage, ...extraPages];
frontend/src/pages/EditLayoutsPage.tsx CHANGED
@@ -15,10 +15,19 @@ import {
15
  } from "react-feather";
16
 
17
  import { putJson, request } from "../lib/api";
 
 
 
 
 
18
  import { BASE_W } from "../lib/report";
19
  import { ensureSections, flattenSections, replacePage } from "../lib/sections";
20
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
21
- import type { JobsheetSection, Session } from "../types/session";
 
 
 
 
22
  import { PageFooter } from "../components/PageFooter";
23
  import { PageHeader } from "../components/PageHeader";
24
  import { PageShell } from "../components/PageShell";
@@ -38,6 +47,12 @@ export default function EditLayoutsPage() {
38
  const [saveState, setSaveState] = useState<
39
  "saved" | "saving" | "pending" | "error"
40
  >("saved");
 
 
 
 
 
 
41
  const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
42
  {},
43
  );
@@ -57,11 +72,21 @@ export default function EditLayoutsPage() {
57
  try {
58
  const data = await request<Session>(`/sessions/${sessionId}`);
59
  setSession(data);
 
60
  const sectionResp = await request<{ sections: JobsheetSection[] }>(
61
  `/sessions/${sessionId}/sections`,
62
  );
63
  const normalized = ensureSections(sectionResp.sections);
64
  setSections(normalized);
 
 
 
 
 
 
 
 
 
65
  lastSavedRef.current = JSON.stringify(normalized);
66
  pendingSaveRef.current = lastSavedRef.current;
67
  setSaveState("saved");
@@ -86,26 +111,19 @@ export default function EditLayoutsPage() {
86
  const previewWidth = 220;
87
  const previewScale = previewWidth / BASE_W;
88
 
89
- const PHOTO_LAYOUT_PRESETS = [
90
- {
91
- id: "auto",
92
- label: "Auto fit",
93
- description: "Let the system choose 1 or 2 columns per page.",
94
- preview: "auto",
95
- },
96
- {
97
- id: "two-column",
98
- label: "Two column",
99
- description: "Force a two-column photo grid.",
100
- preview: "two-column",
101
- },
102
- {
103
- id: "stacked",
104
- label: "Stacked",
105
- description: "Stack images vertically for tall photos.",
106
- preview: "stacked",
107
- },
108
- ] as const;
109
 
110
  function hasExplicitPhotos(source: JobsheetSection[]) {
111
  return source.some((section) =>
@@ -151,6 +169,17 @@ export default function EditLayoutsPage() {
151
  }, 800);
152
  }, [sections, sessionId, isSaving]);
153
 
 
 
 
 
 
 
 
 
 
 
 
154
  async function saveLayout(
155
  next: JobsheetSection[],
156
  nextSelectedIds?: string[],
@@ -196,6 +225,7 @@ export default function EditLayoutsPage() {
196
  setSaveState("saved");
197
  if (updatedSession) {
198
  setSession(updatedSession);
 
199
  }
200
  if (!silent) {
201
  setStatus("Layout saved.");
@@ -227,12 +257,13 @@ export default function EditLayoutsPage() {
227
  }
228
 
229
  async function handleAddSection() {
 
230
  const next = [
231
  ...sections,
232
  {
233
  id: crypto.randomUUID(),
234
  title: `Section ${sections.length + 1}`,
235
- pages: [{ items: [] }],
236
  },
237
  ];
238
  await saveLayout(next);
@@ -254,11 +285,21 @@ export default function EditLayoutsPage() {
254
  }
255
 
256
  async function handleAddPage(sectionIndex: number) {
 
 
 
257
  const next = sections.map((section, idx) => {
258
  if (idx !== sectionIndex) return section;
 
 
 
 
 
 
 
259
  return {
260
  ...section,
261
- pages: [...(section.pages ?? []), { items: [], blank: true }],
262
  };
263
  });
264
  if (session) {
@@ -271,6 +312,27 @@ export default function EditLayoutsPage() {
271
  await saveLayout(next);
272
  }
273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  async function handleRemovePage(sectionIndex: number, pageIndex: number) {
275
  const next = sections.map((section, idx) => {
276
  if (idx !== sectionIndex) return section;
@@ -308,22 +370,6 @@ export default function EditLayoutsPage() {
308
  await saveLayout(next);
309
  }
310
 
311
- function applyPhotoLayout(
312
- sectionIndex: number,
313
- pageIndex: number,
314
- layout: "auto" | "two-column" | "stacked",
315
- ) {
316
- if (!canModify) return;
317
- setSections((prev) => {
318
- const section = prev[sectionIndex];
319
- const page = section?.pages?.[pageIndex];
320
- if (!section || !page) return prev;
321
- const nextPage = { ...page, photo_layout: layout };
322
- return replacePage(prev, sectionIndex, pageIndex, nextPage);
323
- });
324
- setStatus(`Applied ${layout.replace("-", " ")} layout to page ${pageIndex + 1}.`);
325
- }
326
-
327
  async function saveAndNavigate(target: string) {
328
  if (!sessionId) {
329
  navigate(target);
@@ -455,6 +501,17 @@ export default function EditLayoutsPage() {
455
  Edit Page Layouts
456
  </span>
457
 
 
 
 
 
 
 
 
 
 
 
 
458
  <Link
459
  to={`/export${sessionQuery}`}
460
  onClick={(event) => {
@@ -512,53 +569,6 @@ export default function EditLayoutsPage() {
512
  ) : null}
513
  </section>
514
 
515
- <section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
516
- <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
517
- <div>
518
- <h2 className="text-lg font-semibold text-gray-900">
519
- Image Layout Presets
520
- </h2>
521
- <p className="text-sm text-gray-600">
522
- Drag a preset onto a page to set how photos are arranged.
523
- </p>
524
- </div>
525
- </div>
526
- <div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
527
- {PHOTO_LAYOUT_PRESETS.map((preset) => (
528
- <div
529
- key={preset.id}
530
- draggable
531
- onDragStart={(event) => {
532
- event.dataTransfer.setData("text/plain", preset.id);
533
- event.dataTransfer.effectAllowed = "copy";
534
- }}
535
- className="rounded-lg border border-gray-200 bg-gray-50 p-3 cursor-grab active:cursor-grabbing"
536
- >
537
- <div className="text-sm font-semibold text-gray-900">
538
- {preset.label}
539
- </div>
540
- <div className="text-xs text-gray-600">{preset.description}</div>
541
- <div className="mt-2">
542
- {preset.preview === "stacked" ? (
543
- <div className="grid grid-cols-1 gap-1">
544
- <div className="h-6 rounded bg-gray-200" />
545
- <div className="h-6 rounded bg-gray-200" />
546
- </div>
547
- ) : (
548
- <div className="grid grid-cols-2 gap-1">
549
- <div className="h-6 rounded bg-gray-200" />
550
- <div className="h-6 rounded bg-gray-200" />
551
- </div>
552
- )}
553
- </div>
554
- <div className="mt-2 text-[11px] text-gray-500">
555
- Drag to apply
556
- </div>
557
- </div>
558
- ))}
559
- </div>
560
- </section>
561
-
562
  <section className="space-y-6">
563
  {sections.map((section, sectionIndex) => {
564
  const isCollapsed = collapsedSections[section.id] ?? false;
@@ -613,6 +623,23 @@ export default function EditLayoutsPage() {
613
  </>
614
  )}
615
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
  <button
617
  type="button"
618
  onClick={() => handleAddPage(sectionIndex)}
@@ -622,16 +649,29 @@ export default function EditLayoutsPage() {
622
  <Plus className="h-4 w-4" />
623
  Add page
624
  </button>
 
 
 
 
 
 
 
 
 
 
625
  </div>
626
  </div>
627
 
628
  {!isCollapsed ? (
629
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
630
- {(section.pages ?? []).map((page, pageIndex) => (
631
- <div
632
- key={`${section.id}-page-${pageIndex}`}
633
- className="rounded-lg border border-gray-200 bg-white p-3"
634
- >
 
 
 
635
  <div className="flex items-center justify-between mb-2">
636
  <div>
637
  <div className="text-sm font-semibold text-gray-900">
@@ -672,6 +712,35 @@ export default function EditLayoutsPage() {
672
  </div>
673
  </div>
674
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  <div
676
  className="mx-auto rounded-lg border border-gray-200 bg-white"
677
  style={{ width: previewWidth }}
@@ -691,29 +760,9 @@ export default function EditLayoutsPage() {
691
  adaptive
692
  />
693
  </div>
694
-
695
- <div
696
- className="mt-2 rounded-md border border-dashed border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600"
697
- onDragOver={(event) => {
698
- event.preventDefault();
699
- event.dataTransfer.dropEffect = "copy";
700
- }}
701
- onDrop={(event) => {
702
- event.preventDefault();
703
- const layout = event.dataTransfer.getData("text/plain");
704
- if (
705
- layout === "auto" ||
706
- layout === "two-column" ||
707
- layout === "stacked"
708
- ) {
709
- applyPhotoLayout(sectionIndex, pageIndex, layout);
710
- }
711
- }}
712
- >
713
- Drop image layout here - Current: {page.photo_layout ?? "auto"}
714
- </div>
715
  </div>
716
- ))}
 
717
  </div>
718
  ) : (
719
  <div className="rounded-md border border-dashed border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">
 
15
  } from "react-feather";
16
 
17
  import { putJson, request } from "../lib/api";
18
+ import {
19
+ applyPageTemplateToPage,
20
+ inferPageTemplateId,
21
+ mergePageTemplates,
22
+ } from "../lib/pageTemplates";
23
  import { BASE_W } from "../lib/report";
24
  import { ensureSections, flattenSections, replacePage } from "../lib/sections";
25
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
26
+ import type {
27
+ JobsheetSection,
28
+ PageTemplateDefinition,
29
+ Session,
30
+ } from "../types/session";
31
  import { PageFooter } from "../components/PageFooter";
32
  import { PageHeader } from "../components/PageHeader";
33
  import { PageShell } from "../components/PageShell";
 
47
  const [saveState, setSaveState] = useState<
48
  "saved" | "saving" | "pending" | "error"
49
  >("saved");
50
+ const [pageTemplates, setPageTemplates] = useState<PageTemplateDefinition[]>(
51
+ mergePageTemplates(undefined),
52
+ );
53
+ const [newPageTemplateBySection, setNewPageTemplateBySection] = useState<
54
+ Record<string, string>
55
+ >({});
56
  const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
57
  {},
58
  );
 
72
  try {
73
  const data = await request<Session>(`/sessions/${sessionId}`);
74
  setSession(data);
75
+ setPageTemplates(mergePageTemplates(data.page_templates));
76
  const sectionResp = await request<{ sections: JobsheetSection[] }>(
77
  `/sessions/${sessionId}/sections`,
78
  );
79
  const normalized = ensureSections(sectionResp.sections);
80
  setSections(normalized);
81
+ setNewPageTemplateBySection((prev) => {
82
+ const next = { ...prev };
83
+ normalized.forEach((section) => {
84
+ if (!next[section.id]) {
85
+ next[section.id] = "repex:blank";
86
+ }
87
+ });
88
+ return next;
89
+ });
90
  lastSavedRef.current = JSON.stringify(normalized);
91
  pendingSaveRef.current = lastSavedRef.current;
92
  setSaveState("saved");
 
111
  const previewWidth = 220;
112
  const previewScale = previewWidth / BASE_W;
113
 
114
+ function findTemplate(templateId?: string) {
115
+ const fallback = mergePageTemplates(undefined)[0];
116
+ const selectedId = (templateId || "").trim();
117
+ if (selectedId) {
118
+ const match = pageTemplates.find((template) => template.id === selectedId);
119
+ if (match) return match;
120
+ }
121
+ return (
122
+ pageTemplates.find((template) => template.id === "repex:standard") ??
123
+ pageTemplates[0] ??
124
+ fallback
125
+ );
126
+ }
 
 
 
 
 
 
 
127
 
128
  function hasExplicitPhotos(source: JobsheetSection[]) {
129
  return source.some((section) =>
 
169
  }, 800);
170
  }, [sections, sessionId, isSaving]);
171
 
172
+ useEffect(() => {
173
+ if (!sections.length) return;
174
+ setNewPageTemplateBySection((prev) => {
175
+ const next: Record<string, string> = {};
176
+ sections.forEach((section) => {
177
+ next[section.id] = prev[section.id] ?? "repex:blank";
178
+ });
179
+ return next;
180
+ });
181
+ }, [sections]);
182
+
183
  async function saveLayout(
184
  next: JobsheetSection[],
185
  nextSelectedIds?: string[],
 
225
  setSaveState("saved");
226
  if (updatedSession) {
227
  setSession(updatedSession);
228
+ setPageTemplates(mergePageTemplates(updatedSession.page_templates));
229
  }
230
  if (!silent) {
231
  setStatus("Layout saved.");
 
257
  }
258
 
259
  async function handleAddSection() {
260
+ const baseTemplate = findTemplate("repex:standard");
261
  const next = [
262
  ...sections,
263
  {
264
  id: crypto.randomUUID(),
265
  title: `Section ${sections.length + 1}`,
266
+ pages: [applyPageTemplateToPage({ items: [] }, baseTemplate)],
267
  },
268
  ];
269
  await saveLayout(next);
 
285
  }
286
 
287
  async function handleAddPage(sectionIndex: number) {
288
+ const section = sections[sectionIndex];
289
+ const templateId = section ? newPageTemplateBySection[section.id] : undefined;
290
+ const selectedTemplate = findTemplate(templateId);
291
  const next = sections.map((section, idx) => {
292
  if (idx !== sectionIndex) return section;
293
+ const baseTemplate = {
294
+ ...(section.pages?.[section.pages.length - 1]?.template ?? {}),
295
+ };
296
+ const nextPage = applyPageTemplateToPage(
297
+ { items: [], template: baseTemplate },
298
+ selectedTemplate,
299
+ );
300
  return {
301
  ...section,
302
+ pages: [...(section.pages ?? []), nextPage],
303
  };
304
  });
305
  if (session) {
 
312
  await saveLayout(next);
313
  }
314
 
315
+ function handleChangePageTemplate(
316
+ sectionIndex: number,
317
+ pageIndex: number,
318
+ templateId: string,
319
+ ) {
320
+ const selectedTemplate = findTemplate(templateId);
321
+ if (!selectedTemplate) return;
322
+ setSections((prev) => {
323
+ const section = prev[sectionIndex];
324
+ const page = section?.pages?.[pageIndex];
325
+ if (!section || !page) return prev;
326
+ const nextPage = applyPageTemplateToPage(
327
+ { ...page, template: { ...(page.template ?? {}) } },
328
+ selectedTemplate,
329
+ );
330
+ return replacePage(prev, sectionIndex, pageIndex, nextPage);
331
+ });
332
+ const label = selectedTemplate.name || selectedTemplate.id;
333
+ setStatus(`Applied "${label}" template to page ${pageIndex + 1}.`);
334
+ }
335
+
336
  async function handleRemovePage(sectionIndex: number, pageIndex: number) {
337
  const next = sections.map((section, idx) => {
338
  if (idx !== sectionIndex) return section;
 
370
  await saveLayout(next);
371
  }
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  async function saveAndNavigate(target: string) {
374
  if (!sessionId) {
375
  navigate(target);
 
501
  Edit Page Layouts
502
  </span>
503
 
504
+ <Link
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
+ >
512
+ Templates
513
+ </Link>
514
+
515
  <Link
516
  to={`/export${sessionQuery}`}
517
  onClick={(event) => {
 
569
  ) : null}
570
  </section>
571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  <section className="space-y-6">
573
  {sections.map((section, sectionIndex) => {
574
  const isCollapsed = collapsedSections[section.id] ?? false;
 
623
  </>
624
  )}
625
  </button>
626
+ <select
627
+ value={newPageTemplateBySection[section.id] ?? "repex:blank"}
628
+ onChange={(event) =>
629
+ setNewPageTemplateBySection((prev) => ({
630
+ ...prev,
631
+ [section.id]: event.target.value,
632
+ }))
633
+ }
634
+ className="rounded-lg border border-gray-200 bg-white px-2 py-2 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200"
635
+ title="Template used when adding a page to this section"
636
+ >
637
+ {pageTemplates.map((template) => (
638
+ <option key={`section-${section.id}-template-${template.id}`} value={template.id}>
639
+ {template.name}
640
+ </option>
641
+ ))}
642
+ </select>
643
  <button
644
  type="button"
645
  onClick={() => handleAddPage(sectionIndex)}
 
649
  <Plus className="h-4 w-4" />
650
  Add page
651
  </button>
652
+ <Link
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
+ >
660
+ Manage templates
661
+ </Link>
662
  </div>
663
  </div>
664
 
665
  {!isCollapsed ? (
666
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
667
+ {(section.pages ?? []).map((page, pageIndex) => {
668
+ const currentTemplateId = inferPageTemplateId(page);
669
+ const currentTemplate = findTemplate(currentTemplateId);
670
+ return (
671
+ <div
672
+ key={`${section.id}-page-${pageIndex}`}
673
+ className="rounded-lg border border-gray-200 bg-white p-3"
674
+ >
675
  <div className="flex items-center justify-between mb-2">
676
  <div>
677
  <div className="text-sm font-semibold text-gray-900">
 
712
  </div>
713
  </div>
714
 
715
+ <div className="mb-2 rounded-md border border-gray-200 bg-gray-50 p-2">
716
+ <label className="block text-[11px] font-semibold uppercase tracking-wide text-gray-500">
717
+ Page Template
718
+ </label>
719
+ <select
720
+ value={currentTemplateId}
721
+ onChange={(event) =>
722
+ handleChangePageTemplate(
723
+ sectionIndex,
724
+ pageIndex,
725
+ event.target.value,
726
+ )
727
+ }
728
+ className="mt-1 w-full rounded-md border border-gray-200 bg-white px-2 py-1 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200"
729
+ >
730
+ {pageTemplates.map((template) => (
731
+ <option
732
+ key={`page-${section.id}-${pageIndex}-${template.id}`}
733
+ value={template.id}
734
+ >
735
+ {template.name}
736
+ </option>
737
+ ))}
738
+ </select>
739
+ <div className="mt-1 text-[11px] text-gray-500">
740
+ {currentTemplate.description || "No description."}
741
+ </div>
742
+ </div>
743
+
744
  <div
745
  className="mx-auto rounded-lg border border-gray-200 bg-white"
746
  style={{ width: previewWidth }}
 
760
  adaptive
761
  />
762
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
763
  </div>
764
+ );
765
+ })}
766
  </div>
767
  ) : (
768
  <div className="rounded-md border border-dashed border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">
frontend/src/pages/ImagePlacementPage.tsx CHANGED
@@ -45,6 +45,27 @@ export default function ImagePlacementPage() {
45
  const uploadInputRef = useRef<HTMLInputElement | null>(null);
46
  const logoInputRef = useRef<HTMLInputElement | null>(null);
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  useEffect(() => {
49
  if (!sessionId) {
50
  setError("No active session found. Return to upload to continue.");
@@ -97,24 +118,6 @@ export default function ImagePlacementPage() {
97
  setPageIndex((idx) => Math.min(Math.max(0, idx), totalPages - 1));
98
  }, [totalPages]);
99
 
100
- useEffect(() => {
101
- setPhotoSelection("");
102
- setLogoSelection(template?.company_logo ?? "");
103
- }, [pageIndex, template?.company_logo]);
104
-
105
- useEffect(() => {
106
- const handler = (event: KeyboardEvent) => {
107
- if (event.key === "ArrowRight") {
108
- setPageIndex((idx) => Math.min(totalPages - 1, idx + 1));
109
- }
110
- if (event.key === "ArrowLeft") {
111
- setPageIndex((idx) => Math.max(0, idx - 1));
112
- }
113
- };
114
- window.addEventListener("keydown", handler);
115
- return () => window.removeEventListener("keydown", handler);
116
- }, [totalPages]);
117
-
118
  const page = flatPages[pageIndex]?.page ?? null;
119
  const pageEntry = flatPages[pageIndex] ?? null;
120
  const sectionLabel = flatPages[pageIndex]?.sectionTitle
@@ -167,6 +170,24 @@ export default function ImagePlacementPage() {
167
  }, [linkedPhotoIds, session?.uploads?.photos]);
168
  const logoOptions = session?.uploads?.photos ?? [];
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  const saveIndicator = useMemo(() => {
171
  const base =
172
  "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
@@ -245,6 +266,20 @@ export default function ImagePlacementPage() {
245
  void persistSections(nextSections);
246
  }
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  function addPhotoToPage(photoId: string) {
249
  if (!photoId || !pageEntry) return;
250
  const ids = [...pagePhotoIds];
@@ -507,6 +542,72 @@ export default function ImagePlacementPage() {
507
 
508
  {pageEntry ? (
509
  <section className="mt-6 no-print" aria-label="Page images">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
  <div className="flex flex-wrap items-center justify-between gap-3">
511
  <div>
512
  <h3 className="text-lg font-semibold text-gray-900">Page Images</h3>
@@ -580,8 +681,8 @@ export default function ImagePlacementPage() {
580
  </span>
581
  </div>
582
  <p className="mt-2 text-[11px] text-gray-500">
583
- Adding more than 2 images will automatically create a continuation
584
- page for the overflow.
585
  </p>
586
  </div>
587
 
 
45
  const uploadInputRef = useRef<HTMLInputElement | null>(null);
46
  const logoInputRef = useRef<HTMLInputElement | null>(null);
47
 
48
+ const PHOTO_LAYOUT_PRESETS = [
49
+ {
50
+ id: "auto" as const,
51
+ label: "Auto fit",
52
+ description: "Automatically balance photos across rows.",
53
+ preview: "auto" as const,
54
+ },
55
+ {
56
+ id: "two-column" as const,
57
+ label: "Two column",
58
+ description: "Force two columns for photo placement.",
59
+ preview: "two-column" as const,
60
+ },
61
+ {
62
+ id: "stacked" as const,
63
+ label: "Stacked",
64
+ description: "Stack photos in one column.",
65
+ preview: "stacked" as const,
66
+ },
67
+ ];
68
+
69
  useEffect(() => {
70
  if (!sessionId) {
71
  setError("No active session found. Return to upload to continue.");
 
118
  setPageIndex((idx) => Math.min(Math.max(0, idx), totalPages - 1));
119
  }, [totalPages]);
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  const page = flatPages[pageIndex]?.page ?? null;
122
  const pageEntry = flatPages[pageIndex] ?? null;
123
  const sectionLabel = flatPages[pageIndex]?.sectionTitle
 
170
  }, [linkedPhotoIds, session?.uploads?.photos]);
171
  const logoOptions = session?.uploads?.photos ?? [];
172
 
173
+ useEffect(() => {
174
+ setPhotoSelection("");
175
+ setLogoSelection(template?.company_logo ?? "");
176
+ }, [pageIndex, template?.company_logo]);
177
+
178
+ useEffect(() => {
179
+ const handler = (event: KeyboardEvent) => {
180
+ if (event.key === "ArrowRight") {
181
+ setPageIndex((idx) => Math.min(totalPages - 1, idx + 1));
182
+ }
183
+ if (event.key === "ArrowLeft") {
184
+ setPageIndex((idx) => Math.max(0, idx - 1));
185
+ }
186
+ };
187
+ window.addEventListener("keydown", handler);
188
+ return () => window.removeEventListener("keydown", handler);
189
+ }, [totalPages]);
190
+
191
  const saveIndicator = useMemo(() => {
192
  const base =
193
  "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
 
266
  void persistSections(nextSections);
267
  }
268
 
269
+ function updatePageLayout(layout: "auto" | "two-column" | "stacked") {
270
+ if (!pageEntry) return;
271
+ const nextPage = { ...pageEntry.page, photo_layout: layout };
272
+ const nextSections = replacePage(
273
+ sections,
274
+ pageEntry.sectionIndex,
275
+ pageEntry.pageIndex,
276
+ nextPage,
277
+ );
278
+ setSections(nextSections);
279
+ setStatus(`Applied ${layout.replace("-", " ")} layout.`);
280
+ void persistSections(nextSections);
281
+ }
282
+
283
  function addPhotoToPage(photoId: string) {
284
  if (!photoId || !pageEntry) return;
285
  const ids = [...pagePhotoIds];
 
542
 
543
  {pageEntry ? (
544
  <section className="mt-6 no-print" aria-label="Page images">
545
+ <div className="mb-4 rounded-lg border border-gray-200 bg-white p-3">
546
+ <div className="text-xs font-semibold text-gray-600 uppercase">
547
+ Photo layout presets
548
+ </div>
549
+ <p className="mt-1 text-[11px] text-gray-500">
550
+ This controls how images are arranged for the current page.
551
+ </p>
552
+ <div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
553
+ {PHOTO_LAYOUT_PRESETS.map((preset) => (
554
+ <button
555
+ key={`photo-layout-${preset.id}`}
556
+ type="button"
557
+ onClick={() => updatePageLayout(preset.id)}
558
+ draggable
559
+ onDragStart={(event) => {
560
+ event.dataTransfer.setData("text/plain", preset.id);
561
+ event.dataTransfer.effectAllowed = "copy";
562
+ }}
563
+ className={[
564
+ "rounded-lg border p-3 text-left transition",
565
+ (page?.photo_layout ?? "auto") === preset.id
566
+ ? "border-blue-300 bg-blue-50"
567
+ : "border-gray-200 bg-gray-50 hover:bg-gray-100",
568
+ ].join(" ")}
569
+ >
570
+ <div className="text-sm font-semibold text-gray-900">
571
+ {preset.label}
572
+ </div>
573
+ <div className="text-xs text-gray-600">{preset.description}</div>
574
+ <div className="mt-2">
575
+ {preset.preview === "stacked" ? (
576
+ <div className="grid grid-cols-1 gap-1">
577
+ <div className="h-6 rounded bg-gray-200" />
578
+ <div className="h-6 rounded bg-gray-200" />
579
+ </div>
580
+ ) : (
581
+ <div className="grid grid-cols-2 gap-1">
582
+ <div className="h-6 rounded bg-gray-200" />
583
+ <div className="h-6 rounded bg-gray-200" />
584
+ </div>
585
+ )}
586
+ </div>
587
+ <div className="mt-2 text-[11px] text-gray-500">
588
+ Click or drag
589
+ </div>
590
+ </button>
591
+ ))}
592
+ </div>
593
+ <div
594
+ className="mt-3 rounded-md border border-dashed border-gray-200 bg-gray-50 px-3 py-2 text-[11px] text-gray-600"
595
+ onDragOver={(event) => {
596
+ event.preventDefault();
597
+ event.dataTransfer.dropEffect = "copy";
598
+ }}
599
+ onDrop={(event) => {
600
+ event.preventDefault();
601
+ const layout = event.dataTransfer.getData("text/plain");
602
+ if (layout === "auto" || layout === "two-column" || layout === "stacked") {
603
+ updatePageLayout(layout);
604
+ }
605
+ }}
606
+ >
607
+ Drop preset here to apply to current page - Current: {page?.photo_layout ?? "auto"}
608
+ </div>
609
+ </div>
610
+
611
  <div className="flex flex-wrap items-center justify-between gap-3">
612
  <div>
613
  <h3 className="text-lg font-semibold text-gray-900">Page Images</h3>
 
681
  </span>
682
  </div>
683
  <p className="mt-2 text-[11px] text-gray-500">
684
+ First template pages show up to 2 images. Continuation pages can
685
+ include more images automatically.
686
  </p>
687
  </div>
688
 
frontend/src/pages/LayoutTemplatesPage.tsx ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo, useState, useEffect } from "react";
2
+ import { Link, useNavigate, useSearchParams } from "react-router-dom";
3
+ import {
4
+ ArrowLeft,
5
+ Download,
6
+ Edit3,
7
+ Grid,
8
+ Image,
9
+ Layout,
10
+ Plus,
11
+ Settings,
12
+ Table,
13
+ Trash2,
14
+ } from "react-feather";
15
+
16
+ import { putJson, request } from "../lib/api";
17
+ import {
18
+ BUILTIN_PAGE_TEMPLATES,
19
+ createCustomTemplateId,
20
+ mergePageTemplates,
21
+ } from "../lib/pageTemplates";
22
+ import { ensureSections, flattenSections } from "../lib/sections";
23
+ import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
24
+ import type { JobsheetSection, PageTemplateDefinition, Session } from "../types/session";
25
+ import { InfoMenu } from "../components/InfoMenu";
26
+ import { PageFooter } from "../components/PageFooter";
27
+ import { PageHeader } from "../components/PageHeader";
28
+ import { PageShell } from "../components/PageShell";
29
+
30
+ type Variant = "full" | "photos";
31
+ type PhotoLayout = "auto" | "two-column" | "stacked";
32
+
33
+ const DEFAULT_VARIANT: Variant = "full";
34
+ const DEFAULT_PHOTO_LAYOUT: PhotoLayout = "auto";
35
+
36
+ export default function LayoutTemplatesPage() {
37
+ const [searchParams] = useSearchParams();
38
+ const sessionId = getSessionId(searchParams.toString());
39
+ const sessionQuery = buildSessionQuery(sessionId);
40
+ const navigate = useNavigate();
41
+
42
+ const [session, setSession] = useState<Session | null>(null);
43
+ const [sections, setSections] = useState<JobsheetSection[]>([]);
44
+ const [templates, setTemplates] = useState<PageTemplateDefinition[]>(
45
+ BUILTIN_PAGE_TEMPLATES,
46
+ );
47
+ const [customTemplates, setCustomTemplates] = useState<PageTemplateDefinition[]>(
48
+ [],
49
+ );
50
+
51
+ const [newName, setNewName] = useState("");
52
+ const [newDescription, setNewDescription] = useState("");
53
+ const [newVariant, setNewVariant] = useState<Variant>(DEFAULT_VARIANT);
54
+ const [newPhotoLayout, setNewPhotoLayout] =
55
+ useState<PhotoLayout>(DEFAULT_PHOTO_LAYOUT);
56
+ const [newBlank, setNewBlank] = useState(false);
57
+ const [status, setStatus] = useState("");
58
+ const [isSaving, setIsSaving] = useState(false);
59
+
60
+ useEffect(() => {
61
+ if (!sessionId) {
62
+ setStatus("No active session found.");
63
+ return;
64
+ }
65
+ setStoredSessionId(sessionId);
66
+ async function load() {
67
+ try {
68
+ const [data, sectionResp] = await Promise.all([
69
+ request<Session>(`/sessions/${sessionId}`),
70
+ request<{ sections: JobsheetSection[] }>(`/sessions/${sessionId}/sections`),
71
+ ]);
72
+ setSession(data);
73
+ setSections(ensureSections(sectionResp.sections));
74
+ const merged = mergePageTemplates(data.page_templates);
75
+ setTemplates(merged);
76
+ setCustomTemplates(merged.filter((template) => template.source === "custom"));
77
+ } catch (error) {
78
+ const message =
79
+ error instanceof Error ? error.message : "Failed to load templates.";
80
+ setStatus(message);
81
+ }
82
+ }
83
+ load();
84
+ }, [sessionId]);
85
+
86
+ const flatPages = useMemo(
87
+ () => flattenSections(ensureSections(sections)),
88
+ [sections],
89
+ );
90
+
91
+ const saveTemplates = async (nextTemplates: PageTemplateDefinition[]) => {
92
+ if (!sessionId) return;
93
+ setIsSaving(true);
94
+ setStatus("Saving templates...");
95
+ try {
96
+ const payload = nextTemplates.map((template) => ({
97
+ id: template.id,
98
+ name: template.name,
99
+ description: template.description ?? "",
100
+ blank: Boolean(template.blank),
101
+ variant: (template.variant ?? DEFAULT_VARIANT) as Variant,
102
+ photo_layout: (template.photo_layout ?? DEFAULT_PHOTO_LAYOUT) as PhotoLayout,
103
+ }));
104
+ const response = await putJson<{ page_templates: PageTemplateDefinition[] }>(
105
+ `/sessions/${sessionId}/page-templates`,
106
+ { page_templates: payload },
107
+ );
108
+ const merged = mergePageTemplates(response.page_templates);
109
+ setTemplates(merged);
110
+ setCustomTemplates(merged.filter((template) => template.source === "custom"));
111
+ setSession((prev) =>
112
+ prev
113
+ ? { ...prev, page_templates: response.page_templates ?? payload }
114
+ : prev,
115
+ );
116
+ setStatus("Templates saved.");
117
+ } catch (error) {
118
+ const message =
119
+ error instanceof Error ? error.message : "Failed to save templates.";
120
+ setStatus(message);
121
+ } finally {
122
+ setIsSaving(false);
123
+ }
124
+ };
125
+
126
+ const addTemplate = async () => {
127
+ const name = newName.trim();
128
+ if (!name) {
129
+ setStatus("Template name is required.");
130
+ return;
131
+ }
132
+ const nextTemplate: PageTemplateDefinition = {
133
+ id: createCustomTemplateId(),
134
+ name,
135
+ description: newDescription.trim(),
136
+ blank: newBlank,
137
+ variant: newVariant,
138
+ photo_layout: newPhotoLayout,
139
+ source: "custom",
140
+ };
141
+ await saveTemplates([...customTemplates, nextTemplate]);
142
+ setNewName("");
143
+ setNewDescription("");
144
+ setNewVariant(DEFAULT_VARIANT);
145
+ setNewPhotoLayout(DEFAULT_PHOTO_LAYOUT);
146
+ setNewBlank(false);
147
+ };
148
+
149
+ const updateCustomTemplate = (
150
+ templateId: string,
151
+ patch: Partial<PageTemplateDefinition>,
152
+ ) => {
153
+ setCustomTemplates((prev) =>
154
+ prev.map((template) =>
155
+ template.id === templateId ? { ...template, ...patch } : template,
156
+ ),
157
+ );
158
+ };
159
+
160
+ const removeCustomTemplate = async (templateId: string) => {
161
+ const next = customTemplates.filter((template) => template.id !== templateId);
162
+ await saveTemplates(next);
163
+ };
164
+
165
+ const persistCustomTemplateEdits = async () => {
166
+ await saveTemplates(customTemplates);
167
+ };
168
+
169
+ return (
170
+ <PageShell>
171
+ <PageHeader
172
+ title="RepEx - Report Express"
173
+ subtitle="Template Library"
174
+ right={
175
+ <div className="flex items-center gap-2">
176
+ <Link
177
+ to={`/edit-layouts${sessionQuery}`}
178
+ 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"
179
+ >
180
+ <ArrowLeft className="h-4 w-4" />
181
+ Back
182
+ </Link>
183
+ <InfoMenu sessionQuery={sessionQuery} />
184
+ </div>
185
+ }
186
+ />
187
+
188
+ <nav className="mb-6" aria-label="Report workflow navigation">
189
+ <div className="flex flex-wrap gap-2">
190
+ <Link
191
+ to={`/input-data${sessionQuery}`}
192
+ 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"
193
+ >
194
+ <Table className="h-4 w-4" />
195
+ Input Data
196
+ </Link>
197
+
198
+ <Link
199
+ to={`/report-viewer${sessionQuery}`}
200
+ 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"
201
+ >
202
+ <Layout className="h-4 w-4" />
203
+ Report Viewer
204
+ </Link>
205
+
206
+ <Link
207
+ to={`/image-placement${sessionQuery}`}
208
+ 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"
209
+ >
210
+ <Image className="h-4 w-4" />
211
+ Image Placement
212
+ </Link>
213
+
214
+ <Link
215
+ to={`/edit-report${sessionQuery}`}
216
+ 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"
217
+ >
218
+ <Edit3 className="h-4 w-4" />
219
+ Edit Report
220
+ </Link>
221
+
222
+ <Link
223
+ to={`/edit-layouts${sessionQuery}`}
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
+ >
226
+ <Grid className="h-4 w-4" />
227
+ Edit Page Layouts
228
+ </Link>
229
+
230
+ <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
231
+ <Settings className="h-4 w-4" />
232
+ Template Library
233
+ </span>
234
+
235
+ <Link
236
+ to={`/export${sessionQuery}`}
237
+ 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"
238
+ >
239
+ <Download className="h-4 w-4" />
240
+ Export
241
+ </Link>
242
+ </div>
243
+ </nav>
244
+
245
+ <section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
246
+ <h2 className="text-lg font-semibold text-gray-900">Current Usage</h2>
247
+ <p className="text-sm text-gray-600">
248
+ {sections.length} sections and {flatPages.length} pages currently use page templates.
249
+ </p>
250
+ </section>
251
+
252
+ <section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
253
+ <h2 className="text-lg font-semibold text-gray-900">Create Custom Template</h2>
254
+ <p className="text-sm text-gray-600 mb-3">
255
+ Define a reusable page template, name it, and apply it when adding pages in
256
+ Edit Page Layouts.
257
+ </p>
258
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
259
+ <label className="text-sm text-gray-700">
260
+ Template Name
261
+ <input
262
+ type="text"
263
+ value={newName}
264
+ onChange={(event) => setNewName(event.target.value)}
265
+ className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
266
+ placeholder="My Custom Template"
267
+ />
268
+ </label>
269
+ <label className="text-sm text-gray-700">
270
+ Variant
271
+ <select
272
+ value={newVariant}
273
+ onChange={(event) => setNewVariant(event.target.value as Variant)}
274
+ className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
275
+ >
276
+ <option value="full">Full</option>
277
+ <option value="photos">Photos only</option>
278
+ </select>
279
+ </label>
280
+ <label className="text-sm text-gray-700">
281
+ Photo Layout
282
+ <select
283
+ value={newPhotoLayout}
284
+ onChange={(event) =>
285
+ setNewPhotoLayout(event.target.value as PhotoLayout)
286
+ }
287
+ className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
288
+ >
289
+ <option value="auto">Auto</option>
290
+ <option value="two-column">Two column</option>
291
+ <option value="stacked">Stacked</option>
292
+ </select>
293
+ </label>
294
+ <label className="text-sm text-gray-700 md:col-span-2 lg:col-span-2">
295
+ Description
296
+ <input
297
+ type="text"
298
+ value={newDescription}
299
+ onChange={(event) => setNewDescription(event.target.value)}
300
+ className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
301
+ placeholder="Optional notes for the template"
302
+ />
303
+ </label>
304
+ <label className="inline-flex items-center gap-2 self-end text-sm text-gray-700">
305
+ <input
306
+ type="checkbox"
307
+ checked={newBlank}
308
+ onChange={(event) => setNewBlank(event.target.checked)}
309
+ />
310
+ Blank page
311
+ </label>
312
+ </div>
313
+ <button
314
+ type="button"
315
+ onClick={() => {
316
+ void addTemplate();
317
+ }}
318
+ disabled={isSaving}
319
+ className="mt-4 inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
320
+ >
321
+ <Plus className="h-4 w-4" />
322
+ Create template
323
+ </button>
324
+ </section>
325
+
326
+ <section className="rounded-lg border border-gray-200 bg-white p-4">
327
+ <div className="flex items-center justify-between gap-3 mb-3">
328
+ <h2 className="text-lg font-semibold text-gray-900">Saved Templates</h2>
329
+ <button
330
+ type="button"
331
+ onClick={() => {
332
+ void persistCustomTemplateEdits();
333
+ }}
334
+ disabled={isSaving}
335
+ 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 disabled:opacity-50 disabled:cursor-not-allowed"
336
+ >
337
+ Save edits
338
+ </button>
339
+ </div>
340
+
341
+ <div className="space-y-3">
342
+ {templates.map((template) => {
343
+ const isCustom = template.source === "custom";
344
+ return (
345
+ <div
346
+ key={template.id}
347
+ className="rounded-lg border border-gray-200 bg-gray-50 p-3"
348
+ >
349
+ <div className="flex flex-wrap items-center justify-between gap-2">
350
+ <div className="text-xs font-semibold text-gray-500">{template.id}</div>
351
+ <span
352
+ className={[
353
+ "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold",
354
+ isCustom
355
+ ? "bg-emerald-50 text-emerald-700 border border-emerald-200"
356
+ : "bg-gray-100 text-gray-600 border border-gray-200",
357
+ ].join(" ")}
358
+ >
359
+ {isCustom ? "Custom" : "Built-in"}
360
+ </span>
361
+ </div>
362
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-2 mt-2">
363
+ <label className="text-xs text-gray-600 lg:col-span-2">
364
+ Name
365
+ <input
366
+ type="text"
367
+ value={template.name}
368
+ disabled={!isCustom}
369
+ onChange={(event) =>
370
+ updateCustomTemplate(template.id, { name: event.target.value })
371
+ }
372
+ className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
373
+ />
374
+ </label>
375
+ <label className="text-xs text-gray-600">
376
+ Variant
377
+ <select
378
+ value={template.variant ?? DEFAULT_VARIANT}
379
+ disabled={!isCustom}
380
+ onChange={(event) =>
381
+ updateCustomTemplate(template.id, {
382
+ variant: event.target.value as Variant,
383
+ })
384
+ }
385
+ className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
386
+ >
387
+ <option value="full">Full</option>
388
+ <option value="photos">Photos only</option>
389
+ </select>
390
+ </label>
391
+ <label className="text-xs text-gray-600">
392
+ Photo Layout
393
+ <select
394
+ value={template.photo_layout ?? DEFAULT_PHOTO_LAYOUT}
395
+ disabled={!isCustom}
396
+ onChange={(event) =>
397
+ updateCustomTemplate(template.id, {
398
+ photo_layout: event.target.value as PhotoLayout,
399
+ })
400
+ }
401
+ className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
402
+ >
403
+ <option value="auto">Auto</option>
404
+ <option value="two-column">Two column</option>
405
+ <option value="stacked">Stacked</option>
406
+ </select>
407
+ </label>
408
+ <label className="inline-flex items-center gap-2 text-xs text-gray-600 self-end">
409
+ <input
410
+ type="checkbox"
411
+ checked={Boolean(template.blank)}
412
+ disabled={!isCustom}
413
+ onChange={(event) =>
414
+ updateCustomTemplate(template.id, { blank: event.target.checked })
415
+ }
416
+ />
417
+ Blank page
418
+ </label>
419
+ </div>
420
+ <label className="text-xs text-gray-600 block mt-2">
421
+ Description
422
+ <input
423
+ type="text"
424
+ value={template.description ?? ""}
425
+ disabled={!isCustom}
426
+ onChange={(event) =>
427
+ updateCustomTemplate(template.id, {
428
+ description: event.target.value,
429
+ })
430
+ }
431
+ className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
432
+ />
433
+ </label>
434
+ {isCustom ? (
435
+ <button
436
+ type="button"
437
+ onClick={() => {
438
+ void removeCustomTemplate(template.id);
439
+ }}
440
+ disabled={isSaving}
441
+ className="mt-2 inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-xs font-semibold text-red-700 hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
442
+ >
443
+ <Trash2 className="h-3.5 w-3.5" />
444
+ Delete template
445
+ </button>
446
+ ) : null}
447
+ </div>
448
+ );
449
+ })}
450
+ </div>
451
+ </section>
452
+
453
+ {status ? <p className="mt-4 text-sm text-gray-600">{status}</p> : null}
454
+
455
+ <PageFooter note="Templates are stored per session and available when adding new pages." />
456
+ </PageShell>
457
+ );
458
+ }
frontend/src/pages/UploadPage.tsx CHANGED
@@ -256,6 +256,33 @@ export default function UploadPage() {
256
  <p className="text-xs text-gray-500 mt-4">{fileSummary}</p>
257
  </div>
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
260
  <div className="space-y-1">
261
  <label className="block text-sm font-medium text-gray-700" htmlFor="documentNo">
 
256
  <p className="text-xs text-gray-500 mt-4">{fileSummary}</p>
257
  </div>
258
 
259
+ <div className="mb-6 rounded-lg border border-blue-100 bg-blue-50 p-4">
260
+ <div className="flex flex-wrap items-center justify-between gap-3">
261
+ <div>
262
+ <h3 className="text-sm font-semibold text-blue-900">
263
+ Need a data template?
264
+ </h3>
265
+ <p className="mt-1 text-xs text-blue-800">
266
+ Download the Excel template, fill the three sheets, then upload it
267
+ together with your images.
268
+ </p>
269
+ </div>
270
+ <a
271
+ href="/templates/repex-data-input-template.xlsx"
272
+ download
273
+ className="inline-flex items-center justify-center rounded-lg border border-blue-200 bg-white px-4 py-2 text-xs font-semibold text-blue-800 hover:bg-blue-100 transition"
274
+ >
275
+ Download Excel Template
276
+ </a>
277
+ </div>
278
+ <ol className="mt-3 list-decimal pl-5 text-xs text-blue-900 space-y-1">
279
+ <li>Fill <span className="font-semibold">General Information</span>.</li>
280
+ <li>Fill <span className="font-semibold">Headings</span>.</li>
281
+ <li>Fill <span className="font-semibold">Item Spesific</span> and image names.</li>
282
+ <li>Upload the Excel file and matching image files in one upload batch.</li>
283
+ </ol>
284
+ </div>
285
+
286
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
287
  <div className="space-y-1">
288
  <label className="block text-sm font-medium text-gray-700" htmlFor="documentNo">
frontend/src/types/session.ts CHANGED
@@ -30,6 +30,7 @@ export type Session = {
30
  page_count: number;
31
  jobsheet_sections?: JobsheetSection[];
32
  headings?: Heading[];
 
33
  layout?: Record<string, unknown> | null;
34
  };
35
 
@@ -88,6 +89,7 @@ export type Page = {
88
  photo_order_locked?: boolean;
89
  blank?: boolean;
90
  variant?: "full" | "photos";
 
91
  };
92
 
93
  export type JobsheetSection = {
@@ -96,6 +98,16 @@ export type JobsheetSection = {
96
  pages: Page[];
97
  };
98
 
 
 
 
 
 
 
 
 
 
 
99
  export type PagesResponse = {
100
  pages: Page[];
101
  };
 
30
  page_count: number;
31
  jobsheet_sections?: JobsheetSection[];
32
  headings?: Heading[];
33
+ page_templates?: PageTemplateDefinition[];
34
  layout?: Record<string, unknown> | null;
35
  };
36
 
 
89
  photo_order_locked?: boolean;
90
  blank?: boolean;
91
  variant?: "full" | "photos";
92
+ page_template?: string;
93
  };
94
 
95
  export type JobsheetSection = {
 
98
  pages: Page[];
99
  };
100
 
101
+ export type PageTemplateDefinition = {
102
+ id: string;
103
+ name: string;
104
+ description?: string;
105
+ blank?: boolean;
106
+ variant?: "full" | "photos";
107
+ photo_layout?: "auto" | "two-column" | "stacked";
108
+ source?: "builtin" | "custom";
109
+ };
110
+
111
  export type PagesResponse = {
112
  pages: Page[];
113
  };
server/app/api/routes/sessions.py CHANGED
@@ -12,6 +12,8 @@ from ..deps import get_session_store
12
  from ..schemas import (
13
  HeadingsRequest,
14
  HeadingsResponse,
 
 
15
  PagesRequest,
16
  PagesResponse,
17
  SectionsRequest,
@@ -206,6 +208,20 @@ def save_headings(
206
  return HeadingsResponse(headings=session.get("headings") or [])
207
 
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  @router.get("/{session_id}/uploads/{file_id}")
210
  def get_upload(
211
  session_id: str,
@@ -329,6 +345,8 @@ def import_json(
329
  session["page_count"] = imported_session["page_count"]
330
  if imported_session.get("headings") is not None:
331
  session["headings"] = imported_session["headings"]
 
 
332
  store.update_session(session)
333
 
334
  return _attach_urls(session)
 
12
  from ..schemas import (
13
  HeadingsRequest,
14
  HeadingsResponse,
15
+ PageTemplatesRequest,
16
+ PageTemplatesResponse,
17
  PagesRequest,
18
  PagesResponse,
19
  SectionsRequest,
 
208
  return HeadingsResponse(headings=session.get("headings") or [])
209
 
210
 
211
+ @router.put("/{session_id}/page-templates", response_model=PageTemplatesResponse)
212
+ def save_page_templates(
213
+ session_id: str,
214
+ payload: PageTemplatesRequest,
215
+ store: SessionStore = Depends(get_session_store),
216
+ ) -> PageTemplatesResponse:
217
+ session_id = _normalize_session_id(session_id, store)
218
+ session = store.get_session(session_id)
219
+ if not session:
220
+ raise HTTPException(status_code=404, detail="Session not found.")
221
+ session = store.set_page_templates(session, payload.page_templates)
222
+ return PageTemplatesResponse(page_templates=session.get("page_templates") or [])
223
+
224
+
225
  @router.get("/{session_id}/uploads/{file_id}")
226
  def get_upload(
227
  session_id: str,
 
345
  session["page_count"] = imported_session["page_count"]
346
  if imported_session.get("headings") is not None:
347
  session["headings"] = imported_session["headings"]
348
+ if imported_session.get("page_templates") is not None:
349
+ session["page_templates"] = imported_session["page_templates"]
350
  store.update_session(session)
351
 
352
  return _attach_urls(session)
server/app/api/schemas.py CHANGED
@@ -19,6 +19,16 @@ class Heading(BaseModel):
19
  name: str = ""
20
 
21
 
 
 
 
 
 
 
 
 
 
 
22
  class SessionResponse(BaseModel):
23
  id: str
24
  status: str
@@ -31,6 +41,7 @@ class SessionResponse(BaseModel):
31
  page_count: int = 0
32
  headings: List[Heading] = Field(default_factory=list)
33
  jobsheet_sections: List["JobsheetSection"] = Field(default_factory=list)
 
34
 
35
 
36
  class SessionStatusResponse(BaseModel):
@@ -71,3 +82,11 @@ class HeadingsRequest(BaseModel):
71
 
72
  class HeadingsResponse(BaseModel):
73
  headings: List[Heading] = Field(default_factory=list)
 
 
 
 
 
 
 
 
 
19
  name: str = ""
20
 
21
 
22
+ class PageTemplateDefinition(BaseModel):
23
+ id: str
24
+ name: str
25
+ description: str = ""
26
+ blank: bool = False
27
+ variant: str = "full"
28
+ photo_layout: str = "auto"
29
+ source: str = "custom"
30
+
31
+
32
  class SessionResponse(BaseModel):
33
  id: str
34
  status: str
 
41
  page_count: int = 0
42
  headings: List[Heading] = Field(default_factory=list)
43
  jobsheet_sections: List["JobsheetSection"] = Field(default_factory=list)
44
+ page_templates: List[PageTemplateDefinition] = Field(default_factory=list)
45
 
46
 
47
  class SessionStatusResponse(BaseModel):
 
82
 
83
  class HeadingsResponse(BaseModel):
84
  headings: List[Heading] = Field(default_factory=list)
85
+
86
+
87
+ class PageTemplatesRequest(BaseModel):
88
+ page_templates: List[PageTemplateDefinition] = Field(default_factory=list)
89
+
90
+
91
+ class PageTemplatesResponse(BaseModel):
92
+ page_templates: List[PageTemplateDefinition] = Field(default_factory=list)
server/app/services/data_import.py CHANGED
@@ -467,7 +467,14 @@ def populate_session_from_data_files(
467
  for photo_id in photo_ids:
468
  if photo_id not in selected_photo_ids:
469
  selected_photo_ids.append(photo_id)
470
- page = {"items": [], "template": template, "photo_ids": photo_ids}
 
 
 
 
 
 
 
471
  title = item.get("reference") or item.get("area") or f"Section {idx + 1}"
472
  sections.append({"id": None, "title": title, "pages": [page]})
473
 
 
467
  for photo_id in photo_ids:
468
  if photo_id not in selected_photo_ids:
469
  selected_photo_ids.append(photo_id)
470
+ page = {
471
+ "items": [],
472
+ "template": template,
473
+ "photo_ids": photo_ids,
474
+ "page_template": "repex:standard",
475
+ "blank": False,
476
+ "variant": "full",
477
+ }
478
  title = item.get("reference") or item.get("area") or f"Section {idx + 1}"
479
  sections.append({"id": None, "title": title, "pages": [page]})
480
 
server/app/services/session_store.py CHANGED
@@ -19,6 +19,36 @@ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
19
  DOC_EXTS = {".pdf", ".doc", ".docx"}
20
  DATA_EXTS = {".csv", ".xls", ".xlsx"}
21
  SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
 
24
  @dataclass
@@ -109,6 +139,54 @@ def _normalize_template_fields(template: Optional[dict]) -> dict:
109
  return normalized
110
 
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  class SessionStore:
113
  def __init__(self, base_dir: Optional[Path] = None) -> None:
114
  settings = get_settings()
@@ -134,6 +212,32 @@ class SessionStore:
134
  except Exception:
135
  continue
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  def list_sessions(self) -> List[dict]:
138
  sessions: List[dict] = []
139
  for session_file in sorted(self.sessions_dir.glob("*/session.json"), reverse=True):
@@ -161,6 +265,7 @@ class SessionStore:
161
  "pages": [],
162
  "jobsheet_sections": [],
163
  "headings": [],
 
164
  }
165
  self._save_session(session)
166
  return session
@@ -212,13 +317,15 @@ class SessionStore:
212
  def set_pages(self, session: dict, pages: List[dict]) -> dict:
213
  if not pages:
214
  pages = [{"items": []}]
 
215
  normalized_pages = []
216
  for page in pages:
217
  if not isinstance(page, dict):
218
- normalized_pages.append({"items": []})
 
 
219
  continue
220
- template = _normalize_template_fields(page.get("template"))
221
- normalized_pages.append({**page, "template": template})
222
  # Legacy compatibility: store as a single section.
223
  session["jobsheet_sections"] = [
224
  {"id": uuid4().hex, "title": "Section 1", "pages": normalized_pages}
@@ -238,6 +345,7 @@ class SessionStore:
238
  return pages
239
 
240
  def set_sections(self, session: dict, sections: List[dict]) -> dict:
 
241
  normalized: List[dict] = []
242
  for section in sections or []:
243
  if hasattr(section, "model_dump"):
@@ -258,10 +366,11 @@ class SessionStore:
258
  normalized_pages = []
259
  for page in pages:
260
  if not isinstance(page, dict):
261
- normalized_pages.append({"items": []})
 
 
262
  continue
263
- template = _normalize_template_fields(page.get("template"))
264
- normalized_pages.append({**page, "template": template})
265
  normalized.append(
266
  {
267
  "id": section.get("id") or uuid4().hex,
@@ -293,7 +402,40 @@ class SessionStore:
293
  self.update_session(session)
294
  return session
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  def ensure_sections(self, session: dict) -> List[dict]:
 
297
  sections = session.get("jobsheet_sections") or []
298
  if sections:
299
  normalized_sections: List[dict] = []
@@ -304,10 +446,11 @@ class SessionStore:
304
  normalized_pages = []
305
  for page in pages:
306
  if not isinstance(page, dict):
307
- normalized_pages.append({"items": []})
 
 
308
  continue
309
- template = _normalize_template_fields(page.get("template"))
310
- normalized_pages.append({**page, "template": template})
311
  normalized_sections.append(
312
  {
313
  "id": section.get("id") or uuid4().hex,
@@ -329,6 +472,11 @@ class SessionStore:
329
  count = selected_count or photo_count or session.get("page_count", 1) or 1
330
  pages = [{"items": []} for _ in range(count)]
331
 
 
 
 
 
 
332
  sections = [{"id": uuid4().hex, "title": "Section 1", "pages": pages}]
333
  session["jobsheet_sections"] = sections
334
  session["pages"] = []
@@ -370,15 +518,21 @@ class SessionStore:
370
  else:
371
  session["headings"] = []
372
 
 
 
 
 
 
373
  pages = session.get("pages") or []
374
  if pages:
375
  normalized_pages = []
376
  for page in pages:
377
  if not isinstance(page, dict):
378
- normalized_pages.append({"items": []})
 
 
379
  continue
380
- template = _normalize_template_fields(page.get("template"))
381
- normalized_pages.append({**page, "template": template})
382
  session["pages"] = normalized_pages
383
 
384
  sections = session.get("jobsheet_sections") or []
@@ -391,10 +545,11 @@ class SessionStore:
391
  normalized_pages = []
392
  for page in pages:
393
  if not isinstance(page, dict):
394
- normalized_pages.append({"items": []})
 
 
395
  continue
396
- template = _normalize_template_fields(page.get("template"))
397
- normalized_pages.append({**page, "template": template})
398
  normalized_sections.append(
399
  {
400
  "id": section.get("id") or uuid4().hex,
 
19
  DOC_EXTS = {".pdf", ".doc", ".docx"}
20
  DATA_EXTS = {".csv", ".xls", ".xlsx"}
21
  SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
22
+ BUILTIN_PAGE_TEMPLATES = [
23
+ {
24
+ "id": "repex:standard",
25
+ "name": "Standard Job Sheet",
26
+ "description": "Observations + up to two photos.",
27
+ "blank": False,
28
+ "variant": "full",
29
+ "photo_layout": "auto",
30
+ "source": "builtin",
31
+ },
32
+ {
33
+ "id": "repex:photos",
34
+ "name": "Photo Continuation",
35
+ "description": "Photo-only continuation page.",
36
+ "blank": False,
37
+ "variant": "photos",
38
+ "photo_layout": "auto",
39
+ "source": "builtin",
40
+ },
41
+ {
42
+ "id": "repex:blank",
43
+ "name": "Blank Canvas",
44
+ "description": "Blank white page.",
45
+ "blank": True,
46
+ "variant": "full",
47
+ "photo_layout": "auto",
48
+ "source": "builtin",
49
+ },
50
+ ]
51
+ BUILTIN_PAGE_TEMPLATE_MAP = {item["id"]: item for item in BUILTIN_PAGE_TEMPLATES}
52
 
53
 
54
  @dataclass
 
139
  return normalized
140
 
141
 
142
+ def _infer_template_id(page: dict) -> str:
143
+ template_id = str(page.get("page_template") or "").strip()
144
+ if template_id:
145
+ return template_id
146
+ if page.get("blank"):
147
+ return "repex:blank"
148
+ if str(page.get("variant") or "").strip().lower() == "photos":
149
+ return "repex:photos"
150
+ return "repex:standard"
151
+
152
+
153
+ def _normalize_page_templates(templates: Optional[List[dict]]) -> List[dict]:
154
+ normalized: List[dict] = []
155
+ seen: set[str] = set()
156
+ for template in templates or []:
157
+ if hasattr(template, "model_dump"):
158
+ template = template.model_dump()
159
+ elif hasattr(template, "dict"):
160
+ template = template.dict()
161
+ if not isinstance(template, dict):
162
+ continue
163
+ template_id = str(template.get("id") or "").strip()
164
+ if not template_id or template_id in BUILTIN_PAGE_TEMPLATE_MAP:
165
+ continue
166
+ if template_id in seen:
167
+ continue
168
+ seen.add(template_id)
169
+ name = str(template.get("name") or template_id).strip() or template_id
170
+ variant = str(template.get("variant") or "full").strip().lower()
171
+ if variant not in {"full", "photos"}:
172
+ variant = "full"
173
+ photo_layout = str(template.get("photo_layout") or "auto").strip().lower()
174
+ if photo_layout not in {"auto", "two-column", "stacked"}:
175
+ photo_layout = "auto"
176
+ normalized.append(
177
+ {
178
+ "id": template_id,
179
+ "name": name,
180
+ "description": str(template.get("description") or "").strip(),
181
+ "blank": bool(template.get("blank")),
182
+ "variant": variant,
183
+ "photo_layout": photo_layout,
184
+ "source": "custom",
185
+ }
186
+ )
187
+ return normalized
188
+
189
+
190
  class SessionStore:
191
  def __init__(self, base_dir: Optional[Path] = None) -> None:
192
  settings = get_settings()
 
212
  except Exception:
213
  continue
214
 
215
+ def _template_index(self, session: dict) -> dict:
216
+ custom_templates = _normalize_page_templates(session.get("page_templates") or [])
217
+ session["page_templates"] = custom_templates
218
+ merged = {key: dict(value) for key, value in BUILTIN_PAGE_TEMPLATE_MAP.items()}
219
+ for template in custom_templates:
220
+ merged[template["id"]] = template
221
+ return merged
222
+
223
+ def _normalize_page(self, page: dict, template_index: dict) -> dict:
224
+ template = _normalize_template_fields(page.get("template"))
225
+ normalized = {**page, "template": template}
226
+ template_id = _infer_template_id(normalized)
227
+ definition = template_index.get(template_id) or BUILTIN_PAGE_TEMPLATE_MAP["repex:standard"]
228
+ normalized["page_template"] = definition["id"]
229
+ normalized["blank"] = bool(definition.get("blank"))
230
+ normalized["variant"] = (
231
+ str(definition.get("variant") or normalized.get("variant") or "full")
232
+ .strip()
233
+ .lower()
234
+ )
235
+ if normalized["variant"] not in {"full", "photos"}:
236
+ normalized["variant"] = "full"
237
+ if normalized.get("photo_layout") is None and definition.get("photo_layout"):
238
+ normalized["photo_layout"] = definition["photo_layout"]
239
+ return normalized
240
+
241
  def list_sessions(self) -> List[dict]:
242
  sessions: List[dict] = []
243
  for session_file in sorted(self.sessions_dir.glob("*/session.json"), reverse=True):
 
265
  "pages": [],
266
  "jobsheet_sections": [],
267
  "headings": [],
268
+ "page_templates": [],
269
  }
270
  self._save_session(session)
271
  return session
 
317
  def set_pages(self, session: dict, pages: List[dict]) -> dict:
318
  if not pages:
319
  pages = [{"items": []}]
320
+ template_index = self._template_index(session)
321
  normalized_pages = []
322
  for page in pages:
323
  if not isinstance(page, dict):
324
+ normalized_pages.append(
325
+ self._normalize_page({"items": []}, template_index)
326
+ )
327
  continue
328
+ normalized_pages.append(self._normalize_page(page, template_index))
 
329
  # Legacy compatibility: store as a single section.
330
  session["jobsheet_sections"] = [
331
  {"id": uuid4().hex, "title": "Section 1", "pages": normalized_pages}
 
345
  return pages
346
 
347
  def set_sections(self, session: dict, sections: List[dict]) -> dict:
348
+ template_index = self._template_index(session)
349
  normalized: List[dict] = []
350
  for section in sections or []:
351
  if hasattr(section, "model_dump"):
 
366
  normalized_pages = []
367
  for page in pages:
368
  if not isinstance(page, dict):
369
+ normalized_pages.append(
370
+ self._normalize_page({"items": []}, template_index)
371
+ )
372
  continue
373
+ normalized_pages.append(self._normalize_page(page, template_index))
 
374
  normalized.append(
375
  {
376
  "id": section.get("id") or uuid4().hex,
 
402
  self.update_session(session)
403
  return session
404
 
405
+ def set_page_templates(self, session: dict, templates: List[dict]) -> dict:
406
+ session["page_templates"] = _normalize_page_templates(templates)
407
+ template_index = self._template_index(session)
408
+ sections = session.get("jobsheet_sections") or []
409
+ normalized_sections = []
410
+ for section in sections:
411
+ if not isinstance(section, dict):
412
+ continue
413
+ pages = section.get("pages") or []
414
+ normalized_pages = []
415
+ for page in pages:
416
+ if not isinstance(page, dict):
417
+ normalized_pages.append(
418
+ self._normalize_page({"items": []}, template_index)
419
+ )
420
+ continue
421
+ normalized_pages.append(self._normalize_page(page, template_index))
422
+ normalized_sections.append(
423
+ {
424
+ "id": section.get("id") or uuid4().hex,
425
+ "title": section.get("title") or "Section",
426
+ "pages": normalized_pages if normalized_pages else [{"items": []}],
427
+ }
428
+ )
429
+ if normalized_sections:
430
+ session["jobsheet_sections"] = normalized_sections
431
+ session["page_count"] = sum(
432
+ len(section.get("pages") or []) for section in normalized_sections
433
+ )
434
+ self.update_session(session)
435
+ return session
436
+
437
  def ensure_sections(self, session: dict) -> List[dict]:
438
+ template_index = self._template_index(session)
439
  sections = session.get("jobsheet_sections") or []
440
  if sections:
441
  normalized_sections: List[dict] = []
 
446
  normalized_pages = []
447
  for page in pages:
448
  if not isinstance(page, dict):
449
+ normalized_pages.append(
450
+ self._normalize_page({"items": []}, template_index)
451
+ )
452
  continue
453
+ normalized_pages.append(self._normalize_page(page, template_index))
 
454
  normalized_sections.append(
455
  {
456
  "id": section.get("id") or uuid4().hex,
 
472
  count = selected_count or photo_count or session.get("page_count", 1) or 1
473
  pages = [{"items": []} for _ in range(count)]
474
 
475
+ pages = [
476
+ self._normalize_page(page if isinstance(page, dict) else {"items": []}, template_index)
477
+ for page in pages
478
+ ]
479
+
480
  sections = [{"id": uuid4().hex, "title": "Section 1", "pages": pages}]
481
  session["jobsheet_sections"] = sections
482
  session["pages"] = []
 
518
  else:
519
  session["headings"] = []
520
 
521
+ session["page_templates"] = _normalize_page_templates(
522
+ session.get("page_templates") or []
523
+ )
524
+ template_index = self._template_index(session)
525
+
526
  pages = session.get("pages") or []
527
  if pages:
528
  normalized_pages = []
529
  for page in pages:
530
  if not isinstance(page, dict):
531
+ normalized_pages.append(
532
+ self._normalize_page({"items": []}, template_index)
533
+ )
534
  continue
535
+ normalized_pages.append(self._normalize_page(page, template_index))
 
536
  session["pages"] = normalized_pages
537
 
538
  sections = session.get("jobsheet_sections") or []
 
545
  normalized_pages = []
546
  for page in pages:
547
  if not isinstance(page, dict):
548
+ normalized_pages.append(
549
+ self._normalize_page({"items": []}, template_index)
550
+ )
551
  continue
552
+ normalized_pages.append(self._normalize_page(page, template_index))
 
553
  normalized_sections.append(
554
  {
555
  "id": section.get("id") or uuid4().hex,