Spaces:
Sleeping
Sleeping
Commit ·
51c39cf
1
Parent(s): 4297738
HTML Static Setup Web
Browse files- .gitignore +3 -0
- README.md +5 -8
- components/report-editor.js +74 -2
- edit-layouts.html +36 -36
- export.html +36 -37
- index.html +138 -37
- processing.html +24 -0
- report-viewer.html +59 -67
- review-setup.html +76 -85
- script.js +106 -0
- server/app/api/deps.py +8 -0
- server/app/api/router.py +2 -0
- server/app/api/routes/__init__.py +2 -1
- server/app/api/routes/sessions.py +166 -0
- server/app/api/schemas.py +45 -0
- server/app/core/config.py +5 -0
- server/app/main.py +6 -0
- server/app/services/__init__.py +3 -0
- server/app/services/session_store.py +197 -0
- server/requirements.txt +1 -0
.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
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 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
|
| 200 |
-
|
| 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 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
}
|
| 222 |
|
| 223 |
-
|
| 224 |
-
|
| 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 =
|
| 255 |
-
if (incLayout.checked) pack.layout = layout ?? null;
|
| 256 |
-
if (incPayload.checked) pack.payload =
|
| 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 |
-
|
| 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
|
| 200 |
-
|
| 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 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
}
|
| 222 |
|
| 223 |
-
|
| 224 |
-
|
| 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 =
|
| 255 |
-
if (incLayout.checked) pack.layout = layout ?? null;
|
| 256 |
-
if (incPayload.checked) pack.payload =
|
| 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 |
-
<
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 238 |
-
|
| 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 |
-
<
|
| 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 |
-
</
|
| 95 |
|
| 96 |
<button
|
| 97 |
type="button"
|
|
@@ -102,21 +101,21 @@ src="assets/prosento-logo.png"
|
|
| 102 |
Edit Report
|
| 103 |
</button>
|
| 104 |
|
| 105 |
-
<
|
| 106 |
-
|
| 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 |
-
</
|
| 112 |
|
| 113 |
-
<
|
| 114 |
-
|
| 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 |
-
</
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 229 |
};
|
| 230 |
|
| 231 |
function setMeta() {
|
| 232 |
-
const selected =
|
| 233 |
-
const docs =
|
| 234 |
-
const dataFiles =
|
| 235 |
-
|
| 236 |
-
const hasEdits = !!state.pages;
|
| 237 |
viewerMeta.textContent =
|
| 238 |
-
`Selected example photos: ${selected}
|
| 239 |
-
(hasEdits ?
|
| 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 =
|
| 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
|
| 329 |
-
|
| 330 |
-
state.
|
| 331 |
-
state.totalPages =
|
|
|
|
| 332 |
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 387 |
renderPage();
|
| 388 |
}
|
| 389 |
|
| 390 |
-
// Events
|
| 391 |
prevPageBtn.addEventListener('click', prevPage);
|
| 392 |
nextPageBtn.addEventListener('click', nextPage);
|
| 393 |
|
| 394 |
-
|
| 395 |
if (!state.editMode) openEditor();
|
| 396 |
});
|
| 397 |
|
| 398 |
-
editor.addEventListener('editor-closed',
|
|
|
|
|
|
|
| 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 |
-
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 250 |
-
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
photoCount.textContent = `${photos.length} file${photos.length === 1 ? '' : 's'}`;
|
| 253 |
|
| 254 |
-
photoGrid.innerHTML = photos.
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
<
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
</div>
|
| 265 |
-
</
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
</div>
|
| 270 |
-
</div>
|
| 271 |
-
</label>
|
| 272 |
-
`;
|
| 273 |
-
}).join('');
|
| 274 |
|
| 275 |
-
|
| 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.
|
| 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 |
-
|
| 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.
|
| 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
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
readyStatus.textContent =
|
| 329 |
-
readyStatus.className = 'font-semibold text-
|
| 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 |
-
|
| 365 |
-
continueBtn.addEventListener('click', () => {
|
| 366 |
if (state.selectedPhotoIds.size === 0) return;
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 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
|