ChristopherJKoen commited on
Commit
51c39cf
·
1 Parent(s): 4297738

HTML Static Setup Web

Browse files
.gitignore CHANGED
@@ -16,6 +16,9 @@ node_modules/
16
  dist/
17
  build/
18
 
 
 
 
19
  # OS
20
  Thumbs.db
21
  .DS_Store
 
16
  dist/
17
  build/
18
 
19
+ # Runtime data
20
+ data/
21
+
22
  # OS
23
  Thumbs.db
24
  .DS_Store
README.md CHANGED
@@ -1,6 +1,6 @@
1
  # RepEx Web Starter
2
 
3
- Static HTML pages at the project root plus a minimal FastAPI backend.
4
 
5
  ## Project Layout
6
 
@@ -35,7 +35,7 @@ Static HTML pages at the project root plus a minimal FastAPI backend.
35
 
36
  ## Quick Start
37
 
38
- ### Server
39
  ```powershell
40
  python -m venv .venv
41
  .venv\Scripts\activate
@@ -43,12 +43,7 @@ pip install -r server/requirements.txt
43
  uvicorn server.app.main:app --reload --port 8000
44
  ```
45
 
46
- ### Web
47
- Open `index.html` directly in your browser, or serve the root with a static server:
48
- ```powershell
49
- python -m http.server 5173
50
- ```
51
- Then open `http://localhost:5173`.
52
 
53
  ## Configuration
54
 
@@ -56,3 +51,5 @@ Environment variables for the API:
56
  - `APP_NAME` (default: `Starter API`)
57
  - `API_PREFIX` (default: `/api`)
58
  - `CORS_ORIGINS` (comma-separated, default: `http://localhost:5173`)
 
 
 
1
  # RepEx Web Starter
2
 
3
+ Static HTML pages at the project root served by a FastAPI backend (API + static files).
4
 
5
  ## Project Layout
6
 
 
35
 
36
  ## Quick Start
37
 
38
+ ### Server (API + static pages)
39
  ```powershell
40
  python -m venv .venv
41
  .venv\Scripts\activate
 
43
  uvicorn server.app.main:app --reload --port 8000
44
  ```
45
 
46
+ Open `http://localhost:8000/index.html`.
 
 
 
 
 
47
 
48
  ## Configuration
49
 
 
51
  - `APP_NAME` (default: `Starter API`)
52
  - `API_PREFIX` (default: `/api`)
53
  - `CORS_ORIGINS` (comma-separated, default: `http://localhost:5173`)
54
+ - `STORAGE_DIR` (default: `data`)
55
+ - `MAX_UPLOAD_MB` (default: `50`)
components/report-editor.js CHANGED
@@ -19,6 +19,10 @@ class ReportEditor extends HTMLElement {
19
  redo: [],
20
  payload: null,
21
  };
 
 
 
 
22
  }
23
 
24
  connectedCallback() {
@@ -30,9 +34,15 @@ class ReportEditor extends HTMLElement {
30
  }
31
 
32
  // Public API
33
- open({ payload, pageIndex = 0, totalPages = 6 } = {}) {
34
  this.state.payload = payload ?? null;
35
  this.state.isOpen = true;
 
 
 
 
 
 
36
 
37
  // Load existing editor pages from storage, else initialize
38
  const stored = this._loadPages();
@@ -51,6 +61,19 @@ class ReportEditor extends HTMLElement {
51
 
52
  this.show();
53
  this.updateAll();
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
 
56
  close() {
@@ -116,7 +139,7 @@ class ReportEditor extends HTMLElement {
116
  <div class="space-y-2 max-h-[60vh] overflow-auto pr-1" data-page-list></div>
117
 
118
  <div class="mt-3 text-xs text-gray-500">
119
- Tip: Click a page to edit. Your edits are saved to your browser storage.
120
  </div>
121
  </aside>
122
 
@@ -473,6 +496,9 @@ class ReportEditor extends HTMLElement {
473
 
474
  // ---------- Storage ----------
475
  _storageKey() {
 
 
 
476
  return "repex_report_pages_v1";
477
  }
478
 
@@ -488,12 +514,58 @@ class ReportEditor extends HTMLElement {
488
  _savePages(showToast = false) {
489
  try {
490
  localStorage.setItem(this._storageKey(), JSON.stringify({ pages: this.state.pages }));
 
491
  if (showToast) this._toast("Saved");
492
  } catch {
493
  if (showToast) this._toast("Save failed");
494
  }
495
  }
496
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  _toast(text) {
498
  const el = document.createElement("div");
499
  el.className = "fixed z-[60] bottom-5 left-1/2 -translate-x-1/2 rounded-lg bg-gray-900 text-white text-sm font-semibold px-4 py-2 shadow";
 
19
  redo: [],
20
  payload: null,
21
  };
22
+
23
+ this.sessionId = null;
24
+ this.apiBase = null;
25
+ this._saveTimer = null;
26
  }
27
 
28
  connectedCallback() {
 
34
  }
35
 
36
  // Public API
37
+ open({ payload, pageIndex = 0, totalPages = 6, sessionId = null } = {}) {
38
  this.state.payload = payload ?? null;
39
  this.state.isOpen = true;
40
+ this.sessionId =
41
+ sessionId ||
42
+ (window.REPEX && typeof window.REPEX.getSessionId === "function"
43
+ ? window.REPEX.getSessionId()
44
+ : null);
45
+ this.apiBase = window.REPEX && window.REPEX.apiBase ? window.REPEX.apiBase : null;
46
 
47
  // Load existing editor pages from storage, else initialize
48
  const stored = this._loadPages();
 
61
 
62
  this.show();
63
  this.updateAll();
64
+
65
+ if (this.sessionId) {
66
+ this._loadPagesFromServer().then((pages) => {
67
+ if (pages && pages.length) {
68
+ this.state.pages = pages;
69
+ this.state.activePage = Math.min(
70
+ Math.max(0, pageIndex),
71
+ this.state.pages.length - 1
72
+ );
73
+ this.updateAll();
74
+ }
75
+ });
76
+ }
77
  }
78
 
79
  close() {
 
139
  <div class="space-y-2 max-h-[60vh] overflow-auto pr-1" data-page-list></div>
140
 
141
  <div class="mt-3 text-xs text-gray-500">
142
+ Tip: Click a page to edit. Your edits are saved to the server.
143
  </div>
144
  </aside>
145
 
 
496
 
497
  // ---------- Storage ----------
498
  _storageKey() {
499
+ if (this.sessionId) {
500
+ return `repex_report_pages_v1_${this.sessionId}`;
501
+ }
502
  return "repex_report_pages_v1";
503
  }
504
 
 
514
  _savePages(showToast = false) {
515
  try {
516
  localStorage.setItem(this._storageKey(), JSON.stringify({ pages: this.state.pages }));
517
+ this._scheduleServerSave();
518
  if (showToast) this._toast("Saved");
519
  } catch {
520
  if (showToast) this._toast("Save failed");
521
  }
522
  }
523
 
524
+ _apiRoot() {
525
+ if (this.apiBase) return this.apiBase.replace(/\/$/, "");
526
+ if (window.REPEX && window.REPEX.apiBase) return window.REPEX.apiBase.replace(/\/$/, "");
527
+ return "";
528
+ }
529
+
530
+ async _loadPagesFromServer() {
531
+ const base = this._apiRoot();
532
+ if (!base || !this.sessionId) return null;
533
+ try {
534
+ const res = await fetch(`${base}/sessions/${this.sessionId}/pages`);
535
+ if (!res.ok) return null;
536
+ const data = await res.json();
537
+ if (data && Array.isArray(data.pages)) {
538
+ return data.pages;
539
+ }
540
+ } catch {}
541
+ return null;
542
+ }
543
+
544
+ _scheduleServerSave() {
545
+ if (!this.sessionId) return;
546
+ if (this._saveTimer) clearTimeout(this._saveTimer);
547
+ this._saveTimer = setTimeout(() => {
548
+ this._savePagesToServer();
549
+ }, 800);
550
+ }
551
+
552
+ async _savePagesToServer() {
553
+ const base = this._apiRoot();
554
+ if (!base || !this.sessionId) return;
555
+ try {
556
+ const res = await fetch(`${base}/sessions/${this.sessionId}/pages`, {
557
+ method: "PUT",
558
+ headers: { "Content-Type": "application/json" },
559
+ body: JSON.stringify({ pages: this.state.pages }),
560
+ });
561
+ if (!res.ok) {
562
+ throw new Error("Failed");
563
+ }
564
+ } catch {
565
+ this._toast("Sync failed");
566
+ }
567
+ }
568
+
569
  _toast(text) {
570
  const el = document.createElement("div");
571
  el.className = "fixed z-[60] bottom-5 left-1/2 -translate-x-1/2 rounded-lg bg-gray-900 text-white text-sm font-semibold px-4 py-2 shadow";
edit-layouts.html CHANGED
@@ -174,12 +174,7 @@ src="assets/prosento-logo.png"
174
  </div>
175
 
176
  <div class="text-xs text-gray-500">
177
- Uses storage keys:
178
- <div class="mt-1 font-mono text-[11px] text-gray-700">
179
- repex_report_pages_v1<br>
180
- repex_layout_settings_v1<br>
181
- repex_report_payload
182
- </div>
183
  </div>
184
  </div>
185
  </aside>
@@ -196,10 +191,8 @@ src="assets/prosento-logo.png"
196
  document.addEventListener('DOMContentLoaded', () => {
197
  if (window.feather && typeof window.feather.replace === 'function') feather.replace();
198
 
199
- const PAGES_KEY = 'repex_report_pages_v1';
200
- const LAYOUT_KEY = 'repex_layout_settings_v1';
201
- const PAYLOAD_KEY = 'repex_report_payload';
202
-
203
  const sumPages = document.getElementById('sumPages');
204
  const sumPhotos = document.getElementById('sumPhotos');
205
  const sumDocs = document.getElementById('sumDocs');
@@ -211,31 +204,17 @@ src="assets/prosento-logo.png"
211
  const incTimestamp = document.getElementById('incTimestamp');
212
  const downloadJson = document.getElementById('downloadJson');
213
 
214
- function loadJson(key) {
215
- try {
216
- const raw = localStorage.getItem(key);
217
- return raw ? JSON.parse(raw) : null;
218
- } catch {
219
- return null;
220
- }
221
  }
222
 
223
- const pagesObj = loadJson(PAGES_KEY);
224
- const pages = pagesObj?.pages ?? [];
225
- const layout = loadJson(LAYOUT_KEY);
226
- const payload = loadJson(PAYLOAD_KEY);
227
-
228
- const selectedPhotos = payload?.selectedPhotoIndices?.length ?? 0;
229
- const docs = payload?.uploads?.documents?.length ?? 0;
230
- const dataFiles = payload?.uploads?.dataFiles?.length ?? 0;
231
-
232
- // Count total items
233
- const totalItems = pages.reduce((acc, p) => acc + ((p?.items?.length) || 0), 0);
234
-
235
- sumPages.textContent = pages.length ? `${pages.length} pages • ${totalItems} total items` : 'No saved pages yet';
236
- sumPhotos.textContent = `${selectedPhotos}`;
237
- sumDocs.textContent = `${docs}`;
238
- sumData.textContent = `${dataFiles}`;
239
 
240
  function downloadBlob(filename, text) {
241
  const blob = new Blob([text], { type: 'application/json' });
@@ -249,16 +228,37 @@ src="assets/prosento-logo.png"
249
  URL.revokeObjectURL(url);
250
  }
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  downloadJson.addEventListener('click', () => {
253
  const pack = {};
254
- if (incPages.checked) pack.pages = pagesObj ?? null;
255
- if (incLayout.checked) pack.layout = layout ?? null;
256
- if (incPayload.checked) pack.payload = payload ?? null;
257
  if (incTimestamp.checked) pack.exportedAt = new Date().toISOString();
258
 
259
  const filename = `repex_report_package_${new Date().toISOString().replace(/[:.]/g,'-')}.json`;
260
  downloadBlob(filename, JSON.stringify(pack, null, 2));
261
  });
 
 
262
  });
263
  </script>
264
  </body>
 
174
  </div>
175
 
176
  <div class="text-xs text-gray-500">
177
+ Stored in the active server session.
 
 
 
 
 
178
  </div>
179
  </div>
180
  </aside>
 
191
  document.addEventListener('DOMContentLoaded', () => {
192
  if (window.feather && typeof window.feather.replace === 'function') feather.replace();
193
 
194
+ const sessionId = window.REPEX.getSessionId();
195
+ window.REPEX.setSessionId(sessionId);
 
 
196
  const sumPages = document.getElementById('sumPages');
197
  const sumPhotos = document.getElementById('sumPhotos');
198
  const sumDocs = document.getElementById('sumDocs');
 
204
  const incTimestamp = document.getElementById('incTimestamp');
205
  const downloadJson = document.getElementById('downloadJson');
206
 
207
+ if (!sessionId) {
208
+ sumPages.textContent = 'No active session.';
209
+ sumPhotos.textContent = '-';
210
+ sumDocs.textContent = '-';
211
+ sumData.textContent = '-';
212
+ downloadJson.disabled = true;
213
+ return;
214
  }
215
 
216
+ let session = null;
217
+ let pages = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
  function downloadBlob(filename, text) {
220
  const blob = new Blob([text], { type: 'application/json' });
 
228
  URL.revokeObjectURL(url);
229
  }
230
 
231
+ function updateSummary() {
232
+ const totalItems = pages.reduce((acc, p) => acc + ((p?.items?.length) || 0), 0);
233
+ sumPages.textContent = pages.length ? `${pages.length} pages ? ${totalItems} total items` : 'No saved pages yet';
234
+ sumPhotos.textContent = `${session?.selected_photo_ids?.length ?? 0}`;
235
+ sumDocs.textContent = `${session?.uploads?.documents?.length ?? 0}`;
236
+ sumData.textContent = `${session?.uploads?.data_files?.length ?? 0}`;
237
+ }
238
+
239
+ async function loadData() {
240
+ try {
241
+ session = await window.REPEX.request(`/sessions/${sessionId}`);
242
+ const pageResp = await window.REPEX.request(`/sessions/${sessionId}/pages`);
243
+ pages = Array.isArray(pageResp?.pages) ? pageResp.pages : [];
244
+ updateSummary();
245
+ } catch (err) {
246
+ sumPages.textContent = err.message || 'Failed to load session.';
247
+ }
248
+ }
249
+
250
  downloadJson.addEventListener('click', () => {
251
  const pack = {};
252
+ if (incPages.checked) pack.pages = pages;
253
+ if (incLayout.checked) pack.layout = session?.layout ?? null;
254
+ if (incPayload.checked) pack.payload = session;
255
  if (incTimestamp.checked) pack.exportedAt = new Date().toISOString();
256
 
257
  const filename = `repex_report_package_${new Date().toISOString().replace(/[:.]/g,'-')}.json`;
258
  downloadBlob(filename, JSON.stringify(pack, null, 2));
259
  });
260
+
261
+ loadData();
262
  });
263
  </script>
264
  </body>
export.html CHANGED
@@ -174,12 +174,7 @@ src="assets/prosento-logo.png"
174
  </div>
175
 
176
  <div class="text-xs text-gray-500">
177
- Uses storage keys:
178
- <div class="mt-1 font-mono text-[11px] text-gray-700">
179
- repex_report_pages_v1<br>
180
- repex_layout_settings_v1<br>
181
- repex_report_payload
182
- </div>
183
  </div>
184
  </div>
185
  </aside>
@@ -196,10 +191,8 @@ src="assets/prosento-logo.png"
196
  document.addEventListener('DOMContentLoaded', () => {
197
  if (window.feather && typeof window.feather.replace === 'function') feather.replace();
198
 
199
- const PAGES_KEY = 'repex_report_pages_v1';
200
- const LAYOUT_KEY = 'repex_layout_settings_v1';
201
- const PAYLOAD_KEY = 'repex_report_payload';
202
-
203
  const sumPages = document.getElementById('sumPages');
204
  const sumPhotos = document.getElementById('sumPhotos');
205
  const sumDocs = document.getElementById('sumDocs');
@@ -211,31 +204,17 @@ src="assets/prosento-logo.png"
211
  const incTimestamp = document.getElementById('incTimestamp');
212
  const downloadJson = document.getElementById('downloadJson');
213
 
214
- function loadJson(key) {
215
- try {
216
- const raw = localStorage.getItem(key);
217
- return raw ? JSON.parse(raw) : null;
218
- } catch {
219
- return null;
220
- }
221
  }
222
 
223
- const pagesObj = loadJson(PAGES_KEY);
224
- const pages = pagesObj?.pages ?? [];
225
- const layout = loadJson(LAYOUT_KEY);
226
- const payload = loadJson(PAYLOAD_KEY);
227
-
228
- const selectedPhotos = payload?.selectedPhotoIndices?.length ?? 0;
229
- const docs = payload?.uploads?.documents?.length ?? 0;
230
- const dataFiles = payload?.uploads?.dataFiles?.length ?? 0;
231
-
232
- // Count total items
233
- const totalItems = pages.reduce((acc, p) => acc + ((p?.items?.length) || 0), 0);
234
-
235
- sumPages.textContent = pages.length ? `${pages.length} pages • ${totalItems} total items` : 'No saved pages yet';
236
- sumPhotos.textContent = `${selectedPhotos}`;
237
- sumDocs.textContent = `${docs}`;
238
- sumData.textContent = `${dataFiles}`;
239
 
240
  function downloadBlob(filename, text) {
241
  const blob = new Blob([text], { type: 'application/json' });
@@ -249,18 +228,38 @@ src="assets/prosento-logo.png"
249
  URL.revokeObjectURL(url);
250
  }
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  downloadJson.addEventListener('click', () => {
253
  const pack = {};
254
- if (incPages.checked) pack.pages = pagesObj ?? null;
255
- if (incLayout.checked) pack.layout = layout ?? null;
256
- if (incPayload.checked) pack.payload = payload ?? null;
257
  if (incTimestamp.checked) pack.exportedAt = new Date().toISOString();
258
 
259
  const filename = `repex_report_package_${new Date().toISOString().replace(/[:.]/g,'-')}.json`;
260
  downloadBlob(filename, JSON.stringify(pack, null, 2));
261
  });
 
 
262
  });
263
  </script>
264
- <script src="https://huggingface.co/deepsite/deepsite-badge.js"></script>
265
  </body>
266
  </html>
 
174
  </div>
175
 
176
  <div class="text-xs text-gray-500">
177
+ Stored in the active server session.
 
 
 
 
 
178
  </div>
179
  </div>
180
  </aside>
 
191
  document.addEventListener('DOMContentLoaded', () => {
192
  if (window.feather && typeof window.feather.replace === 'function') feather.replace();
193
 
194
+ const sessionId = window.REPEX.getSessionId();
195
+ window.REPEX.setSessionId(sessionId);
 
 
196
  const sumPages = document.getElementById('sumPages');
197
  const sumPhotos = document.getElementById('sumPhotos');
198
  const sumDocs = document.getElementById('sumDocs');
 
204
  const incTimestamp = document.getElementById('incTimestamp');
205
  const downloadJson = document.getElementById('downloadJson');
206
 
207
+ if (!sessionId) {
208
+ sumPages.textContent = 'No active session.';
209
+ sumPhotos.textContent = '-';
210
+ sumDocs.textContent = '-';
211
+ sumData.textContent = '-';
212
+ downloadJson.disabled = true;
213
+ return;
214
  }
215
 
216
+ let session = null;
217
+ let pages = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
  function downloadBlob(filename, text) {
220
  const blob = new Blob([text], { type: 'application/json' });
 
228
  URL.revokeObjectURL(url);
229
  }
230
 
231
+ function updateSummary() {
232
+ const totalItems = pages.reduce((acc, p) => acc + ((p?.items?.length) || 0), 0);
233
+ sumPages.textContent = pages.length ? `${pages.length} pages ? ${totalItems} total items` : 'No saved pages yet';
234
+ sumPhotos.textContent = `${session?.selected_photo_ids?.length ?? 0}`;
235
+ sumDocs.textContent = `${session?.uploads?.documents?.length ?? 0}`;
236
+ sumData.textContent = `${session?.uploads?.data_files?.length ?? 0}`;
237
+ }
238
+
239
+ async function loadData() {
240
+ try {
241
+ session = await window.REPEX.request(`/sessions/${sessionId}`);
242
+ const pageResp = await window.REPEX.request(`/sessions/${sessionId}/pages`);
243
+ pages = Array.isArray(pageResp?.pages) ? pageResp.pages : [];
244
+ updateSummary();
245
+ } catch (err) {
246
+ sumPages.textContent = err.message || 'Failed to load session.';
247
+ }
248
+ }
249
+
250
  downloadJson.addEventListener('click', () => {
251
  const pack = {};
252
+ if (incPages.checked) pack.pages = pages;
253
+ if (incLayout.checked) pack.layout = session?.layout ?? null;
254
+ if (incPayload.checked) pack.payload = session;
255
  if (incTimestamp.checked) pack.exportedAt = new Date().toISOString();
256
 
257
  const filename = `repex_report_package_${new Date().toISOString().replace(/[:.]/g,'-')}.json`;
258
  downloadBlob(filename, JSON.stringify(pack, null, 2));
259
  });
260
+
261
+ loadData();
262
  });
263
  </script>
 
264
  </body>
265
  </html>
index.html CHANGED
@@ -134,6 +134,7 @@
134
  <div class="rounded-lg border border-gray-200 bg-white p-6">
135
  <!-- Drop zone -->
136
  <div
 
137
  class="rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center hover:border-blue-400 transition mb-6"
138
  role="button"
139
  tabindex="0"
@@ -147,14 +148,23 @@
147
  <p class="text-sm text-gray-600 mt-1 mb-4">or browse to upload</p>
148
 
149
  <button
 
150
  type="button"
151
  class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-5 py-2.5 text-white font-semibold hover:bg-blue-700 transition"
152
  >
153
  Browse Files
154
  </button>
155
 
156
- <p class="text-xs text-gray-500 mt-4">
157
- Supports JPG, PNG, PDF, DOCX (Max 50MB each)
 
 
 
 
 
 
 
 
158
  </p>
159
  </div>
160
 
@@ -194,12 +204,14 @@
194
  </div>
195
 
196
  <button
 
197
  type="button"
198
  class="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 text-white font-semibold hover:bg-emerald-700 transition"
199
  >
200
  <i data-feather="file-plus" class="h-5 w-5"></i>
201
  Generate Report
202
  </button>
 
203
  </div>
204
  </section>
205
 
@@ -232,42 +244,10 @@
232
  </tr>
233
  </thead>
234
 
235
- <tbody class="bg-white divide-y divide-gray-200">
236
  <tr>
237
- <td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">#RPT-2023-001</td>
238
- <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">Bridge Inspection - Main Span</td>
239
- <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">2023-10-15</td>
240
- <td class="px-6 py-4 whitespace-nowrap">
241
- <span class="inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold bg-emerald-50 text-emerald-700 border-emerald-200">
242
- Completed
243
- </span>
244
- </td>
245
- <td class="px-6 py-4 whitespace-nowrap text-sm">
246
- <a href="#" class="inline-flex items-center text-blue-700 hover:text-blue-800 mr-3" aria-label="Download report">
247
- <i data-feather="download" class="h-4 w-4"></i>
248
- </a>
249
- <a href="#" class="inline-flex items-center text-blue-700 hover:text-blue-800" aria-label="Edit report">
250
- <i data-feather="edit" class="h-4 w-4"></i>
251
- </a>
252
- </td>
253
- </tr>
254
-
255
- <tr>
256
- <td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">#RPT-2023-002</td>
257
- <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">Building Facade Assessment</td>
258
- <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">2023-10-18</td>
259
- <td class="px-6 py-4 whitespace-nowrap">
260
- <span class="inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold bg-amber-50 text-amber-700 border-amber-200">
261
- Processing
262
- </span>
263
- </td>
264
- <td class="px-6 py-4 whitespace-nowrap text-sm">
265
- <a href="#" class="inline-flex items-center text-gray-400 cursor-not-allowed mr-3" aria-label="Download report (disabled)">
266
- <i data-feather="download" class="h-4 w-4"></i>
267
- </a>
268
- <a href="#" class="inline-flex items-center text-blue-700 hover:text-blue-800" aria-label="Edit report">
269
- <i data-feather="edit" class="h-4 w-4"></i>
270
- </a>
271
  </td>
272
  </tr>
273
  </tbody>
@@ -291,6 +271,127 @@
291
  if (window.feather && typeof window.feather.replace === 'function') {
292
  window.feather.replace();
293
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  });
295
  </script>
296
  </body>
 
134
  <div class="rounded-lg border border-gray-200 bg-white p-6">
135
  <!-- Drop zone -->
136
  <div
137
+ id="dropZone"
138
  class="rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center hover:border-blue-400 transition mb-6"
139
  role="button"
140
  tabindex="0"
 
148
  <p class="text-sm text-gray-600 mt-1 mb-4">or browse to upload</p>
149
 
150
  <button
151
+ id="browseFiles"
152
  type="button"
153
  class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-5 py-2.5 text-white font-semibold hover:bg-blue-700 transition"
154
  >
155
  Browse Files
156
  </button>
157
 
158
+ <input
159
+ id="uploadInput"
160
+ type="file"
161
+ class="hidden"
162
+ multiple
163
+ accept=".jpg,.jpeg,.png,.webp,.pdf,.doc,.docx,.csv,.xls,.xlsx"
164
+ />
165
+
166
+ <p id="fileList" class="text-xs text-gray-500 mt-4">
167
+ Supports JPG, PNG, PDF, DOCX, CSV, XLSX (Max 50MB each)
168
  </p>
169
  </div>
170
 
 
204
  </div>
205
 
206
  <button
207
+ id="generateReport"
208
  type="button"
209
  class="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 text-white font-semibold hover:bg-emerald-700 transition"
210
  >
211
  <i data-feather="file-plus" class="h-5 w-5"></i>
212
  Generate Report
213
  </button>
214
+ <p id="uploadStatus" class="text-sm text-gray-600 mt-3"></p>
215
  </div>
216
  </section>
217
 
 
244
  </tr>
245
  </thead>
246
 
247
+ <tbody id="recentReports" class="bg-white divide-y divide-gray-200">
248
  <tr>
249
+ <td colspan="5" class="px-6 py-6 text-sm text-gray-500 text-center">
250
+ No recent reports yet.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  </td>
252
  </tr>
253
  </tbody>
 
271
  if (window.feather && typeof window.feather.replace === 'function') {
272
  window.feather.replace();
273
  }
274
+
275
+ const dropZone = document.getElementById('dropZone');
276
+ const browseFiles = document.getElementById('browseFiles');
277
+ const uploadInput = document.getElementById('uploadInput');
278
+ const fileList = document.getElementById('fileList');
279
+ const generateReport = document.getElementById('generateReport');
280
+ const uploadStatus = document.getElementById('uploadStatus');
281
+
282
+ const projectName = document.getElementById('projectName');
283
+ const inspectionDate = document.getElementById('inspectionDate');
284
+ const notes = document.getElementById('notes');
285
+
286
+ let selectedFiles = [];
287
+
288
+ const updateFileList = () => {
289
+ if (!selectedFiles.length) {
290
+ fileList.textContent = 'Supports JPG, PNG, PDF, DOCX, CSV, XLSX (Max 50MB each)';
291
+ return;
292
+ }
293
+ const summary = selectedFiles
294
+ .map((file) => `${file.name} (${window.REPEX.formatBytes(file.size)})`)
295
+ .join(' • ');
296
+ fileList.textContent = summary;
297
+ };
298
+
299
+ browseFiles.addEventListener('click', () => uploadInput.click());
300
+ uploadInput.addEventListener('change', (event) => {
301
+ selectedFiles = Array.from(event.target.files || []);
302
+ updateFileList();
303
+ });
304
+
305
+ const setDragState = (active) => {
306
+ dropZone.classList.toggle('border-blue-500', active);
307
+ dropZone.classList.toggle('bg-blue-50', active);
308
+ };
309
+
310
+ ['dragenter', 'dragover'].forEach((evt) => {
311
+ dropZone.addEventListener(evt, (event) => {
312
+ event.preventDefault();
313
+ setDragState(true);
314
+ });
315
+ });
316
+ ['dragleave', 'drop'].forEach((evt) => {
317
+ dropZone.addEventListener(evt, (event) => {
318
+ event.preventDefault();
319
+ setDragState(false);
320
+ });
321
+ });
322
+ dropZone.addEventListener('drop', (event) => {
323
+ const files = Array.from(event.dataTransfer.files || []);
324
+ if (files.length) {
325
+ selectedFiles = files;
326
+ uploadInput.value = '';
327
+ updateFileList();
328
+ }
329
+ });
330
+
331
+ generateReport.addEventListener('click', async () => {
332
+ if (!selectedFiles.length) {
333
+ uploadStatus.textContent = 'Please add at least one file to continue.';
334
+ uploadStatus.classList.add('text-amber-700');
335
+ return;
336
+ }
337
+ generateReport.disabled = true;
338
+ uploadStatus.textContent = 'Uploading files...';
339
+ uploadStatus.classList.remove('text-amber-700');
340
+
341
+ const formData = new FormData();
342
+ formData.append('project_name', projectName.value.trim());
343
+ formData.append('inspection_date', inspectionDate.value);
344
+ formData.append('notes', notes.value.trim());
345
+ selectedFiles.forEach((file) => formData.append('files', file));
346
+
347
+ try {
348
+ const session = await window.REPEX.postForm('/sessions', formData);
349
+ window.REPEX.setSessionId(session.id);
350
+ window.location.href = `processing.html?session=${encodeURIComponent(session.id)}`;
351
+ } catch (err) {
352
+ uploadStatus.textContent = err.message || 'Upload failed.';
353
+ uploadStatus.classList.add('text-red-600');
354
+ generateReport.disabled = false;
355
+ }
356
+ });
357
+
358
+ async function loadRecentReports() {
359
+ const table = document.getElementById('recentReports');
360
+ if (!table) return;
361
+ try {
362
+ const sessions = await window.REPEX.request('/sessions');
363
+ if (!Array.isArray(sessions) || sessions.length === 0) {
364
+ return;
365
+ }
366
+ table.innerHTML = '';
367
+ sessions.slice(0, 5).forEach((session) => {
368
+ const row = document.createElement('tr');
369
+ row.innerHTML = `
370
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">#${session.id.slice(0, 8)}</td>
371
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${session.project_name || 'Untitled project'}</td>
372
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${session.created_at ? new Date(session.created_at).toLocaleDateString() : '-'}</td>
373
+ <td class="px-6 py-4 whitespace-nowrap">
374
+ <span class="inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold bg-emerald-50 text-emerald-700 border-emerald-200">
375
+ ${session.status || 'ready'}
376
+ </span>
377
+ </td>
378
+ <td class="px-6 py-4 whitespace-nowrap text-sm">
379
+ <a href="report-viewer.html?session=${session.id}" class="inline-flex items-center text-blue-700 hover:text-blue-800" aria-label="View report">
380
+ <i data-feather="edit" class="h-4 w-4"></i>
381
+ </a>
382
+ </td>
383
+ `;
384
+ table.appendChild(row);
385
+ });
386
+ if (window.feather && typeof window.feather.replace === 'function') {
387
+ window.feather.replace();
388
+ }
389
+ } catch {
390
+ // ignore for now
391
+ }
392
+ }
393
+
394
+ loadRecentReports();
395
  });
396
  </script>
397
  </body>
processing.html CHANGED
@@ -50,6 +50,30 @@
50
  if (window.feather && typeof window.feather.replace === 'function') {
51
  window.feather.replace();
52
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  });
54
  </script>
55
  </body>
 
50
  if (window.feather && typeof window.feather.replace === 'function') {
51
  window.feather.replace();
52
  }
53
+
54
+ const sessionId = window.REPEX.getSessionId();
55
+ window.REPEX.setSessionId(sessionId);
56
+ if (!sessionId) {
57
+ const message = document.createElement('p');
58
+ message.className = 'text-sm text-red-600 mt-6 text-center';
59
+ message.textContent = 'No active session found. Return to the upload page.';
60
+ document.querySelector('main').appendChild(message);
61
+ return;
62
+ }
63
+
64
+ const poll = async () => {
65
+ try {
66
+ const status = await window.REPEX.request(`/sessions/${sessionId}/status`);
67
+ if (status?.status === 'ready') {
68
+ window.location.href = `review-setup.html?session=${encodeURIComponent(sessionId)}`;
69
+ }
70
+ } catch {
71
+ // keep polling on transient errors
72
+ }
73
+ };
74
+
75
+ poll();
76
+ setInterval(poll, 2000);
77
  });
78
  </script>
79
  </body>
report-viewer.html CHANGED
@@ -85,13 +85,12 @@ src="assets/prosento-logo.png"
85
  <!-- Workflow buttons -->
86
  <nav class="mb-6 no-print" aria-label="Report workflow navigation">
87
  <div class="flex flex-wrap gap-2">
88
- <button
89
- type="button"
90
  class="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"
91
  >
92
  <i data-feather="layout" class="h-4 w-4"></i>
93
  Report Viewer
94
- </button>
95
 
96
  <button
97
  type="button"
@@ -102,21 +101,21 @@ src="assets/prosento-logo.png"
102
  Edit Report
103
  </button>
104
 
105
- <button
106
- type="button"
107
  class="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"
108
  >
109
  <i data-feather="grid" class="h-4 w-4"></i>
110
  Edit Page Layouts
111
- </button>
112
 
113
- <button
114
- type="button"
115
  class="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"
116
  >
117
  <i data-feather="download" class="h-4 w-4"></i>
118
  Export
119
- </button>
120
  </div>
121
  </nav>
122
 
@@ -177,12 +176,18 @@ src="assets/prosento-logo.png"
177
  <script src="script.js"></script>
178
  <script>
179
  document.addEventListener('DOMContentLoaded', () => {
180
- // Feather icons
181
  if (window.feather && typeof window.feather.replace === 'function') {
182
  window.feather.replace();
183
  }
184
 
185
- // Mount editor (hidden until opened)
 
 
 
 
 
 
 
186
  const editor = document.createElement('report-editor');
187
  document.body.appendChild(editor);
188
  editor.style.display = 'none';
@@ -197,57 +202,35 @@ src="assets/prosento-logo.png"
197
  const pageTotal = document.getElementById('pageTotal');
198
  const editBtn = document.getElementById('edit-report');
199
 
200
- // A4 model dimensions (must match editor)
201
  const BASE_W = 595;
202
  const BASE_H = 842;
203
 
204
- // ---- Load payload from Review page (optional meta) ----
205
- const payloadRaw = localStorage.getItem('repex_report_payload');
206
- let payload = null;
207
- try { payload = payloadRaw ? JSON.parse(payloadRaw) : null; } catch { payload = null; }
208
-
209
- // ---- Pages storage used by editor ----
210
- const PAGES_STORAGE_KEY = 'repex_report_pages_v1';
211
-
212
- function loadEditorPages() {
213
- try {
214
- const raw = localStorage.getItem(PAGES_STORAGE_KEY);
215
- const parsed = raw ? JSON.parse(raw) : null;
216
- if (parsed && Array.isArray(parsed.pages)) return parsed.pages;
217
- return null;
218
- } catch {
219
- return null;
220
- }
221
- }
222
-
223
- // ---- State ----
224
  const state = {
225
  pageIndex: 0,
226
  totalPages: 6,
227
  editMode: false,
228
- pages: null
 
229
  };
230
 
231
  function setMeta() {
232
- const selected = payload?.selectedPhotoIndices?.length ?? 0;
233
- const docs = payload?.uploads?.documents?.length ?? 0;
234
- const dataFiles = payload?.uploads?.dataFiles?.length ?? 0;
235
-
236
- const hasEdits = !!state.pages;
237
  viewerMeta.textContent =
238
- `Selected example photos: ${selected} Documents: ${docs} Data files: ${dataFiles}` +
239
- (hasEdits ? ` Edited pages loaded` : ` No saved edits yet`);
240
  }
241
 
242
  function renderControls() {
243
- pageTotal.textContent = String(state.totalPages);
244
  pageNumber.textContent = String(state.pageIndex + 1);
245
  prevPageBtn.disabled = state.pageIndex === 0;
246
- nextPageBtn.disabled = state.pageIndex === state.totalPages - 1;
247
  }
248
 
249
  function stageScale() {
250
- // stage width is in CSS pixels; map model coords to it
251
  const rect = pageStage.getBoundingClientRect();
252
  return rect.width / BASE_W;
253
  }
@@ -262,16 +245,13 @@ src="assets/prosento-logo.png"
262
  const items = pageObj?.items ?? [];
263
  const hasItems = items.length > 0;
264
 
265
- // page background style
266
  pageStage.classList.toggle('page-empty', !hasItems);
267
  pageStage.classList.toggle('page-white', hasItems);
268
 
269
- // If empty, leave blank red page
270
  if (!hasItems) return;
271
 
272
  const scale = stageScale();
273
 
274
- // Render items sorted by z
275
  items
276
  .slice()
277
  .sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
@@ -325,16 +305,28 @@ src="assets/prosento-logo.png"
325
  });
326
  }
327
 
328
- function renderPage() {
329
- // Reload pages each time to reflect saved edits immediately
330
- state.pages = loadEditorPages();
331
- state.totalPages = state.pages?.length ?? state.totalPages;
 
332
 
333
- renderControls();
 
 
 
 
 
 
 
 
 
 
334
 
335
- const pageObj = state.pages ? state.pages[state.pageIndex] : null;
 
 
336
  renderStageFromPage(pageObj);
337
-
338
  setMeta();
339
  }
340
 
@@ -357,14 +349,11 @@ src="assets/prosento-logo.png"
357
  editor.style.display = 'block';
358
  viewerSection.classList.add('hidden');
359
 
360
- // Ensure the editor has the latest page count
361
- const pages = loadEditorPages();
362
- const totalPages = pages?.length ?? state.totalPages;
363
-
364
  editor.open({
365
- payload,
366
  pageIndex: state.pageIndex,
367
- totalPages
 
368
  });
369
 
370
  editBtn.classList.add('bg-gray-900', 'text-white', 'border-gray-900');
@@ -375,7 +364,7 @@ src="assets/prosento-logo.png"
375
  }
376
  }
377
 
378
- function closeEditor() {
379
  state.editMode = false;
380
  editor.style.display = 'none';
381
  viewerSection.classList.remove('hidden');
@@ -383,19 +372,20 @@ src="assets/prosento-logo.png"
383
  editBtn.classList.remove('bg-gray-900', 'text-white', 'border-gray-900');
384
  editBtn.classList.add('bg-white', 'text-gray-800', 'border-gray-200');
385
 
386
- // Re-render to reflect latest saved edits
387
  renderPage();
388
  }
389
 
390
- // Events
391
  prevPageBtn.addEventListener('click', prevPage);
392
  nextPageBtn.addEventListener('click', nextPage);
393
 
394
- document.getElementById('edit-report').addEventListener('click', () => {
395
  if (!state.editMode) openEditor();
396
  });
397
 
398
- editor.addEventListener('editor-closed', closeEditor);
 
 
399
 
400
  window.addEventListener('keydown', (e) => {
401
  if (state.editMode) return;
@@ -403,7 +393,6 @@ src="assets/prosento-logo.png"
403
  if (e.key === 'ArrowLeft') prevPage();
404
  });
405
 
406
- // Re-render on resize so scaling stays correct
407
  window.addEventListener('resize', () => {
408
  if (!state.editMode) renderPage();
409
  });
@@ -412,8 +401,11 @@ src="assets/prosento-logo.png"
412
  document.querySelectorAll('img').forEach(img => (img.loading = 'eager'));
413
  });
414
 
415
- // Initial render
416
- renderPage();
 
 
 
417
  });
418
  </script>
419
  </body>
 
85
  <!-- Workflow buttons -->
86
  <nav class="mb-6 no-print" aria-label="Report workflow navigation">
87
  <div class="flex flex-wrap gap-2">
88
+ <span
 
89
  class="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"
90
  >
91
  <i data-feather="layout" class="h-4 w-4"></i>
92
  Report Viewer
93
+ </span>
94
 
95
  <button
96
  type="button"
 
101
  Edit Report
102
  </button>
103
 
104
+ <a
105
+ href="edit-layouts.html"
106
  class="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"
107
  >
108
  <i data-feather="grid" class="h-4 w-4"></i>
109
  Edit Page Layouts
110
+ </a>
111
 
112
+ <a
113
+ href="export.html"
114
  class="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"
115
  >
116
  <i data-feather="download" class="h-4 w-4"></i>
117
  Export
118
+ </a>
119
  </div>
120
  </nav>
121
 
 
176
  <script src="script.js"></script>
177
  <script>
178
  document.addEventListener('DOMContentLoaded', () => {
 
179
  if (window.feather && typeof window.feather.replace === 'function') {
180
  window.feather.replace();
181
  }
182
 
183
+ const sessionId = window.REPEX.getSessionId();
184
+ window.REPEX.setSessionId(sessionId);
185
+ if (!sessionId) {
186
+ const meta = document.getElementById('viewerMeta');
187
+ if (meta) meta.textContent = 'No active session found. Return to upload to continue.';
188
+ return;
189
+ }
190
+
191
  const editor = document.createElement('report-editor');
192
  document.body.appendChild(editor);
193
  editor.style.display = 'none';
 
202
  const pageTotal = document.getElementById('pageTotal');
203
  const editBtn = document.getElementById('edit-report');
204
 
 
205
  const BASE_W = 595;
206
  const BASE_H = 842;
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  const state = {
209
  pageIndex: 0,
210
  totalPages: 6,
211
  editMode: false,
212
+ pages: [],
213
+ session: null,
214
  };
215
 
216
  function setMeta() {
217
+ const selected = state.session?.selected_photo_ids?.length ?? 0;
218
+ const docs = state.session?.uploads?.documents?.length ?? 0;
219
+ const dataFiles = state.session?.uploads?.data_files?.length ?? 0;
220
+ const hasEdits = state.pages.length > 0;
 
221
  viewerMeta.textContent =
222
+ `Selected example photos: ${selected} ? Documents: ${docs} ? Data files: ${dataFiles}` +
223
+ (hasEdits ? ' ? Edited pages loaded' : ' ? No saved edits yet');
224
  }
225
 
226
  function renderControls() {
227
+ pageTotal.textContent = String(state.totalPages || 1);
228
  pageNumber.textContent = String(state.pageIndex + 1);
229
  prevPageBtn.disabled = state.pageIndex === 0;
230
+ nextPageBtn.disabled = state.pageIndex >= state.totalPages - 1;
231
  }
232
 
233
  function stageScale() {
 
234
  const rect = pageStage.getBoundingClientRect();
235
  return rect.width / BASE_W;
236
  }
 
245
  const items = pageObj?.items ?? [];
246
  const hasItems = items.length > 0;
247
 
 
248
  pageStage.classList.toggle('page-empty', !hasItems);
249
  pageStage.classList.toggle('page-white', hasItems);
250
 
 
251
  if (!hasItems) return;
252
 
253
  const scale = stageScale();
254
 
 
255
  items
256
  .slice()
257
  .sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
 
305
  });
306
  }
307
 
308
+ async function fetchSession() {
309
+ const session = await window.REPEX.request(`/sessions/${sessionId}`);
310
+ state.session = session;
311
+ state.totalPages = session.page_count || state.totalPages;
312
+ }
313
 
314
+ async function fetchPages() {
315
+ try {
316
+ const result = await window.REPEX.request(`/sessions/${sessionId}/pages`);
317
+ state.pages = Array.isArray(result?.pages) ? result.pages : [];
318
+ if (state.pages.length) {
319
+ state.totalPages = Math.max(state.totalPages, state.pages.length);
320
+ }
321
+ } catch {
322
+ state.pages = [];
323
+ }
324
+ }
325
 
326
+ function renderPage() {
327
+ renderControls();
328
+ const pageObj = state.pages?.[state.pageIndex] ?? null;
329
  renderStageFromPage(pageObj);
 
330
  setMeta();
331
  }
332
 
 
349
  editor.style.display = 'block';
350
  viewerSection.classList.add('hidden');
351
 
 
 
 
 
352
  editor.open({
353
+ payload: state.session,
354
  pageIndex: state.pageIndex,
355
+ totalPages: state.totalPages,
356
+ sessionId,
357
  });
358
 
359
  editBtn.classList.add('bg-gray-900', 'text-white', 'border-gray-900');
 
364
  }
365
  }
366
 
367
+ async function closeEditor() {
368
  state.editMode = false;
369
  editor.style.display = 'none';
370
  viewerSection.classList.remove('hidden');
 
372
  editBtn.classList.remove('bg-gray-900', 'text-white', 'border-gray-900');
373
  editBtn.classList.add('bg-white', 'text-gray-800', 'border-gray-200');
374
 
375
+ await fetchPages();
376
  renderPage();
377
  }
378
 
 
379
  prevPageBtn.addEventListener('click', prevPage);
380
  nextPageBtn.addEventListener('click', nextPage);
381
 
382
+ editBtn.addEventListener('click', () => {
383
  if (!state.editMode) openEditor();
384
  });
385
 
386
+ editor.addEventListener('editor-closed', () => {
387
+ closeEditor();
388
+ });
389
 
390
  window.addEventListener('keydown', (e) => {
391
  if (state.editMode) return;
 
393
  if (e.key === 'ArrowLeft') prevPage();
394
  });
395
 
 
396
  window.addEventListener('resize', () => {
397
  if (!state.editMode) renderPage();
398
  });
 
401
  document.querySelectorAll('img').forEach(img => (img.loading = 'eager'));
402
  });
403
 
404
+ (async () => {
405
+ await fetchSession();
406
+ await fetchPages();
407
+ renderPage();
408
+ })();
409
  });
410
  </script>
411
  </body>
review-setup.html CHANGED
@@ -202,34 +202,10 @@ src="assets/prosento-logo.png"
202
  <script src="script.js"></script>
203
  <script>
204
  document.addEventListener('DOMContentLoaded', () => {
205
- // Feather icons
206
  if (window.feather && typeof window.feather.replace === 'function') {
207
  window.feather.replace();
208
  }
209
 
210
- // ---- Simulated "uploads processed" payload ----
211
- // Replace this with the real payload passed from the progress page (e.g., localStorage, URL params, backend session).
212
- const processedUploads = {
213
- photos: [
214
- { name: 'photo_01.jpg', url: 'https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Picture2.png' },
215
- { name: 'photo_02.jpg', url: 'https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Screenshot%202026-02-02%20100102.png' },
216
- { name: 'photo_03.jpg', url: 'https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Picture2.png' }
217
- ],
218
- documents: [
219
- { name: 'inspection_notes.pdf', type: 'PDF' },
220
- { name: 'supporting_docs.docx', type: 'DOCX' }
221
- ],
222
- dataFiles: [
223
- { name: 'report_data.xlsx', type: 'XLSX' }
224
- ]
225
- };
226
-
227
- // ---- State ----
228
- const state = {
229
- selectedPhotoIds: new Set(),
230
- };
231
-
232
- // ---- Elements ----
233
  const photoGrid = document.getElementById('photoGrid');
234
  const photoCount = document.getElementById('photoCount');
235
  const photoSelected = document.getElementById('photoSelected');
@@ -246,34 +222,62 @@ src="assets/prosento-logo.png"
246
  const readyStatus = document.getElementById('readyStatus');
247
  const continueBtn = document.getElementById('continueBtn');
248
 
249
- function renderUploads() {
250
- // Photos
251
- const photos = processedUploads.photos || [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  photoCount.textContent = `${photos.length} file${photos.length === 1 ? '' : 's'}`;
253
 
254
- photoGrid.innerHTML = photos.map((p, idx) => {
255
- const id = `photo-${idx}`;
256
- return `
257
- <label class="group cursor-pointer">
258
- <input type="checkbox" class="sr-only photoCheck" data-photo-id="${id}">
259
- <div class="rounded-lg border border-gray-200 bg-gray-50 overflow-hidden group-has-[:checked]:ring-2 group-has-[:checked]:ring-emerald-200 group-has-[:checked]:border-emerald-300 transition">
260
- <div class="relative">
261
- <img src="${p.url}" alt="${p.name}" class="h-28 w-full object-cover" loading="eager">
262
- <div class="absolute top-2 right-2 inline-flex items-center justify-center rounded-full bg-white/90 border border-gray-200 p-1.5 text-gray-700 group-has-[:checked]:bg-emerald-50 group-has-[:checked]:border-emerald-200 group-has-[:checked]:text-emerald-700">
263
- <i data-feather="check" class="h-4 w-4"></i>
 
 
 
 
 
 
 
 
264
  </div>
265
- </div>
266
- <div class="p-2">
267
- <div class="text-xs font-semibold text-gray-900 truncate">${p.name}</div>
268
- <div class="text-xs text-gray-500">Click to select for report</div>
269
- </div>
270
- </div>
271
- </label>
272
- `;
273
- }).join('');
274
 
275
- // Documents
276
- const docs = processedUploads.documents || [];
277
  docCount.textContent = `${docs.length} file${docs.length === 1 ? '' : 's'}`;
278
  docList.innerHTML = docs.length
279
  ? docs.map(d => `
@@ -282,14 +286,13 @@ src="assets/prosento-logo.png"
282
  <i data-feather="file-text" class="h-4 w-4 text-gray-600"></i>
283
  <span class="truncate text-gray-800">${d.name}</span>
284
  </div>
285
- <span class="text-xs font-semibold text-gray-600">${d.type}</span>
286
  </li>
287
  `).join('')
288
  : `<li class="text-sm text-gray-500">No supporting documents detected.</li>`;
289
  docHint.style.display = docs.length ? 'none' : 'block';
290
 
291
- // Data files
292
- const dataFiles = processedUploads.dataFiles || [];
293
  dataCount.textContent = `${dataFiles.length} file${dataFiles.length === 1 ? '' : 's'}`;
294
  dataBox.innerHTML = dataFiles.length
295
  ? dataFiles.map(f => `
@@ -299,7 +302,7 @@ src="assets/prosento-logo.png"
299
  <i data-feather="table" class="h-4 w-4 text-amber-700"></i>
300
  <span class="truncate font-semibold text-gray-900">${f.name}</span>
301
  </div>
302
- <span class="text-xs font-semibold text-gray-600">${f.type}</span>
303
  </div>
304
  <div class="text-xs text-gray-600 mt-1">
305
  Will populate report data areas (tables/fields).
@@ -313,31 +316,22 @@ src="assets/prosento-logo.png"
313
  </div>
314
  `;
315
 
316
- // Icons for dynamic content
317
  if (window.feather && typeof window.feather.replace === 'function') {
318
  window.feather.replace();
319
  }
320
  }
321
 
322
- function updateSelectionState() {
323
- photoSelected.textContent = String(state.selectedPhotoIds.size);
324
- const canContinue = state.selectedPhotoIds.size > 0;
325
- continueBtn.disabled = !canContinue;
326
-
327
- if (!canContinue) {
328
- readyStatus.textContent = 'Choose report example images to continue…';
329
- readyStatus.className = 'font-semibold text-amber-700';
330
- } else {
331
- readyStatus.textContent = 'Ready. Continue to report viewer.';
332
- readyStatus.className = 'font-semibold text-emerald-700';
333
  }
334
  }
335
 
336
- // Init render
337
- renderUploads();
338
- updateSelectionState();
339
-
340
- // Photo selection tracking
341
  photoGrid.addEventListener('change', (e) => {
342
  const cb = e.target.closest('.photoCheck');
343
  if (!cb) return;
@@ -361,24 +355,21 @@ src="assets/prosento-logo.png"
361
  updateSelectionState();
362
  });
363
 
364
- // Continue → go to report-viewer.html and pass selections
365
- continueBtn.addEventListener('click', () => {
366
  if (state.selectedPhotoIds.size === 0) return;
367
-
368
- const selectedIndices = Array.from(state.selectedPhotoIds)
369
- .map(id => Number(id.replace('photo-', '')))
370
- .filter(n => Number.isFinite(n));
371
-
372
- const payload = {
373
- // what the viewer might need later
374
- selectedPhotoIndices: selectedIndices,
375
- uploads: processedUploads,
376
- createdAt: new Date().toISOString(),
377
- };
378
-
379
- localStorage.setItem('repex_report_payload', JSON.stringify(payload));
380
- window.location.href = 'report-viewer.html';
381
  });
 
 
382
  });
383
  </script>
384
  </body>
 
202
  <script src="script.js"></script>
203
  <script>
204
  document.addEventListener('DOMContentLoaded', () => {
 
205
  if (window.feather && typeof window.feather.replace === 'function') {
206
  window.feather.replace();
207
  }
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  const photoGrid = document.getElementById('photoGrid');
210
  const photoCount = document.getElementById('photoCount');
211
  const photoSelected = document.getElementById('photoSelected');
 
222
  const readyStatus = document.getElementById('readyStatus');
223
  const continueBtn = document.getElementById('continueBtn');
224
 
225
+ const sessionId = window.REPEX.getSessionId();
226
+ window.REPEX.setSessionId(sessionId);
227
+ if (!sessionId) {
228
+ readyStatus.textContent = 'No active session found. Return to upload to continue.';
229
+ readyStatus.className = 'font-semibold text-red-600';
230
+ continueBtn.disabled = true;
231
+ return;
232
+ }
233
+
234
+ const state = {
235
+ selectedPhotoIds: new Set(),
236
+ };
237
+
238
+ function updateSelectionState() {
239
+ photoSelected.textContent = String(state.selectedPhotoIds.size);
240
+ const canContinue = state.selectedPhotoIds.size > 0;
241
+ continueBtn.disabled = !canContinue;
242
+
243
+ if (!canContinue) {
244
+ readyStatus.textContent = 'Choose report example images to continue...';
245
+ readyStatus.className = 'font-semibold text-amber-700';
246
+ } else {
247
+ readyStatus.textContent = 'Ready. Continue to report viewer.';
248
+ readyStatus.className = 'font-semibold text-emerald-700';
249
+ }
250
+ }
251
+
252
+ function renderUploads(uploads, selectedIds) {
253
+ const photos = uploads.photos || [];
254
  photoCount.textContent = `${photos.length} file${photos.length === 1 ? '' : 's'}`;
255
 
256
+ photoGrid.innerHTML = photos.length
257
+ ? photos.map((p) => {
258
+ const checked = selectedIds.includes(p.id) ? 'checked' : '';
259
+ if (checked) state.selectedPhotoIds.add(p.id);
260
+ return `
261
+ <label class="group cursor-pointer">
262
+ <input type="checkbox" class="sr-only photoCheck" data-photo-id="${p.id}" ${checked}>
263
+ <div class="rounded-lg border border-gray-200 bg-gray-50 overflow-hidden group-has-[:checked]:ring-2 group-has-[:checked]:ring-emerald-200 group-has-[:checked]:border-emerald-300 transition">
264
+ <div class="relative">
265
+ <img src="${p.url}" alt="${p.name}" class="h-28 w-full object-cover" loading="eager">
266
+ <div class="absolute top-2 right-2 inline-flex items-center justify-center rounded-full bg-white/90 border border-gray-200 p-1.5 text-gray-700 group-has-[:checked]:bg-emerald-50 group-has-[:checked]:border-emerald-200 group-has-[:checked]:text-emerald-700">
267
+ <i data-feather="check" class="h-4 w-4"></i>
268
+ </div>
269
+ </div>
270
+ <div class="p-2">
271
+ <div class="text-xs font-semibold text-gray-900 truncate">${p.name}</div>
272
+ <div class="text-xs text-gray-500">Click to select for report</div>
273
+ </div>
274
  </div>
275
+ </label>
276
+ `;
277
+ }).join('')
278
+ : `<div class="col-span-full text-sm text-gray-500">No photos were uploaded.</div>`;
 
 
 
 
 
279
 
280
+ const docs = uploads.documents || [];
 
281
  docCount.textContent = `${docs.length} file${docs.length === 1 ? '' : 's'}`;
282
  docList.innerHTML = docs.length
283
  ? docs.map(d => `
 
286
  <i data-feather="file-text" class="h-4 w-4 text-gray-600"></i>
287
  <span class="truncate text-gray-800">${d.name}</span>
288
  </div>
289
+ <span class="text-xs font-semibold text-gray-600">${d.content_type || 'File'}</span>
290
  </li>
291
  `).join('')
292
  : `<li class="text-sm text-gray-500">No supporting documents detected.</li>`;
293
  docHint.style.display = docs.length ? 'none' : 'block';
294
 
295
+ const dataFiles = uploads.data_files || [];
 
296
  dataCount.textContent = `${dataFiles.length} file${dataFiles.length === 1 ? '' : 's'}`;
297
  dataBox.innerHTML = dataFiles.length
298
  ? dataFiles.map(f => `
 
302
  <i data-feather="table" class="h-4 w-4 text-amber-700"></i>
303
  <span class="truncate font-semibold text-gray-900">${f.name}</span>
304
  </div>
305
+ <span class="text-xs font-semibold text-gray-600">${f.content_type || 'Data'}</span>
306
  </div>
307
  <div class="text-xs text-gray-600 mt-1">
308
  Will populate report data areas (tables/fields).
 
316
  </div>
317
  `;
318
 
 
319
  if (window.feather && typeof window.feather.replace === 'function') {
320
  window.feather.replace();
321
  }
322
  }
323
 
324
+ async function loadSession() {
325
+ try {
326
+ const session = await window.REPEX.request(`/sessions/${sessionId}`);
327
+ renderUploads(session.uploads || {}, session.selected_photo_ids || []);
328
+ updateSelectionState();
329
+ } catch (err) {
330
+ readyStatus.textContent = err.message || 'Failed to load session.';
331
+ readyStatus.className = 'font-semibold text-red-600';
 
 
 
332
  }
333
  }
334
 
 
 
 
 
 
335
  photoGrid.addEventListener('change', (e) => {
336
  const cb = e.target.closest('.photoCheck');
337
  if (!cb) return;
 
355
  updateSelectionState();
356
  });
357
 
358
+ continueBtn.addEventListener('click', async () => {
 
359
  if (state.selectedPhotoIds.size === 0) return;
360
+ const selectedIds = Array.from(state.selectedPhotoIds);
361
+ try {
362
+ await window.REPEX.putJson(`/sessions/${sessionId}/selection`, {
363
+ selected_photo_ids: selectedIds,
364
+ });
365
+ window.location.href = `report-viewer.html?session=${encodeURIComponent(sessionId)}`;
366
+ } catch (err) {
367
+ readyStatus.textContent = err.message || 'Failed to save selection.';
368
+ readyStatus.className = 'font-semibold text-red-600';
369
+ }
 
 
 
 
370
  });
371
+
372
+ loadSession();
373
  });
374
  </script>
375
  </body>
script.js CHANGED
@@ -1 +1,107 @@
1
  "use strict";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  "use strict";
2
+
3
+ (function () {
4
+ const SESSION_KEY = "repex_session_id";
5
+ const apiBase =
6
+ window.REPEX_API_BASE ||
7
+ new URL("/api", window.location.origin).toString().replace(/\/$/, "");
8
+
9
+ function getSessionId() {
10
+ const params = new URLSearchParams(window.location.search);
11
+ return params.get("session") || localStorage.getItem(SESSION_KEY) || "";
12
+ }
13
+
14
+ function setSessionId(id) {
15
+ if (!id) return;
16
+ localStorage.setItem(SESSION_KEY, id);
17
+ }
18
+
19
+ function clearSessionId() {
20
+ localStorage.removeItem(SESSION_KEY);
21
+ }
22
+
23
+ function formatBytes(bytes) {
24
+ if (!Number.isFinite(bytes)) return "0 B";
25
+ const units = ["B", "KB", "MB", "GB"];
26
+ let value = bytes;
27
+ let idx = 0;
28
+ while (value >= 1024 && idx < units.length - 1) {
29
+ value /= 1024;
30
+ idx += 1;
31
+ }
32
+ return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
33
+ }
34
+
35
+ async function request(path, options = {}) {
36
+ const url = `${apiBase}${path}`;
37
+ const res = await fetch(url, {
38
+ credentials: "same-origin",
39
+ ...options,
40
+ });
41
+ if (!res.ok) {
42
+ let detail = res.statusText;
43
+ try {
44
+ const data = await res.json();
45
+ detail = data?.detail || detail;
46
+ } catch {}
47
+ throw new Error(detail);
48
+ }
49
+ if (res.status === 204) return null;
50
+ const contentType = res.headers.get("content-type") || "";
51
+ if (contentType.includes("application/json")) {
52
+ return res.json();
53
+ }
54
+ return res.text();
55
+ }
56
+
57
+ async function postForm(path, formData) {
58
+ return request(path, { method: "POST", body: formData });
59
+ }
60
+
61
+ async function postJson(path, body) {
62
+ return request(path, {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify(body),
66
+ });
67
+ }
68
+
69
+ async function putJson(path, body) {
70
+ return request(path, {
71
+ method: "PUT",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify(body),
74
+ });
75
+ }
76
+
77
+ function applySessionToLinks() {
78
+ const sessionId = getSessionId();
79
+ if (!sessionId) return;
80
+ document.querySelectorAll('a[href$=".html"]').forEach((link) => {
81
+ const href = link.getAttribute("href");
82
+ if (!href || href.startsWith("http")) return;
83
+ const url = new URL(href, window.location.origin);
84
+ if (!url.searchParams.get("session")) {
85
+ url.searchParams.set("session", sessionId);
86
+ link.setAttribute("href", `${url.pathname}${url.search}`);
87
+ }
88
+ });
89
+ }
90
+
91
+ window.REPEX = {
92
+ apiBase,
93
+ getSessionId,
94
+ setSessionId,
95
+ clearSessionId,
96
+ request,
97
+ postForm,
98
+ postJson,
99
+ putJson,
100
+ formatBytes,
101
+ applySessionToLinks,
102
+ };
103
+
104
+ document.addEventListener("DOMContentLoaded", () => {
105
+ applySessionToLinks();
106
+ });
107
+ })();
server/app/api/deps.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+
3
+ from ..services import SessionStore
4
+
5
+
6
+ @lru_cache
7
+ def get_session_store() -> SessionStore:
8
+ return SessionStore()
server/app/api/router.py CHANGED
@@ -1,8 +1,10 @@
1
  from fastapi import APIRouter
2
 
3
  from .routes.health import router as health_router
 
4
 
5
  api_router = APIRouter()
6
  api_router.include_router(health_router, tags=["system"])
 
7
 
8
  __all__ = ["api_router"]
 
1
  from fastapi import APIRouter
2
 
3
  from .routes.health import router as health_router
4
+ from .routes.sessions import router as sessions_router
5
 
6
  api_router = APIRouter()
7
  api_router.include_router(health_router, tags=["system"])
8
+ api_router.include_router(sessions_router, prefix="/sessions", tags=["sessions"])
9
 
10
  __all__ = ["api_router"]
server/app/api/routes/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
  from .health import router as health_router
 
2
 
3
- __all__ = ["health_router"]
 
1
  from .health import router as health_router
2
+ from .sessions import router as sessions_router
3
 
4
+ __all__ = ["health_router", "sessions_router"]
server/app/api/routes/sessions.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
8
+ from fastapi.responses import FileResponse
9
+
10
+ from ..deps import get_session_store
11
+ from ..schemas import (
12
+ PagesRequest,
13
+ PagesResponse,
14
+ SelectionRequest,
15
+ SessionResponse,
16
+ SessionStatusResponse,
17
+ )
18
+ from ...services import SessionStore
19
+
20
+
21
+ router = APIRouter()
22
+
23
+
24
+ def _attach_urls(session: dict) -> dict:
25
+ session = dict(session)
26
+ uploads = {}
27
+ for category, items in (session.get("uploads") or {}).items():
28
+ enriched = []
29
+ for item in items:
30
+ enriched.append(
31
+ {
32
+ **item,
33
+ "url": f"/api/sessions/{session['id']}/uploads/{item['id']}",
34
+ }
35
+ )
36
+ uploads[category] = enriched
37
+ session["uploads"] = uploads
38
+ return session
39
+
40
+
41
+ @router.get("", response_model=List[SessionResponse])
42
+ def list_sessions(store: SessionStore = Depends(get_session_store)) -> List[SessionResponse]:
43
+ sessions = store.list_sessions()
44
+ sessions.sort(key=lambda item: item.get("created_at", ""), reverse=True)
45
+ return [_attach_urls(session) for session in sessions]
46
+
47
+
48
+ @router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
49
+ def create_session(
50
+ project_name: str = Form(""),
51
+ inspection_date: str = Form(""),
52
+ notes: str = Form(""),
53
+ files: List[UploadFile] = File(...),
54
+ store: SessionStore = Depends(get_session_store),
55
+ ) -> SessionResponse:
56
+ if not files:
57
+ raise HTTPException(status_code=400, detail="At least one file is required.")
58
+
59
+ session = store.create_session(project_name, inspection_date, notes)
60
+ saved_files = []
61
+ for upload in files:
62
+ try:
63
+ saved_files.append(store.save_upload(session["id"], upload))
64
+ except ValueError as exc:
65
+ raise HTTPException(status_code=413, detail=str(exc)) from exc
66
+ finally:
67
+ try:
68
+ upload.file.close()
69
+ except Exception:
70
+ pass
71
+
72
+ session = store.add_uploads(session, saved_files)
73
+ return _attach_urls(session)
74
+
75
+
76
+ @router.get("/{session_id}", response_model=SessionResponse)
77
+ def get_session(session_id: str, store: SessionStore = Depends(get_session_store)) -> SessionResponse:
78
+ session = store.get_session(session_id)
79
+ if not session:
80
+ raise HTTPException(status_code=404, detail="Session not found.")
81
+ return _attach_urls(session)
82
+
83
+
84
+ @router.get("/{session_id}/status", response_model=SessionStatusResponse)
85
+ def get_session_status(
86
+ session_id: str, store: SessionStore = Depends(get_session_store)
87
+ ) -> SessionStatusResponse:
88
+ session = store.get_session(session_id)
89
+ if not session:
90
+ raise HTTPException(status_code=404, detail="Session not found.")
91
+ return SessionStatusResponse(
92
+ id=session["id"], status=session["status"], updated_at=session["updated_at"]
93
+ )
94
+
95
+
96
+ @router.put("/{session_id}/selection", response_model=SessionResponse)
97
+ def update_selection(
98
+ session_id: str,
99
+ payload: SelectionRequest,
100
+ store: SessionStore = Depends(get_session_store),
101
+ ) -> SessionResponse:
102
+ session = store.get_session(session_id)
103
+ if not session:
104
+ raise HTTPException(status_code=404, detail="Session not found.")
105
+ session = store.set_selected_photos(session, payload.selected_photo_ids)
106
+ return _attach_urls(session)
107
+
108
+
109
+ @router.get("/{session_id}/pages", response_model=PagesResponse)
110
+ def get_pages(session_id: str, store: SessionStore = Depends(get_session_store)) -> PagesResponse:
111
+ session = store.get_session(session_id)
112
+ if not session:
113
+ raise HTTPException(status_code=404, detail="Session not found.")
114
+ pages = store.ensure_pages(session)
115
+ return PagesResponse(pages=pages)
116
+
117
+
118
+ @router.put("/{session_id}/pages", response_model=PagesResponse)
119
+ def save_pages(
120
+ session_id: str,
121
+ payload: PagesRequest,
122
+ store: SessionStore = Depends(get_session_store),
123
+ ) -> PagesResponse:
124
+ session = store.get_session(session_id)
125
+ if not session:
126
+ raise HTTPException(status_code=404, detail="Session not found.")
127
+ session = store.set_pages(session, payload.pages)
128
+ return PagesResponse(pages=session.get("pages") or [])
129
+
130
+
131
+ @router.get("/{session_id}/uploads/{file_id}")
132
+ def get_upload(
133
+ session_id: str,
134
+ file_id: str,
135
+ store: SessionStore = Depends(get_session_store),
136
+ ) -> FileResponse:
137
+ session = store.get_session(session_id)
138
+ if not session:
139
+ raise HTTPException(status_code=404, detail="Session not found.")
140
+ path = store.resolve_upload_path(session, file_id)
141
+ if not path or not path.exists():
142
+ raise HTTPException(status_code=404, detail="File not found.")
143
+ return FileResponse(path)
144
+
145
+
146
+ @router.get("/{session_id}/export")
147
+ def export_package(
148
+ session_id: str, store: SessionStore = Depends(get_session_store)
149
+ ) -> FileResponse:
150
+ session = store.get_session(session_id)
151
+ if not session:
152
+ raise HTTPException(status_code=404, detail="Session not found.")
153
+ export_path = Path(store.session_dir(session_id)) / "export.json"
154
+ payload = {
155
+ "session": session,
156
+ "pages": session.get("pages") or [],
157
+ "exported_at": session.get("updated_at"),
158
+ }
159
+ export_path.write_text(
160
+ json.dumps(payload, indent=2), encoding="utf-8"
161
+ )
162
+ return FileResponse(
163
+ export_path,
164
+ media_type="application/json",
165
+ filename=f"repex_report_{session_id}.json",
166
+ )
server/app/api/schemas.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class FileMeta(BaseModel):
9
+ id: str
10
+ name: str
11
+ size: int
12
+ content_type: str
13
+ category: str
14
+ url: Optional[str] = None
15
+
16
+
17
+ class SessionResponse(BaseModel):
18
+ id: str
19
+ status: str
20
+ created_at: str
21
+ updated_at: str
22
+ project_name: str
23
+ inspection_date: str
24
+ notes: str
25
+ uploads: Dict[str, List[FileMeta]] = Field(default_factory=dict)
26
+ selected_photo_ids: List[str] = Field(default_factory=list)
27
+ page_count: int = 0
28
+
29
+
30
+ class SessionStatusResponse(BaseModel):
31
+ id: str
32
+ status: str
33
+ updated_at: str
34
+
35
+
36
+ class SelectionRequest(BaseModel):
37
+ selected_photo_ids: List[str] = Field(default_factory=list)
38
+
39
+
40
+ class PagesResponse(BaseModel):
41
+ pages: List[dict] = Field(default_factory=list)
42
+
43
+
44
+ class PagesRequest(BaseModel):
45
+ pages: List[dict] = Field(default_factory=list)
server/app/core/config.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import os
4
  from dataclasses import dataclass
5
  from functools import lru_cache
 
6
  from typing import List
7
 
8
 
@@ -17,6 +18,8 @@ class Settings:
17
  app_name: str
18
  api_prefix: str
19
  cors_origins: List[str]
 
 
20
 
21
 
22
  @lru_cache
@@ -27,4 +30,6 @@ def get_settings() -> Settings:
27
  cors_origins=_split_csv(
28
  os.getenv("CORS_ORIGINS"), fallback=["http://localhost:5173"]
29
  ),
 
 
30
  )
 
3
  import os
4
  from dataclasses import dataclass
5
  from functools import lru_cache
6
+ from pathlib import Path
7
  from typing import List
8
 
9
 
 
18
  app_name: str
19
  api_prefix: str
20
  cors_origins: List[str]
21
+ storage_dir: Path
22
+ max_upload_mb: int
23
 
24
 
25
  @lru_cache
 
30
  cors_origins=_split_csv(
31
  os.getenv("CORS_ORIGINS"), fallback=["http://localhost:5173"]
32
  ),
33
+ storage_dir=Path(os.getenv("STORAGE_DIR", "data")).resolve(),
34
+ max_upload_mb=int(os.getenv("MAX_UPLOAD_MB", "50")),
35
  )
server/app/main.py CHANGED
@@ -1,5 +1,8 @@
 
 
1
  from fastapi import FastAPI
2
  from fastapi.middleware.cors import CORSMiddleware
 
3
 
4
  from .api.router import api_router
5
  from .core.config import get_settings
@@ -17,6 +20,9 @@ def create_app() -> FastAPI:
17
  allow_headers=["*"],
18
  )
19
  app.include_router(api_router, prefix=settings.api_prefix)
 
 
 
20
  return app
21
 
22
 
 
1
+ from pathlib import Path
2
+
3
  from fastapi import FastAPI
4
  from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.staticfiles import StaticFiles
6
 
7
  from .api.router import api_router
8
  from .core.config import get_settings
 
20
  allow_headers=["*"],
21
  )
22
  app.include_router(api_router, prefix=settings.api_prefix)
23
+
24
+ static_root = Path(__file__).resolve().parents[2]
25
+ app.mount("/", StaticFiles(directory=static_root, html=True), name="static")
26
  return app
27
 
28
 
server/app/services/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .session_store import SessionStore, StoredFile
2
+
3
+ __all__ = ["SessionStore", "StoredFile"]
server/app/services/session_store.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from threading import Lock
9
+ from typing import Iterable, List, Optional
10
+ from uuid import uuid4
11
+
12
+ from fastapi import UploadFile
13
+
14
+ from ..core.config import get_settings
15
+
16
+
17
+ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
18
+ DOC_EXTS = {".pdf", ".doc", ".docx"}
19
+ DATA_EXTS = {".csv", ".xls", ".xlsx"}
20
+
21
+
22
+ @dataclass
23
+ class StoredFile:
24
+ id: str
25
+ name: str
26
+ size: int
27
+ content_type: str
28
+ category: str
29
+ path: str
30
+
31
+
32
+ def _now_iso() -> str:
33
+ return datetime.now(timezone.utc).isoformat()
34
+
35
+
36
+ def _safe_name(name: str) -> str:
37
+ name = Path(name).name
38
+ name = re.sub(r"[^a-zA-Z0-9._-]", "_", name)
39
+ return name or "upload"
40
+
41
+
42
+ def _category_for(filename: str) -> str:
43
+ ext = Path(filename).suffix.lower()
44
+ if ext in IMAGE_EXTS:
45
+ return "photos"
46
+ if ext in DOC_EXTS:
47
+ return "documents"
48
+ if ext in DATA_EXTS:
49
+ return "data_files"
50
+ return "documents"
51
+
52
+
53
+ class SessionStore:
54
+ def __init__(self, base_dir: Optional[Path] = None) -> None:
55
+ settings = get_settings()
56
+ self.base_dir = (base_dir or settings.storage_dir).resolve()
57
+ self.sessions_dir = self.base_dir / "sessions"
58
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
59
+ self.max_upload_bytes = settings.max_upload_mb * 1024 * 1024
60
+ self._lock = Lock()
61
+
62
+ def list_sessions(self) -> List[dict]:
63
+ sessions: List[dict] = []
64
+ for session_file in sorted(self.sessions_dir.glob("*/session.json"), reverse=True):
65
+ try:
66
+ sessions.append(json.loads(session_file.read_text(encoding="utf-8")))
67
+ except Exception:
68
+ continue
69
+ return sessions
70
+
71
+ def create_session(self, project_name: str, inspection_date: str, notes: str) -> dict:
72
+ session_id = uuid4().hex
73
+ now = _now_iso()
74
+ session = {
75
+ "id": session_id,
76
+ "status": "ready",
77
+ "created_at": now,
78
+ "updated_at": now,
79
+ "project_name": project_name,
80
+ "inspection_date": inspection_date,
81
+ "notes": notes,
82
+ "uploads": {"photos": [], "documents": [], "data_files": []},
83
+ "selected_photo_ids": [],
84
+ "page_count": 6,
85
+ "pages": [],
86
+ }
87
+ self._save_session(session)
88
+ return session
89
+
90
+ def get_session(self, session_id: str) -> Optional[dict]:
91
+ session_path = self._session_file(session_id)
92
+ if not session_path.exists():
93
+ return None
94
+ try:
95
+ return json.loads(session_path.read_text(encoding="utf-8"))
96
+ except Exception:
97
+ return None
98
+
99
+ def update_session(self, session: dict) -> None:
100
+ session["updated_at"] = _now_iso()
101
+ self._save_session(session)
102
+
103
+ def add_uploads(self, session: dict, uploads: Iterable[StoredFile]) -> dict:
104
+ for item in uploads:
105
+ session["uploads"].setdefault(item.category, [])
106
+ session["uploads"][item.category].append(
107
+ {
108
+ "id": item.id,
109
+ "name": item.name,
110
+ "size": item.size,
111
+ "content_type": item.content_type,
112
+ "category": item.category,
113
+ "path": item.path,
114
+ }
115
+ )
116
+ self.update_session(session)
117
+ return session
118
+
119
+ def set_selected_photos(self, session: dict, selected_ids: List[str]) -> dict:
120
+ session["selected_photo_ids"] = selected_ids
121
+ self.update_session(session)
122
+ return session
123
+
124
+ def set_pages(self, session: dict, pages: List[dict]) -> dict:
125
+ session["pages"] = pages
126
+ session["page_count"] = max(len(pages), session.get("page_count", 0))
127
+ self.update_session(session)
128
+ return session
129
+
130
+ def ensure_pages(self, session: dict) -> List[dict]:
131
+ pages = session.get("pages") or []
132
+ if pages:
133
+ return pages
134
+ count = session.get("page_count", 6) or 6
135
+ pages = [{"items": []} for _ in range(count)]
136
+ session["pages"] = pages
137
+ self.update_session(session)
138
+ return pages
139
+
140
+ def save_upload(self, session_id: str, upload: UploadFile) -> StoredFile:
141
+ filename = _safe_name(upload.filename or "upload")
142
+ ext = Path(filename).suffix
143
+ file_id = uuid4().hex
144
+ stored_name = f"{file_id}{ext}"
145
+ session_dir = self._session_dir(session_id)
146
+ uploads_dir = session_dir / "uploads"
147
+ uploads_dir.mkdir(parents=True, exist_ok=True)
148
+ dest = uploads_dir / stored_name
149
+
150
+ size = 0
151
+ with dest.open("wb") as handle:
152
+ while True:
153
+ chunk = upload.file.read(1024 * 1024)
154
+ if not chunk:
155
+ break
156
+ size += len(chunk)
157
+ if size > self.max_upload_bytes:
158
+ handle.close()
159
+ dest.unlink(missing_ok=True)
160
+ raise ValueError("File exceeds maximum upload size.")
161
+ handle.write(chunk)
162
+
163
+ category = _category_for(filename)
164
+ return StoredFile(
165
+ id=file_id,
166
+ name=filename,
167
+ size=size,
168
+ content_type=upload.content_type or "application/octet-stream",
169
+ category=category,
170
+ path=f"uploads/{stored_name}",
171
+ )
172
+
173
+ def _session_dir(self, session_id: str) -> Path:
174
+ return self.sessions_dir / session_id
175
+
176
+ def session_dir(self, session_id: str) -> Path:
177
+ return self._session_dir(session_id)
178
+
179
+ def _session_file(self, session_id: str) -> Path:
180
+ return self._session_dir(session_id) / "session.json"
181
+
182
+ def _save_session(self, session: dict) -> None:
183
+ session_dir = self._session_dir(session["id"])
184
+ session_dir.mkdir(parents=True, exist_ok=True)
185
+ session_path = self._session_file(session["id"])
186
+ with self._lock:
187
+ session_path.write_text(json.dumps(session, indent=2), encoding="utf-8")
188
+
189
+ def resolve_upload_path(self, session: dict, file_id: str) -> Optional[Path]:
190
+ uploads = session.get("uploads") or {}
191
+ for items in uploads.values():
192
+ for item in items:
193
+ if item.get("id") == file_id:
194
+ relative = item.get("path")
195
+ if relative:
196
+ return self._session_dir(session["id"]) / relative
197
+ return None
server/requirements.txt CHANGED
@@ -1,2 +1,3 @@
1
  fastapi>=0.115.0,<1.0.0
2
  uvicorn[standard]>=0.30.0,<0.32.0
 
 
1
  fastapi>=0.115.0,<1.0.0
2
  uvicorn[standard]>=0.30.0,<0.32.0
3
+ python-multipart>=0.0.9,<0.1.0