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

Initial React Transcription

Browse files
README.md CHANGED
@@ -1,41 +1,38 @@
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
 
7
  ```
8
  /
9
- index.html
10
- processing.html
11
- review-setup.html
12
- report-viewer.html
13
- edit-layouts.html
14
- export.html
15
- style.css
16
- script.js
17
- components/
18
- report-editor.js
19
- templates/
20
- job-sheet-template.html
21
- assets/
22
- prosento-logo.png
23
- client-logo.png
24
  server/
25
  app/
26
  api/
27
  routes/
28
  health.py
 
29
  router.py
30
  core/
31
  config.py
 
 
32
  main.py
33
- frontend/ (optional Vite starter)
 
 
 
 
 
 
 
 
 
34
  ```
35
 
36
- ## Quick Start
37
 
38
- ### Server (API + static pages)
39
  ```powershell
40
  python -m venv .venv
41
  .venv\Scripts\activate
@@ -43,7 +40,26 @@ pip install -r server/requirements.txt
43
  uvicorn server.app.main:app --reload --port 8000
44
  ```
45
 
46
- Open `http://localhost:8000/index.html`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
  ## Configuration
49
 
@@ -53,3 +69,6 @@ Environment variables for the API:
53
  - `CORS_ORIGINS` (comma-separated, default: `http://localhost:5173`)
54
  - `STORAGE_DIR` (default: `data`)
55
  - `MAX_UPLOAD_MB` (default: `50`)
 
 
 
 
1
  # RepEx Web Starter
2
 
3
+ React (Vite) frontend + FastAPI backend with local session storage.
4
 
5
  ## Project Layout
6
 
7
  ```
8
  /
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  server/
10
  app/
11
  api/
12
  routes/
13
  health.py
14
+ sessions.py
15
  router.py
16
  core/
17
  config.py
18
+ services/
19
+ session_store.py
20
  main.py
21
+ frontend/
22
+ public/
23
+ assets/
24
+ templates/
25
+ src/
26
+ components/
27
+ pages/
28
+ lib/
29
+ index.html
30
+ vite.config.ts
31
  ```
32
 
33
+ ## Quick Start (Dev)
34
 
35
+ ### Backend (API)
36
  ```powershell
37
  python -m venv .venv
38
  .venv\Scripts\activate
 
40
  uvicorn server.app.main:app --reload --port 8000
41
  ```
42
 
43
+ ### Frontend (Vite)
44
+ ```powershell
45
+ cd frontend
46
+ npm install
47
+ npm run dev
48
+ ```
49
+
50
+ Open `http://localhost:5173`.
51
+
52
+ ## Production
53
+
54
+ ```powershell
55
+ cd frontend
56
+ npm run build
57
+ ```
58
+
59
+ Start the API server; it will serve `frontend/dist` if present:
60
+ ```
61
+ uvicorn server.app.main:app --host 0.0.0.0 --port 8000
62
+ ```
63
 
64
  ## Configuration
65
 
 
69
  - `CORS_ORIGINS` (comma-separated, default: `http://localhost:5173`)
70
  - `STORAGE_DIR` (default: `data`)
71
  - `MAX_UPLOAD_MB` (default: `50`)
72
+
73
+ Frontend environment variables:
74
+ - `VITE_API_BASE` (optional, default: `/api`)
frontend/index.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="UTF-8" />
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>Starter</title>
8
  </head>
9
  <body>
10
  <div id="root"></div>
 
4
  <meta charset="UTF-8" />
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>RepEx</title>
8
  </head>
9
  <body>
10
  <div id="root"></div>
frontend/package-lock.json CHANGED
@@ -9,16 +9,34 @@
9
  "version": "0.1.0",
10
  "dependencies": {
11
  "react": "^18.3.1",
12
- "react-dom": "^18.3.1"
 
 
13
  },
14
  "devDependencies": {
15
  "@types/react": "^18.3.3",
16
  "@types/react-dom": "^18.3.3",
17
  "@vitejs/plugin-react": "^4.3.1",
 
 
 
18
  "typescript": "^5.4.5",
19
  "vite": "^7.3.1"
20
  }
21
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  "node_modules/@babel/code-frame": {
23
  "version": "7.27.1",
24
  "dev": true,
@@ -745,6 +763,53 @@
745
  "@jridgewell/sourcemap-codec": "^1.4.14"
746
  }
747
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  "node_modules/@rolldown/pluginutils": {
749
  "version": "1.0.0-beta.27",
750
  "dev": true,
@@ -857,16 +922,124 @@
857
  "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
858
  }
859
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
860
  "node_modules/baseline-browser-mapping": {
861
- "version": "2.8.16",
 
 
862
  "dev": true,
863
  "license": "Apache-2.0",
864
  "bin": {
865
  "baseline-browser-mapping": "dist/cli.js"
866
  }
867
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
868
  "node_modules/browserslist": {
869
- "version": "4.26.3",
 
 
870
  "dev": true,
871
  "funding": [
872
  {
@@ -884,11 +1057,11 @@
884
  ],
885
  "license": "MIT",
886
  "dependencies": {
887
- "baseline-browser-mapping": "^2.8.9",
888
- "caniuse-lite": "^1.0.30001746",
889
- "electron-to-chromium": "^1.5.227",
890
- "node-releases": "^2.0.21",
891
- "update-browserslist-db": "^1.1.3"
892
  },
893
  "bin": {
894
  "browserslist": "cli.js"
@@ -897,8 +1070,20 @@
897
  "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
898
  }
899
  },
 
 
 
 
 
 
 
 
 
 
900
  "node_modules/caniuse-lite": {
901
- "version": "1.0.30001750",
 
 
902
  "dev": true,
903
  "funding": [
904
  {
@@ -916,11 +1101,72 @@
916
  ],
917
  "license": "CC-BY-4.0"
918
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
919
  "node_modules/convert-source-map": {
920
  "version": "2.0.0",
921
  "dev": true,
922
  "license": "MIT"
923
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
924
  "node_modules/csstype": {
925
  "version": "3.1.3",
926
  "dev": true,
@@ -942,8 +1188,24 @@
942
  }
943
  }
944
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
945
  "node_modules/electron-to-chromium": {
946
- "version": "1.5.237",
 
 
947
  "dev": true,
948
  "license": "ISC"
949
  },
@@ -991,12 +1253,54 @@
991
  },
992
  "node_modules/escalade": {
993
  "version": "3.2.0",
 
 
994
  "dev": true,
995
  "license": "MIT",
996
  "engines": {
997
  "node": ">=6"
998
  }
999
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1000
  "node_modules/fdir": {
1001
  "version": "6.5.0",
1002
  "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1015,6 +1319,33 @@
1015
  }
1016
  }
1017
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1018
  "node_modules/fsevents": {
1019
  "version": "2.3.3",
1020
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1030,6 +1361,16 @@
1030
  "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1031
  }
1032
  },
 
 
 
 
 
 
 
 
 
 
1033
  "node_modules/gensync": {
1034
  "version": "1.0.0-beta.2",
1035
  "dev": true,
@@ -1038,6 +1379,104 @@
1038
  "node": ">=6.9.0"
1039
  }
1040
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1041
  "node_modules/js-tokens": {
1042
  "version": "4.0.0",
1043
  "license": "MIT"
@@ -1064,6 +1503,26 @@
1064
  "node": ">=6"
1065
  }
1066
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1067
  "node_modules/loose-envify": {
1068
  "version": "1.4.0",
1069
  "license": "MIT",
@@ -1082,30 +1541,117 @@
1082
  "yallist": "^3.0.2"
1083
  }
1084
  },
1085
- "node_modules/ms": {
1086
- "version": "2.1.3",
 
 
1087
  "dev": true,
1088
- "license": "MIT"
 
 
 
1089
  },
1090
- "node_modules/nanoid": {
1091
- "version": "3.3.11",
 
 
1092
  "dev": true,
1093
- "funding": [
1094
- {
1095
- "type": "github",
1096
- "url": "https://github.com/sponsors/ai"
1097
- }
1098
- ],
1099
  "license": "MIT",
1100
- "bin": {
1101
- "nanoid": "bin/nanoid.cjs"
 
1102
  },
1103
  "engines": {
1104
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1105
  }
1106
  },
1107
  "node_modules/node-releases": {
1108
- "version": "2.0.23",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1109
  "dev": true,
1110
  "license": "MIT"
1111
  },
@@ -1127,6 +1673,26 @@
1127
  "url": "https://github.com/sponsors/jonschlinkert"
1128
  }
1129
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1130
  "node_modules/postcss": {
1131
  "version": "8.5.6",
1132
  "dev": true,
@@ -1154,6 +1720,172 @@
1154
  "node": "^10 || ^12 || >=14"
1155
  }
1156
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1157
  "node_modules/react": {
1158
  "version": "18.3.1",
1159
  "license": "MIT",
@@ -1175,6 +1907,24 @@
1175
  "react": "^18.3.1"
1176
  }
1177
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1178
  "node_modules/react-refresh": {
1179
  "version": "0.17.0",
1180
  "dev": true,
@@ -1183,6 +1933,106 @@
1183
  "node": ">=0.10.0"
1184
  }
1185
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1186
  "node_modules/rollup": {
1187
  "version": "4.52.4",
1188
  "dev": true,
@@ -1223,6 +2073,30 @@
1223
  "fsevents": "~2.3.2"
1224
  }
1225
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1226
  "node_modules/scheduler": {
1227
  "version": "0.23.2",
1228
  "license": "MIT",
@@ -1246,6 +2120,103 @@
1246
  "node": ">=0.10.0"
1247
  }
1248
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1249
  "node_modules/tinyglobby": {
1250
  "version": "0.2.15",
1251
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -1263,6 +2234,26 @@
1263
  "url": "https://github.com/sponsors/SuperchupuDev"
1264
  }
1265
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1266
  "node_modules/typescript": {
1267
  "version": "5.9.3",
1268
  "dev": true,
@@ -1276,7 +2267,9 @@
1276
  }
1277
  },
1278
  "node_modules/update-browserslist-db": {
1279
- "version": "1.1.3",
 
 
1280
  "dev": true,
1281
  "funding": [
1282
  {
@@ -1304,6 +2297,13 @@
1304
  "browserslist": ">= 4.21.0"
1305
  }
1306
  },
 
 
 
 
 
 
 
1307
  "node_modules/vite": {
1308
  "version": "7.3.1",
1309
  "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
 
9
  "version": "0.1.0",
10
  "dependencies": {
11
  "react": "^18.3.1",
12
+ "react-dom": "^18.3.1",
13
+ "react-feather": "^2.0.10",
14
+ "react-router-dom": "^6.26.2"
15
  },
16
  "devDependencies": {
17
  "@types/react": "^18.3.3",
18
  "@types/react-dom": "^18.3.3",
19
  "@vitejs/plugin-react": "^4.3.1",
20
+ "autoprefixer": "^10.4.20",
21
+ "postcss": "^8.4.41",
22
+ "tailwindcss": "^3.4.10",
23
  "typescript": "^5.4.5",
24
  "vite": "^7.3.1"
25
  }
26
  },
27
+ "node_modules/@alloc/quick-lru": {
28
+ "version": "5.2.0",
29
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
30
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
31
+ "dev": true,
32
+ "license": "MIT",
33
+ "engines": {
34
+ "node": ">=10"
35
+ },
36
+ "funding": {
37
+ "url": "https://github.com/sponsors/sindresorhus"
38
+ }
39
+ },
40
  "node_modules/@babel/code-frame": {
41
  "version": "7.27.1",
42
  "dev": true,
 
763
  "@jridgewell/sourcemap-codec": "^1.4.14"
764
  }
765
  },
766
+ "node_modules/@nodelib/fs.scandir": {
767
+ "version": "2.1.5",
768
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
769
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
770
+ "dev": true,
771
+ "license": "MIT",
772
+ "dependencies": {
773
+ "@nodelib/fs.stat": "2.0.5",
774
+ "run-parallel": "^1.1.9"
775
+ },
776
+ "engines": {
777
+ "node": ">= 8"
778
+ }
779
+ },
780
+ "node_modules/@nodelib/fs.stat": {
781
+ "version": "2.0.5",
782
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
783
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
784
+ "dev": true,
785
+ "license": "MIT",
786
+ "engines": {
787
+ "node": ">= 8"
788
+ }
789
+ },
790
+ "node_modules/@nodelib/fs.walk": {
791
+ "version": "1.2.8",
792
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
793
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
794
+ "dev": true,
795
+ "license": "MIT",
796
+ "dependencies": {
797
+ "@nodelib/fs.scandir": "2.1.5",
798
+ "fastq": "^1.6.0"
799
+ },
800
+ "engines": {
801
+ "node": ">= 8"
802
+ }
803
+ },
804
+ "node_modules/@remix-run/router": {
805
+ "version": "1.23.2",
806
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
807
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
808
+ "license": "MIT",
809
+ "engines": {
810
+ "node": ">=14.0.0"
811
+ }
812
+ },
813
  "node_modules/@rolldown/pluginutils": {
814
  "version": "1.0.0-beta.27",
815
  "dev": true,
 
922
  "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
923
  }
924
  },
925
+ "node_modules/any-promise": {
926
+ "version": "1.3.0",
927
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
928
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
929
+ "dev": true,
930
+ "license": "MIT"
931
+ },
932
+ "node_modules/anymatch": {
933
+ "version": "3.1.3",
934
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
935
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
936
+ "dev": true,
937
+ "license": "ISC",
938
+ "dependencies": {
939
+ "normalize-path": "^3.0.0",
940
+ "picomatch": "^2.0.4"
941
+ },
942
+ "engines": {
943
+ "node": ">= 8"
944
+ }
945
+ },
946
+ "node_modules/anymatch/node_modules/picomatch": {
947
+ "version": "2.3.1",
948
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
949
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
950
+ "dev": true,
951
+ "license": "MIT",
952
+ "engines": {
953
+ "node": ">=8.6"
954
+ },
955
+ "funding": {
956
+ "url": "https://github.com/sponsors/jonschlinkert"
957
+ }
958
+ },
959
+ "node_modules/arg": {
960
+ "version": "5.0.2",
961
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
962
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
963
+ "dev": true,
964
+ "license": "MIT"
965
+ },
966
+ "node_modules/autoprefixer": {
967
+ "version": "10.4.24",
968
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
969
+ "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
970
+ "dev": true,
971
+ "funding": [
972
+ {
973
+ "type": "opencollective",
974
+ "url": "https://opencollective.com/postcss/"
975
+ },
976
+ {
977
+ "type": "tidelift",
978
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
979
+ },
980
+ {
981
+ "type": "github",
982
+ "url": "https://github.com/sponsors/ai"
983
+ }
984
+ ],
985
+ "license": "MIT",
986
+ "dependencies": {
987
+ "browserslist": "^4.28.1",
988
+ "caniuse-lite": "^1.0.30001766",
989
+ "fraction.js": "^5.3.4",
990
+ "picocolors": "^1.1.1",
991
+ "postcss-value-parser": "^4.2.0"
992
+ },
993
+ "bin": {
994
+ "autoprefixer": "bin/autoprefixer"
995
+ },
996
+ "engines": {
997
+ "node": "^10 || ^12 || >=14"
998
+ },
999
+ "peerDependencies": {
1000
+ "postcss": "^8.1.0"
1001
+ }
1002
+ },
1003
  "node_modules/baseline-browser-mapping": {
1004
+ "version": "2.9.19",
1005
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
1006
+ "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
1007
  "dev": true,
1008
  "license": "Apache-2.0",
1009
  "bin": {
1010
  "baseline-browser-mapping": "dist/cli.js"
1011
  }
1012
  },
1013
+ "node_modules/binary-extensions": {
1014
+ "version": "2.3.0",
1015
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
1016
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
1017
+ "dev": true,
1018
+ "license": "MIT",
1019
+ "engines": {
1020
+ "node": ">=8"
1021
+ },
1022
+ "funding": {
1023
+ "url": "https://github.com/sponsors/sindresorhus"
1024
+ }
1025
+ },
1026
+ "node_modules/braces": {
1027
+ "version": "3.0.3",
1028
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
1029
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
1030
+ "dev": true,
1031
+ "license": "MIT",
1032
+ "dependencies": {
1033
+ "fill-range": "^7.1.1"
1034
+ },
1035
+ "engines": {
1036
+ "node": ">=8"
1037
+ }
1038
+ },
1039
  "node_modules/browserslist": {
1040
+ "version": "4.28.1",
1041
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
1042
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
1043
  "dev": true,
1044
  "funding": [
1045
  {
 
1057
  ],
1058
  "license": "MIT",
1059
  "dependencies": {
1060
+ "baseline-browser-mapping": "^2.9.0",
1061
+ "caniuse-lite": "^1.0.30001759",
1062
+ "electron-to-chromium": "^1.5.263",
1063
+ "node-releases": "^2.0.27",
1064
+ "update-browserslist-db": "^1.2.0"
1065
  },
1066
  "bin": {
1067
  "browserslist": "cli.js"
 
1070
  "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1071
  }
1072
  },
1073
+ "node_modules/camelcase-css": {
1074
+ "version": "2.0.1",
1075
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
1076
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
1077
+ "dev": true,
1078
+ "license": "MIT",
1079
+ "engines": {
1080
+ "node": ">= 6"
1081
+ }
1082
+ },
1083
  "node_modules/caniuse-lite": {
1084
+ "version": "1.0.30001767",
1085
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz",
1086
+ "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==",
1087
  "dev": true,
1088
  "funding": [
1089
  {
 
1101
  ],
1102
  "license": "CC-BY-4.0"
1103
  },
1104
+ "node_modules/chokidar": {
1105
+ "version": "3.6.0",
1106
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
1107
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
1108
+ "dev": true,
1109
+ "license": "MIT",
1110
+ "dependencies": {
1111
+ "anymatch": "~3.1.2",
1112
+ "braces": "~3.0.2",
1113
+ "glob-parent": "~5.1.2",
1114
+ "is-binary-path": "~2.1.0",
1115
+ "is-glob": "~4.0.1",
1116
+ "normalize-path": "~3.0.0",
1117
+ "readdirp": "~3.6.0"
1118
+ },
1119
+ "engines": {
1120
+ "node": ">= 8.10.0"
1121
+ },
1122
+ "funding": {
1123
+ "url": "https://paulmillr.com/funding/"
1124
+ },
1125
+ "optionalDependencies": {
1126
+ "fsevents": "~2.3.2"
1127
+ }
1128
+ },
1129
+ "node_modules/chokidar/node_modules/glob-parent": {
1130
+ "version": "5.1.2",
1131
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
1132
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
1133
+ "dev": true,
1134
+ "license": "ISC",
1135
+ "dependencies": {
1136
+ "is-glob": "^4.0.1"
1137
+ },
1138
+ "engines": {
1139
+ "node": ">= 6"
1140
+ }
1141
+ },
1142
+ "node_modules/commander": {
1143
+ "version": "4.1.1",
1144
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
1145
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
1146
+ "dev": true,
1147
+ "license": "MIT",
1148
+ "engines": {
1149
+ "node": ">= 6"
1150
+ }
1151
+ },
1152
  "node_modules/convert-source-map": {
1153
  "version": "2.0.0",
1154
  "dev": true,
1155
  "license": "MIT"
1156
  },
1157
+ "node_modules/cssesc": {
1158
+ "version": "3.0.0",
1159
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
1160
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
1161
+ "dev": true,
1162
+ "license": "MIT",
1163
+ "bin": {
1164
+ "cssesc": "bin/cssesc"
1165
+ },
1166
+ "engines": {
1167
+ "node": ">=4"
1168
+ }
1169
+ },
1170
  "node_modules/csstype": {
1171
  "version": "3.1.3",
1172
  "dev": true,
 
1188
  }
1189
  }
1190
  },
1191
+ "node_modules/didyoumean": {
1192
+ "version": "1.2.2",
1193
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
1194
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
1195
+ "dev": true,
1196
+ "license": "Apache-2.0"
1197
+ },
1198
+ "node_modules/dlv": {
1199
+ "version": "1.1.3",
1200
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
1201
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
1202
+ "dev": true,
1203
+ "license": "MIT"
1204
+ },
1205
  "node_modules/electron-to-chromium": {
1206
+ "version": "1.5.283",
1207
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz",
1208
+ "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==",
1209
  "dev": true,
1210
  "license": "ISC"
1211
  },
 
1253
  },
1254
  "node_modules/escalade": {
1255
  "version": "3.2.0",
1256
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1257
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1258
  "dev": true,
1259
  "license": "MIT",
1260
  "engines": {
1261
  "node": ">=6"
1262
  }
1263
  },
1264
+ "node_modules/fast-glob": {
1265
+ "version": "3.3.3",
1266
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
1267
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
1268
+ "dev": true,
1269
+ "license": "MIT",
1270
+ "dependencies": {
1271
+ "@nodelib/fs.stat": "^2.0.2",
1272
+ "@nodelib/fs.walk": "^1.2.3",
1273
+ "glob-parent": "^5.1.2",
1274
+ "merge2": "^1.3.0",
1275
+ "micromatch": "^4.0.8"
1276
+ },
1277
+ "engines": {
1278
+ "node": ">=8.6.0"
1279
+ }
1280
+ },
1281
+ "node_modules/fast-glob/node_modules/glob-parent": {
1282
+ "version": "5.1.2",
1283
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
1284
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
1285
+ "dev": true,
1286
+ "license": "ISC",
1287
+ "dependencies": {
1288
+ "is-glob": "^4.0.1"
1289
+ },
1290
+ "engines": {
1291
+ "node": ">= 6"
1292
+ }
1293
+ },
1294
+ "node_modules/fastq": {
1295
+ "version": "1.20.1",
1296
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
1297
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
1298
+ "dev": true,
1299
+ "license": "ISC",
1300
+ "dependencies": {
1301
+ "reusify": "^1.0.4"
1302
+ }
1303
+ },
1304
  "node_modules/fdir": {
1305
  "version": "6.5.0",
1306
  "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
 
1319
  }
1320
  }
1321
  },
1322
+ "node_modules/fill-range": {
1323
+ "version": "7.1.1",
1324
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
1325
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
1326
+ "dev": true,
1327
+ "license": "MIT",
1328
+ "dependencies": {
1329
+ "to-regex-range": "^5.0.1"
1330
+ },
1331
+ "engines": {
1332
+ "node": ">=8"
1333
+ }
1334
+ },
1335
+ "node_modules/fraction.js": {
1336
+ "version": "5.3.4",
1337
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
1338
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
1339
+ "dev": true,
1340
+ "license": "MIT",
1341
+ "engines": {
1342
+ "node": "*"
1343
+ },
1344
+ "funding": {
1345
+ "type": "github",
1346
+ "url": "https://github.com/sponsors/rawify"
1347
+ }
1348
+ },
1349
  "node_modules/fsevents": {
1350
  "version": "2.3.3",
1351
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
 
1361
  "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1362
  }
1363
  },
1364
+ "node_modules/function-bind": {
1365
+ "version": "1.1.2",
1366
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
1367
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
1368
+ "dev": true,
1369
+ "license": "MIT",
1370
+ "funding": {
1371
+ "url": "https://github.com/sponsors/ljharb"
1372
+ }
1373
+ },
1374
  "node_modules/gensync": {
1375
  "version": "1.0.0-beta.2",
1376
  "dev": true,
 
1379
  "node": ">=6.9.0"
1380
  }
1381
  },
1382
+ "node_modules/glob-parent": {
1383
+ "version": "6.0.2",
1384
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
1385
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
1386
+ "dev": true,
1387
+ "license": "ISC",
1388
+ "dependencies": {
1389
+ "is-glob": "^4.0.3"
1390
+ },
1391
+ "engines": {
1392
+ "node": ">=10.13.0"
1393
+ }
1394
+ },
1395
+ "node_modules/hasown": {
1396
+ "version": "2.0.2",
1397
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
1398
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
1399
+ "dev": true,
1400
+ "license": "MIT",
1401
+ "dependencies": {
1402
+ "function-bind": "^1.1.2"
1403
+ },
1404
+ "engines": {
1405
+ "node": ">= 0.4"
1406
+ }
1407
+ },
1408
+ "node_modules/is-binary-path": {
1409
+ "version": "2.1.0",
1410
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
1411
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
1412
+ "dev": true,
1413
+ "license": "MIT",
1414
+ "dependencies": {
1415
+ "binary-extensions": "^2.0.0"
1416
+ },
1417
+ "engines": {
1418
+ "node": ">=8"
1419
+ }
1420
+ },
1421
+ "node_modules/is-core-module": {
1422
+ "version": "2.16.1",
1423
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
1424
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
1425
+ "dev": true,
1426
+ "license": "MIT",
1427
+ "dependencies": {
1428
+ "hasown": "^2.0.2"
1429
+ },
1430
+ "engines": {
1431
+ "node": ">= 0.4"
1432
+ },
1433
+ "funding": {
1434
+ "url": "https://github.com/sponsors/ljharb"
1435
+ }
1436
+ },
1437
+ "node_modules/is-extglob": {
1438
+ "version": "2.1.1",
1439
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
1440
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
1441
+ "dev": true,
1442
+ "license": "MIT",
1443
+ "engines": {
1444
+ "node": ">=0.10.0"
1445
+ }
1446
+ },
1447
+ "node_modules/is-glob": {
1448
+ "version": "4.0.3",
1449
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
1450
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
1451
+ "dev": true,
1452
+ "license": "MIT",
1453
+ "dependencies": {
1454
+ "is-extglob": "^2.1.1"
1455
+ },
1456
+ "engines": {
1457
+ "node": ">=0.10.0"
1458
+ }
1459
+ },
1460
+ "node_modules/is-number": {
1461
+ "version": "7.0.0",
1462
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
1463
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
1464
+ "dev": true,
1465
+ "license": "MIT",
1466
+ "engines": {
1467
+ "node": ">=0.12.0"
1468
+ }
1469
+ },
1470
+ "node_modules/jiti": {
1471
+ "version": "1.21.7",
1472
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
1473
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
1474
+ "dev": true,
1475
+ "license": "MIT",
1476
+ "bin": {
1477
+ "jiti": "bin/jiti.js"
1478
+ }
1479
+ },
1480
  "node_modules/js-tokens": {
1481
  "version": "4.0.0",
1482
  "license": "MIT"
 
1503
  "node": ">=6"
1504
  }
1505
  },
1506
+ "node_modules/lilconfig": {
1507
+ "version": "3.1.3",
1508
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
1509
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
1510
+ "dev": true,
1511
+ "license": "MIT",
1512
+ "engines": {
1513
+ "node": ">=14"
1514
+ },
1515
+ "funding": {
1516
+ "url": "https://github.com/sponsors/antonk52"
1517
+ }
1518
+ },
1519
+ "node_modules/lines-and-columns": {
1520
+ "version": "1.2.4",
1521
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
1522
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
1523
+ "dev": true,
1524
+ "license": "MIT"
1525
+ },
1526
  "node_modules/loose-envify": {
1527
  "version": "1.4.0",
1528
  "license": "MIT",
 
1541
  "yallist": "^3.0.2"
1542
  }
1543
  },
1544
+ "node_modules/merge2": {
1545
+ "version": "1.4.1",
1546
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
1547
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
1548
  "dev": true,
1549
+ "license": "MIT",
1550
+ "engines": {
1551
+ "node": ">= 8"
1552
+ }
1553
  },
1554
+ "node_modules/micromatch": {
1555
+ "version": "4.0.8",
1556
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
1557
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
1558
  "dev": true,
 
 
 
 
 
 
1559
  "license": "MIT",
1560
+ "dependencies": {
1561
+ "braces": "^3.0.3",
1562
+ "picomatch": "^2.3.1"
1563
  },
1564
  "engines": {
1565
+ "node": ">=8.6"
1566
+ }
1567
+ },
1568
+ "node_modules/micromatch/node_modules/picomatch": {
1569
+ "version": "2.3.1",
1570
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
1571
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
1572
+ "dev": true,
1573
+ "license": "MIT",
1574
+ "engines": {
1575
+ "node": ">=8.6"
1576
+ },
1577
+ "funding": {
1578
+ "url": "https://github.com/sponsors/jonschlinkert"
1579
+ }
1580
+ },
1581
+ "node_modules/ms": {
1582
+ "version": "2.1.3",
1583
+ "dev": true,
1584
+ "license": "MIT"
1585
+ },
1586
+ "node_modules/mz": {
1587
+ "version": "2.7.0",
1588
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
1589
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
1590
+ "dev": true,
1591
+ "license": "MIT",
1592
+ "dependencies": {
1593
+ "any-promise": "^1.0.0",
1594
+ "object-assign": "^4.0.1",
1595
+ "thenify-all": "^1.0.0"
1596
+ }
1597
+ },
1598
+ "node_modules/nanoid": {
1599
+ "version": "3.3.11",
1600
+ "dev": true,
1601
+ "funding": [
1602
+ {
1603
+ "type": "github",
1604
+ "url": "https://github.com/sponsors/ai"
1605
+ }
1606
+ ],
1607
+ "license": "MIT",
1608
+ "bin": {
1609
+ "nanoid": "bin/nanoid.cjs"
1610
+ },
1611
+ "engines": {
1612
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1613
  }
1614
  },
1615
  "node_modules/node-releases": {
1616
+ "version": "2.0.27",
1617
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
1618
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
1619
+ "dev": true,
1620
+ "license": "MIT"
1621
+ },
1622
+ "node_modules/normalize-path": {
1623
+ "version": "3.0.0",
1624
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
1625
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
1626
+ "dev": true,
1627
+ "license": "MIT",
1628
+ "engines": {
1629
+ "node": ">=0.10.0"
1630
+ }
1631
+ },
1632
+ "node_modules/object-assign": {
1633
+ "version": "4.1.1",
1634
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
1635
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
1636
+ "license": "MIT",
1637
+ "engines": {
1638
+ "node": ">=0.10.0"
1639
+ }
1640
+ },
1641
+ "node_modules/object-hash": {
1642
+ "version": "3.0.0",
1643
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
1644
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
1645
+ "dev": true,
1646
+ "license": "MIT",
1647
+ "engines": {
1648
+ "node": ">= 6"
1649
+ }
1650
+ },
1651
+ "node_modules/path-parse": {
1652
+ "version": "1.0.7",
1653
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
1654
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
1655
  "dev": true,
1656
  "license": "MIT"
1657
  },
 
1673
  "url": "https://github.com/sponsors/jonschlinkert"
1674
  }
1675
  },
1676
+ "node_modules/pify": {
1677
+ "version": "2.3.0",
1678
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
1679
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
1680
+ "dev": true,
1681
+ "license": "MIT",
1682
+ "engines": {
1683
+ "node": ">=0.10.0"
1684
+ }
1685
+ },
1686
+ "node_modules/pirates": {
1687
+ "version": "4.0.7",
1688
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
1689
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
1690
+ "dev": true,
1691
+ "license": "MIT",
1692
+ "engines": {
1693
+ "node": ">= 6"
1694
+ }
1695
+ },
1696
  "node_modules/postcss": {
1697
  "version": "8.5.6",
1698
  "dev": true,
 
1720
  "node": "^10 || ^12 || >=14"
1721
  }
1722
  },
1723
+ "node_modules/postcss-import": {
1724
+ "version": "15.1.0",
1725
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
1726
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
1727
+ "dev": true,
1728
+ "license": "MIT",
1729
+ "dependencies": {
1730
+ "postcss-value-parser": "^4.0.0",
1731
+ "read-cache": "^1.0.0",
1732
+ "resolve": "^1.1.7"
1733
+ },
1734
+ "engines": {
1735
+ "node": ">=14.0.0"
1736
+ },
1737
+ "peerDependencies": {
1738
+ "postcss": "^8.0.0"
1739
+ }
1740
+ },
1741
+ "node_modules/postcss-js": {
1742
+ "version": "4.1.0",
1743
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
1744
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
1745
+ "dev": true,
1746
+ "funding": [
1747
+ {
1748
+ "type": "opencollective",
1749
+ "url": "https://opencollective.com/postcss/"
1750
+ },
1751
+ {
1752
+ "type": "github",
1753
+ "url": "https://github.com/sponsors/ai"
1754
+ }
1755
+ ],
1756
+ "license": "MIT",
1757
+ "dependencies": {
1758
+ "camelcase-css": "^2.0.1"
1759
+ },
1760
+ "engines": {
1761
+ "node": "^12 || ^14 || >= 16"
1762
+ },
1763
+ "peerDependencies": {
1764
+ "postcss": "^8.4.21"
1765
+ }
1766
+ },
1767
+ "node_modules/postcss-load-config": {
1768
+ "version": "6.0.1",
1769
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
1770
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
1771
+ "dev": true,
1772
+ "funding": [
1773
+ {
1774
+ "type": "opencollective",
1775
+ "url": "https://opencollective.com/postcss/"
1776
+ },
1777
+ {
1778
+ "type": "github",
1779
+ "url": "https://github.com/sponsors/ai"
1780
+ }
1781
+ ],
1782
+ "license": "MIT",
1783
+ "dependencies": {
1784
+ "lilconfig": "^3.1.1"
1785
+ },
1786
+ "engines": {
1787
+ "node": ">= 18"
1788
+ },
1789
+ "peerDependencies": {
1790
+ "jiti": ">=1.21.0",
1791
+ "postcss": ">=8.0.9",
1792
+ "tsx": "^4.8.1",
1793
+ "yaml": "^2.4.2"
1794
+ },
1795
+ "peerDependenciesMeta": {
1796
+ "jiti": {
1797
+ "optional": true
1798
+ },
1799
+ "postcss": {
1800
+ "optional": true
1801
+ },
1802
+ "tsx": {
1803
+ "optional": true
1804
+ },
1805
+ "yaml": {
1806
+ "optional": true
1807
+ }
1808
+ }
1809
+ },
1810
+ "node_modules/postcss-nested": {
1811
+ "version": "6.2.0",
1812
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
1813
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
1814
+ "dev": true,
1815
+ "funding": [
1816
+ {
1817
+ "type": "opencollective",
1818
+ "url": "https://opencollective.com/postcss/"
1819
+ },
1820
+ {
1821
+ "type": "github",
1822
+ "url": "https://github.com/sponsors/ai"
1823
+ }
1824
+ ],
1825
+ "license": "MIT",
1826
+ "dependencies": {
1827
+ "postcss-selector-parser": "^6.1.1"
1828
+ },
1829
+ "engines": {
1830
+ "node": ">=12.0"
1831
+ },
1832
+ "peerDependencies": {
1833
+ "postcss": "^8.2.14"
1834
+ }
1835
+ },
1836
+ "node_modules/postcss-selector-parser": {
1837
+ "version": "6.1.2",
1838
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
1839
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
1840
+ "dev": true,
1841
+ "license": "MIT",
1842
+ "dependencies": {
1843
+ "cssesc": "^3.0.0",
1844
+ "util-deprecate": "^1.0.2"
1845
+ },
1846
+ "engines": {
1847
+ "node": ">=4"
1848
+ }
1849
+ },
1850
+ "node_modules/postcss-value-parser": {
1851
+ "version": "4.2.0",
1852
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
1853
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
1854
+ "dev": true,
1855
+ "license": "MIT"
1856
+ },
1857
+ "node_modules/prop-types": {
1858
+ "version": "15.8.1",
1859
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
1860
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
1861
+ "license": "MIT",
1862
+ "dependencies": {
1863
+ "loose-envify": "^1.4.0",
1864
+ "object-assign": "^4.1.1",
1865
+ "react-is": "^16.13.1"
1866
+ }
1867
+ },
1868
+ "node_modules/queue-microtask": {
1869
+ "version": "1.2.3",
1870
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
1871
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
1872
+ "dev": true,
1873
+ "funding": [
1874
+ {
1875
+ "type": "github",
1876
+ "url": "https://github.com/sponsors/feross"
1877
+ },
1878
+ {
1879
+ "type": "patreon",
1880
+ "url": "https://www.patreon.com/feross"
1881
+ },
1882
+ {
1883
+ "type": "consulting",
1884
+ "url": "https://feross.org/support"
1885
+ }
1886
+ ],
1887
+ "license": "MIT"
1888
+ },
1889
  "node_modules/react": {
1890
  "version": "18.3.1",
1891
  "license": "MIT",
 
1907
  "react": "^18.3.1"
1908
  }
1909
  },
1910
+ "node_modules/react-feather": {
1911
+ "version": "2.0.10",
1912
+ "resolved": "https://registry.npmjs.org/react-feather/-/react-feather-2.0.10.tgz",
1913
+ "integrity": "sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==",
1914
+ "license": "MIT",
1915
+ "dependencies": {
1916
+ "prop-types": "^15.7.2"
1917
+ },
1918
+ "peerDependencies": {
1919
+ "react": ">=16.8.6"
1920
+ }
1921
+ },
1922
+ "node_modules/react-is": {
1923
+ "version": "16.13.1",
1924
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
1925
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
1926
+ "license": "MIT"
1927
+ },
1928
  "node_modules/react-refresh": {
1929
  "version": "0.17.0",
1930
  "dev": true,
 
1933
  "node": ">=0.10.0"
1934
  }
1935
  },
1936
+ "node_modules/react-router": {
1937
+ "version": "6.30.3",
1938
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
1939
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
1940
+ "license": "MIT",
1941
+ "dependencies": {
1942
+ "@remix-run/router": "1.23.2"
1943
+ },
1944
+ "engines": {
1945
+ "node": ">=14.0.0"
1946
+ },
1947
+ "peerDependencies": {
1948
+ "react": ">=16.8"
1949
+ }
1950
+ },
1951
+ "node_modules/react-router-dom": {
1952
+ "version": "6.30.3",
1953
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
1954
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
1955
+ "license": "MIT",
1956
+ "dependencies": {
1957
+ "@remix-run/router": "1.23.2",
1958
+ "react-router": "6.30.3"
1959
+ },
1960
+ "engines": {
1961
+ "node": ">=14.0.0"
1962
+ },
1963
+ "peerDependencies": {
1964
+ "react": ">=16.8",
1965
+ "react-dom": ">=16.8"
1966
+ }
1967
+ },
1968
+ "node_modules/read-cache": {
1969
+ "version": "1.0.0",
1970
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
1971
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
1972
+ "dev": true,
1973
+ "license": "MIT",
1974
+ "dependencies": {
1975
+ "pify": "^2.3.0"
1976
+ }
1977
+ },
1978
+ "node_modules/readdirp": {
1979
+ "version": "3.6.0",
1980
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
1981
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
1982
+ "dev": true,
1983
+ "license": "MIT",
1984
+ "dependencies": {
1985
+ "picomatch": "^2.2.1"
1986
+ },
1987
+ "engines": {
1988
+ "node": ">=8.10.0"
1989
+ }
1990
+ },
1991
+ "node_modules/readdirp/node_modules/picomatch": {
1992
+ "version": "2.3.1",
1993
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
1994
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
1995
+ "dev": true,
1996
+ "license": "MIT",
1997
+ "engines": {
1998
+ "node": ">=8.6"
1999
+ },
2000
+ "funding": {
2001
+ "url": "https://github.com/sponsors/jonschlinkert"
2002
+ }
2003
+ },
2004
+ "node_modules/resolve": {
2005
+ "version": "1.22.11",
2006
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
2007
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
2008
+ "dev": true,
2009
+ "license": "MIT",
2010
+ "dependencies": {
2011
+ "is-core-module": "^2.16.1",
2012
+ "path-parse": "^1.0.7",
2013
+ "supports-preserve-symlinks-flag": "^1.0.0"
2014
+ },
2015
+ "bin": {
2016
+ "resolve": "bin/resolve"
2017
+ },
2018
+ "engines": {
2019
+ "node": ">= 0.4"
2020
+ },
2021
+ "funding": {
2022
+ "url": "https://github.com/sponsors/ljharb"
2023
+ }
2024
+ },
2025
+ "node_modules/reusify": {
2026
+ "version": "1.1.0",
2027
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
2028
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
2029
+ "dev": true,
2030
+ "license": "MIT",
2031
+ "engines": {
2032
+ "iojs": ">=1.0.0",
2033
+ "node": ">=0.10.0"
2034
+ }
2035
+ },
2036
  "node_modules/rollup": {
2037
  "version": "4.52.4",
2038
  "dev": true,
 
2073
  "fsevents": "~2.3.2"
2074
  }
2075
  },
2076
+ "node_modules/run-parallel": {
2077
+ "version": "1.2.0",
2078
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
2079
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
2080
+ "dev": true,
2081
+ "funding": [
2082
+ {
2083
+ "type": "github",
2084
+ "url": "https://github.com/sponsors/feross"
2085
+ },
2086
+ {
2087
+ "type": "patreon",
2088
+ "url": "https://www.patreon.com/feross"
2089
+ },
2090
+ {
2091
+ "type": "consulting",
2092
+ "url": "https://feross.org/support"
2093
+ }
2094
+ ],
2095
+ "license": "MIT",
2096
+ "dependencies": {
2097
+ "queue-microtask": "^1.2.2"
2098
+ }
2099
+ },
2100
  "node_modules/scheduler": {
2101
  "version": "0.23.2",
2102
  "license": "MIT",
 
2120
  "node": ">=0.10.0"
2121
  }
2122
  },
2123
+ "node_modules/sucrase": {
2124
+ "version": "3.35.1",
2125
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
2126
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
2127
+ "dev": true,
2128
+ "license": "MIT",
2129
+ "dependencies": {
2130
+ "@jridgewell/gen-mapping": "^0.3.2",
2131
+ "commander": "^4.0.0",
2132
+ "lines-and-columns": "^1.1.6",
2133
+ "mz": "^2.7.0",
2134
+ "pirates": "^4.0.1",
2135
+ "tinyglobby": "^0.2.11",
2136
+ "ts-interface-checker": "^0.1.9"
2137
+ },
2138
+ "bin": {
2139
+ "sucrase": "bin/sucrase",
2140
+ "sucrase-node": "bin/sucrase-node"
2141
+ },
2142
+ "engines": {
2143
+ "node": ">=16 || 14 >=14.17"
2144
+ }
2145
+ },
2146
+ "node_modules/supports-preserve-symlinks-flag": {
2147
+ "version": "1.0.0",
2148
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
2149
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
2150
+ "dev": true,
2151
+ "license": "MIT",
2152
+ "engines": {
2153
+ "node": ">= 0.4"
2154
+ },
2155
+ "funding": {
2156
+ "url": "https://github.com/sponsors/ljharb"
2157
+ }
2158
+ },
2159
+ "node_modules/tailwindcss": {
2160
+ "version": "3.4.19",
2161
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
2162
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
2163
+ "dev": true,
2164
+ "license": "MIT",
2165
+ "dependencies": {
2166
+ "@alloc/quick-lru": "^5.2.0",
2167
+ "arg": "^5.0.2",
2168
+ "chokidar": "^3.6.0",
2169
+ "didyoumean": "^1.2.2",
2170
+ "dlv": "^1.1.3",
2171
+ "fast-glob": "^3.3.2",
2172
+ "glob-parent": "^6.0.2",
2173
+ "is-glob": "^4.0.3",
2174
+ "jiti": "^1.21.7",
2175
+ "lilconfig": "^3.1.3",
2176
+ "micromatch": "^4.0.8",
2177
+ "normalize-path": "^3.0.0",
2178
+ "object-hash": "^3.0.0",
2179
+ "picocolors": "^1.1.1",
2180
+ "postcss": "^8.4.47",
2181
+ "postcss-import": "^15.1.0",
2182
+ "postcss-js": "^4.0.1",
2183
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
2184
+ "postcss-nested": "^6.2.0",
2185
+ "postcss-selector-parser": "^6.1.2",
2186
+ "resolve": "^1.22.8",
2187
+ "sucrase": "^3.35.0"
2188
+ },
2189
+ "bin": {
2190
+ "tailwind": "lib/cli.js",
2191
+ "tailwindcss": "lib/cli.js"
2192
+ },
2193
+ "engines": {
2194
+ "node": ">=14.0.0"
2195
+ }
2196
+ },
2197
+ "node_modules/thenify": {
2198
+ "version": "3.3.1",
2199
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
2200
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
2201
+ "dev": true,
2202
+ "license": "MIT",
2203
+ "dependencies": {
2204
+ "any-promise": "^1.0.0"
2205
+ }
2206
+ },
2207
+ "node_modules/thenify-all": {
2208
+ "version": "1.6.0",
2209
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
2210
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
2211
+ "dev": true,
2212
+ "license": "MIT",
2213
+ "dependencies": {
2214
+ "thenify": ">= 3.1.0 < 4"
2215
+ },
2216
+ "engines": {
2217
+ "node": ">=0.8"
2218
+ }
2219
+ },
2220
  "node_modules/tinyglobby": {
2221
  "version": "0.2.15",
2222
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
 
2234
  "url": "https://github.com/sponsors/SuperchupuDev"
2235
  }
2236
  },
2237
+ "node_modules/to-regex-range": {
2238
+ "version": "5.0.1",
2239
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
2240
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
2241
+ "dev": true,
2242
+ "license": "MIT",
2243
+ "dependencies": {
2244
+ "is-number": "^7.0.0"
2245
+ },
2246
+ "engines": {
2247
+ "node": ">=8.0"
2248
+ }
2249
+ },
2250
+ "node_modules/ts-interface-checker": {
2251
+ "version": "0.1.13",
2252
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
2253
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
2254
+ "dev": true,
2255
+ "license": "Apache-2.0"
2256
+ },
2257
  "node_modules/typescript": {
2258
  "version": "5.9.3",
2259
  "dev": true,
 
2267
  }
2268
  },
2269
  "node_modules/update-browserslist-db": {
2270
+ "version": "1.2.3",
2271
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2272
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
2273
  "dev": true,
2274
  "funding": [
2275
  {
 
2297
  "browserslist": ">= 4.21.0"
2298
  }
2299
  },
2300
+ "node_modules/util-deprecate": {
2301
+ "version": "1.0.2",
2302
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
2303
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
2304
+ "dev": true,
2305
+ "license": "MIT"
2306
+ },
2307
  "node_modules/vite": {
2308
  "version": "7.3.1",
2309
  "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
frontend/package.json CHANGED
@@ -10,12 +10,17 @@
10
  },
11
  "dependencies": {
12
  "react": "^18.3.1",
13
- "react-dom": "^18.3.1"
 
 
14
  },
15
  "devDependencies": {
16
  "@types/react": "^18.3.3",
17
  "@types/react-dom": "^18.3.3",
18
  "@vitejs/plugin-react": "^4.3.1",
 
 
 
19
  "typescript": "^5.4.5",
20
  "vite": "^7.3.1"
21
  }
 
10
  },
11
  "dependencies": {
12
  "react": "^18.3.1",
13
+ "react-dom": "^18.3.1",
14
+ "react-feather": "^2.0.10",
15
+ "react-router-dom": "^6.26.2"
16
  },
17
  "devDependencies": {
18
  "@types/react": "^18.3.3",
19
  "@types/react-dom": "^18.3.3",
20
  "@vitejs/plugin-react": "^4.3.1",
21
+ "autoprefixer": "^10.4.20",
22
+ "postcss": "^8.4.41",
23
+ "tailwindcss": "^3.4.10",
24
  "typescript": "^5.4.5",
25
  "vite": "^7.3.1"
26
  }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
frontend/public/assets/client-logo.png ADDED
frontend/public/assets/prosento-logo.png ADDED
frontend/public/templates/job-sheet-template.html ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>SIMM Inspection Job Sheet</title>
7
+
8
+ <!-- Optional project stylesheet -->
9
+ <link rel="stylesheet" href="../style.css" />
10
+
11
+ <!-- Tailwind (CDN OK for prototypes; compile for production) -->
12
+ <script src="https://cdn.tailwindcss.com"></script>
13
+
14
+ <!-- Feather icons -->
15
+ <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
16
+
17
+ <style>
18
+ @page { size: A4; margin: 10mm; }
19
+
20
+ @media print {
21
+ html, body { background: #fff !important; }
22
+ .no-print { display: none !important; }
23
+ main { box-shadow: none !important; border: none !important; margin: 0 !important; padding: 0 !important; }
24
+ }
25
+
26
+ .avoid-break { break-inside: avoid; page-break-inside: avoid; }
27
+ img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
28
+ </style>
29
+ </head>
30
+
31
+ <body class="bg-gray-50 print:bg-white">
32
+ <!-- A4-focused container -->
33
+ <main class="mx-auto max-w-[210mm] bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-5 print:ring-0 print:shadow-none print:rounded-none">
34
+ <!-- Header -->
35
+ <header class="mb-4 border-b border-gray-200 pb-3">
36
+ <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
37
+ <div class="flex items-center">
38
+ <img
39
+ src="../assets/prosento-logo.png"
40
+ alt="Prosento logo"
41
+ class="h-10 w-auto object-contain"
42
+ loading="eager"
43
+ />
44
+ </div>
45
+
46
+ <div class="text-center leading-tight">
47
+ <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">SIMM Inspection Job Sheet</h1>
48
+ <p class="text-sm text-gray-600 whitespace-nowrap">Structural Inspection and Maintenance Management</p>
49
+ </div>
50
+
51
+ <div class="flex items-center justify-end">
52
+ <img
53
+ src="../assets/client-logo.png"
54
+ alt="Client logo placeholder"
55
+ class="h-10 w-auto object-contain"
56
+ loading="eager"
57
+ />
58
+ </div>
59
+ </div>
60
+ </header>
61
+
62
+ <!-- Inspection Details -->
63
+ <section class="mb-4" aria-labelledby="inspection-details-title">
64
+ <h2 id="inspection-details-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
65
+ Inspection Details
66
+ </h2>
67
+
68
+ <dl class="grid grid-cols-2 md:grid-cols-4 gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
69
+ <div class="space-y-0.5">
70
+ <dt class="text-xs font-medium text-gray-500">Inspection Date</dt>
71
+ <dd class="text-sm font-semibold text-gray-900">2023-11-15</dd>
72
+ </div>
73
+
74
+ <div class="space-y-0.5">
75
+ <dt class="text-xs font-medium text-gray-500">Inspector</dt>
76
+ <dd class="text-sm font-semibold text-gray-900">John Doe</dd>
77
+ </div>
78
+
79
+ <div class="space-y-0.5">
80
+ <dt class="text-xs font-medium text-gray-500">Accompanied By</dt>
81
+ <dd class="text-sm font-semibold text-gray-900">John Doe</dd>
82
+ </div>
83
+
84
+ <div class="space-y-0.5">
85
+ <dt class="text-xs font-medium text-gray-500">Document No</dt>
86
+ <dd class="text-sm font-mono font-semibold text-gray-900">SIMM-JS-2023-001</dd>
87
+ </div>
88
+
89
+ <div class="space-y-0.5 md:col-span-2">
90
+ <dt class="text-xs font-medium text-gray-500">Project</dt>
91
+ <dd class="text-sm font-semibold text-gray-900">North Pit - Sector 4 Conveyor Upgrade</dd>
92
+ </div>
93
+
94
+ <div class="space-y-0.5 md:col-span-2">
95
+ <dt class="text-xs font-medium text-gray-500">Client / Site</dt>
96
+ <dd class="text-sm font-semibold text-gray-900">Tronox</dd>
97
+ </div>
98
+ </dl>
99
+ </section>
100
+
101
+ <!-- Observations and Findings -->
102
+ <section class="mb-4" aria-labelledby="observations-title">
103
+ <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
104
+ Observations and Findings
105
+ </h2>
106
+
107
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
108
+ <!-- Left column -->
109
+ <div class="space-y-2">
110
+ <div class="grid grid-cols-2 gap-2">
111
+ <div class="space-y-0.5">
112
+ <div class="text-xs font-medium text-gray-500">Reference</div>
113
+ <div class="text-sm font-semibold text-gray-900">REF-7821</div>
114
+ </div>
115
+
116
+ <div class="space-y-0.5">
117
+ <div class="text-xs font-medium text-gray-500">Action Type</div>
118
+ <div class="text-sm font-semibold text-gray-900">Structural Repair</div>
119
+ </div>
120
+
121
+ <div class="space-y-0.5 col-span-2">
122
+ <div class="text-xs font-medium text-gray-500">Item Description</div>
123
+ <div class="text-sm font-semibold text-gray-900">Main support beam for conveyor CV-04</div>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <!-- Right column -->
129
+ <div class="space-y-2">
130
+ <div class="space-y-0.5">
131
+ <div class="text-xs font-medium text-gray-500">Functional Location</div>
132
+ <div class="text-sm font-semibold text-gray-900">Conveyor Support - CV-04-SUP-02</div>
133
+ </div>
134
+ </div>
135
+
136
+ <!-- Centered Category + Priority (must be direct child of the grid) -->
137
+ <div class="md:col-span-2 flex justify-center">
138
+ <div class="inline-flex items-center gap-10">
139
+ <div class="text-center space-y-1">
140
+ <div class="text-xs font-medium text-gray-500">Category</div>
141
+ <span
142
+ id="condition-rating"
143
+ class="badge inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold"
144
+ aria-label="Category rating"
145
+ >
146
+ <!-- populated by JS -->
147
+ </span>
148
+ </div>
149
+
150
+ <div class="text-center space-y-1">
151
+ <div class="text-xs font-medium text-gray-500">Priority</div>
152
+ <span
153
+ id="priority-rating"
154
+ class="badge inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold"
155
+ aria-label="Priority rating"
156
+ >
157
+ <!-- populated by JS -->
158
+ </span>
159
+ </div>
160
+ </div>
161
+ </div>
162
+
163
+ <!-- Full-width: Condition Description -->
164
+ <div class="md:col-span-2 space-y-1">
165
+ <div class="text-xs font-medium text-gray-500">Condition Description</div>
166
+ <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
167
+ <p class="text-amber-800 text-sm font-semibold leading-snug">
168
+ Visible corrosion on lower flange with ~15% material loss. Surface pitting along entire length.
169
+ </p>
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Full-width: Required Action -->
174
+ <div class="md:col-span-2 space-y-1">
175
+ <div class="text-xs font-medium text-gray-500">Required Action</div>
176
+ <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
177
+ <p class="text-blue-800 text-sm font-semibold leading-snug">
178
+ Clean exposed rebar; apply corrosion protection; use wet-to-dry epoxy; reinstate with concrete repair.
179
+ Complete within next 12 months to limit further degradation.
180
+ </p>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ </section>
185
+
186
+ <!-- Photo Documentation -->
187
+ <section class="mb-4 avoid-break" aria-labelledby="photo-doc-title">
188
+ <h2 id="photo-doc-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
189
+ Photo Documentation
190
+ </h2>
191
+
192
+ <div class="grid grid-cols-2 gap-3">
193
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
194
+ <img
195
+ src="https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Screenshot%202026-02-02%20100102.png"
196
+ alt="Photo 1"
197
+ class="w-full h-40 object-contain mx-auto"
198
+ loading="eager"
199
+ />
200
+ <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
201
+ Fig 1: Ref 1.1 - Concrete spalling (example)
202
+ </figcaption>
203
+ </figure>
204
+
205
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 relative">
206
+ <img
207
+ src="https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Picture2.png"
208
+ alt="Photo 2"
209
+ class="w-full h-40 object-contain mx-auto"
210
+ loading="eager"
211
+ />
212
+ <div class="absolute top-2 left-2 bg-white/95 text-black text-[11px] font-bold px-2 py-1 rounded border border-gray-300">
213
+ #1.2 [C3, P3] Moderate Corrosion
214
+ </div>
215
+ <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
216
+ Fig 2: Ref 1.2 - Walkway corrosion (example)
217
+ </figcaption>
218
+ </figure>
219
+ </div>
220
+ </section>
221
+
222
+ <!-- Signatures -->
223
+ <section class="mt-6" aria-label="Signatures">
224
+ <div class="grid grid-cols-3 gap-4">
225
+ <div class="border-t pt-2">
226
+ <div class="text-xs font-medium text-gray-500">Inspected By</div>
227
+ <div class="h-10 mt-1 border-b border-gray-300"></div>
228
+ <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
229
+ </div>
230
+
231
+ <div class="border-t pt-2">
232
+ <div class="text-xs font-medium text-gray-500">Approved By</div>
233
+ <div class="h-10 mt-1 border-b border-gray-300"></div>
234
+ <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
235
+ </div>
236
+
237
+ <div class="border-t pt-2">
238
+ <div class="text-xs font-medium text-gray-500">Completed By</div>
239
+ <div class="h-10 mt-1 border-b border-gray-300"></div>
240
+ <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
241
+ </div>
242
+ </div>
243
+ </section>
244
+
245
+ <!-- Footer -->
246
+ <footer class="mt-4 text-center text-[11px] text-gray-500">
247
+ <p>Prosento - (c) 2026 All Rights Reserved</p>
248
+ <p class="mt-0.5">Automatically generated job sheet</p>
249
+ </footer>
250
+ </main>
251
+
252
+ <script>
253
+ document.addEventListener('DOMContentLoaded', () => {
254
+ // Badge tone system
255
+ const TONES = {
256
+ amber: ['bg-amber-50', 'text-amber-800', 'border-amber-200'],
257
+ emerald: ['bg-emerald-50', 'text-emerald-800', 'border-emerald-200'],
258
+ gray: ['bg-gray-50', 'text-gray-700', 'border-gray-200'],
259
+ };
260
+
261
+ const BASE_BADGE = 'inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold';
262
+
263
+ function setBadge(id, text, toneKey) {
264
+ const el = document.getElementById(id);
265
+ if (!el) return;
266
+ const tone = TONES[toneKey] || TONES.gray;
267
+ el.className = `${BASE_BADGE} ${tone.join(' ')}`;
268
+ el.textContent = text;
269
+ }
270
+
271
+ setBadge('condition-rating', '3 - Poor', 'amber');
272
+ setBadge('priority-rating', '3 - 3 Years', 'emerald');
273
+
274
+ // Icons
275
+ if (window.feather && typeof window.feather.replace === 'function') {
276
+ window.feather.replace();
277
+ }
278
+
279
+ // Ensure images are loaded before printing
280
+ window.addEventListener('beforeprint', () => {
281
+ document.querySelectorAll('img').forEach(img => (img.loading = 'eager'));
282
+ });
283
+ });
284
+ </script>
285
+ </body>
286
+ </html>
frontend/src/App.tsx CHANGED
@@ -1,3 +1,24 @@
 
 
 
 
 
 
 
 
 
1
  export default function App() {
2
- return <div className="black-screen" />;
 
 
 
 
 
 
 
 
 
 
 
 
3
  }
 
1
+ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
2
+
3
+ import UploadPage from "./pages/UploadPage";
4
+ import ProcessingPage from "./pages/ProcessingPage";
5
+ import ReviewSetupPage from "./pages/ReviewSetupPage";
6
+ import ReportViewerPage from "./pages/ReportViewerPage";
7
+ import EditLayoutsPage from "./pages/EditLayoutsPage";
8
+ import ExportPage from "./pages/ExportPage";
9
+
10
  export default function App() {
11
+ return (
12
+ <BrowserRouter>
13
+ <Routes>
14
+ <Route path="/" element={<UploadPage />} />
15
+ <Route path="/processing" element={<ProcessingPage />} />
16
+ <Route path="/review-setup" element={<ReviewSetupPage />} />
17
+ <Route path="/report-viewer" element={<ReportViewerPage />} />
18
+ <Route path="/edit-layouts" element={<EditLayoutsPage />} />
19
+ <Route path="/export" element={<ExportPage />} />
20
+ <Route path="*" element={<Navigate to="/" replace />} />
21
+ </Routes>
22
+ </BrowserRouter>
23
+ );
24
  }
frontend/src/components/PageFooter.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ type PageFooterProps = {
2
+ note?: string;
3
+ };
4
+
5
+ export function PageFooter({ note }: PageFooterProps) {
6
+ return (
7
+ <footer className="mt-12 text-center text-xs text-gray-500">
8
+ <p>Prosento - (c) 2026 All Rights Reserved</p>
9
+ {note ? <p className="mt-1">{note}</p> : null}
10
+ </footer>
11
+ );
12
+ }
frontend/src/components/PageHeader.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+
3
+ type PageHeaderProps = {
4
+ title: string;
5
+ subtitle?: string;
6
+ right?: ReactNode;
7
+ };
8
+
9
+ export function PageHeader({ title, subtitle, right }: PageHeaderProps) {
10
+ return (
11
+ <header className="mb-8 border-b border-gray-200 pb-4">
12
+ <div className="grid grid-cols-[auto,1fr,auto] items-center gap-4">
13
+ <div className="flex items-center">
14
+ <img
15
+ src="/assets/prosento-logo.png"
16
+ alt="Company logo"
17
+ className="h-12 w-auto object-contain"
18
+ loading="eager"
19
+ />
20
+ </div>
21
+
22
+ <div className="text-center">
23
+ <h1 className="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
24
+ {title}
25
+ </h1>
26
+ {subtitle ? (
27
+ <p className="text-gray-600 whitespace-nowrap">{subtitle}</p>
28
+ ) : null}
29
+ </div>
30
+
31
+ <div className="flex justify-end">{right}</div>
32
+ </div>
33
+ </header>
34
+ );
35
+ }
frontend/src/components/PageShell.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+
3
+ type PageShellProps = {
4
+ children: ReactNode;
5
+ className?: string;
6
+ };
7
+
8
+ export function PageShell({ children, className = "" }: PageShellProps) {
9
+ return (
10
+ <main
11
+ className={[
12
+ "max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8",
13
+ className,
14
+ ].join(" ")}
15
+ >
16
+ {children}
17
+ </main>
18
+ );
19
+ }
frontend/src/components/report-editor.js ADDED
@@ -0,0 +1,1177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-nocheck
2
+ class ReportEditor extends HTMLElement {
3
+ constructor() {
4
+ super();
5
+ this._mounted = false;
6
+
7
+ this.BASE_W = 595; // A4 points-ish (screen independent model)
8
+ this.BASE_H = 842;
9
+
10
+ this.state = {
11
+ isOpen: false,
12
+ zoom: 1,
13
+ activePage: 0,
14
+ pages: [], // [{ items: [...] }]
15
+ selectedId: null,
16
+ tool: "select", // select | text | rect
17
+ dragging: null, // { id, startX, startY, origX, origY }
18
+ resizing: null, // { id, handle, startX, startY, orig }
19
+ undo: [], // stack of serialized states (current page)
20
+ redo: [],
21
+ payload: null,
22
+ };
23
+
24
+ this.sessionId = null;
25
+ this.apiBase = null;
26
+ this._saveTimer = null;
27
+ }
28
+
29
+ connectedCallback() {
30
+ if (this._mounted) return;
31
+ this._mounted = true;
32
+ this.render();
33
+ this.bind();
34
+ this.hide();
35
+ }
36
+
37
+ // Public API
38
+ open({ payload, pageIndex = 0, totalPages = 6, sessionId = null, apiBase = null } = {}) {
39
+ this.state.payload = payload ?? null;
40
+ this.state.isOpen = true;
41
+ this.sessionId =
42
+ sessionId ||
43
+ (window.REPEX && typeof window.REPEX.getSessionId === "function"
44
+ ? window.REPEX.getSessionId()
45
+ : null);
46
+ this.apiBase =
47
+ apiBase ||
48
+ window.REPEX_API_BASE ||
49
+ (window.REPEX && window.REPEX.apiBase ? window.REPEX.apiBase : null);
50
+
51
+ // Load existing editor pages from storage, else initialize
52
+ const stored = this._loadPages();
53
+ if (stored && Array.isArray(stored.pages) && stored.pages.length) {
54
+ this.state.pages = stored.pages;
55
+ } else {
56
+ this.state.pages = Array.from({ length: totalPages }, () => ({ items: [] }));
57
+ this._savePages();
58
+ }
59
+
60
+ this.state.activePage = Math.min(Math.max(0, pageIndex), this.state.pages.length - 1);
61
+ this.state.selectedId = null;
62
+ this.state.tool = "select";
63
+ this.state.undo = [];
64
+ this.state.redo = [];
65
+
66
+ this.show();
67
+ this.updateAll();
68
+
69
+ if (this.sessionId) {
70
+ this._loadPagesFromServer().then((pages) => {
71
+ if (pages && pages.length) {
72
+ this.state.pages = pages;
73
+ this.state.activePage = Math.min(
74
+ Math.max(0, pageIndex),
75
+ this.state.pages.length - 1
76
+ );
77
+ this.updateAll();
78
+ }
79
+ });
80
+ }
81
+ }
82
+
83
+ close() {
84
+ this.state.isOpen = false;
85
+ this.hide();
86
+ this.dispatchEvent(new CustomEvent("editor-closed", { bubbles: true }));
87
+ }
88
+
89
+ // ---------- Rendering ----------
90
+ render() {
91
+ this.innerHTML = `
92
+ <div class="fixed inset-0 z-50 hidden" data-overlay>
93
+ <div class="absolute inset-0 bg-black/30"></div>
94
+
95
+ <div class="relative h-full w-full flex items-center justify-center p-4">
96
+ <div class="w-full max-w-6xl bg-white rounded-xl shadow-sm ring-1 ring-gray-200 overflow-hidden">
97
+ <!-- Header -->
98
+ <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
99
+ <div class="flex items-center gap-2">
100
+ <div class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gray-50 border border-gray-200">
101
+ <span class="text-xs font-bold text-gray-700">A4</span>
102
+ </div>
103
+ <div>
104
+ <div class="text-sm font-semibold text-gray-900">Edit Report</div>
105
+ <div class="text-xs text-gray-500">Drag, resize, format and arrange elements</div>
106
+ </div>
107
+ </div>
108
+
109
+ <div class="flex items-center gap-2">
110
+ <button data-btn="undo"
111
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed">
112
+ <i data-feather="rotate-ccw" class="h-4 w-4"></i> Undo
113
+ </button>
114
+ <button data-btn="redo"
115
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed">
116
+ <i data-feather="rotate-cw" class="h-4 w-4"></i> Redo
117
+ </button>
118
+
119
+ <button data-btn="save"
120
+ class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition">
121
+ <i data-feather="save" class="h-4 w-4"></i> Save
122
+ </button>
123
+
124
+ <button data-btn="close"
125
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
126
+ <i data-feather="x" class="h-4 w-4"></i> Done
127
+ </button>
128
+ </div>
129
+ </div>
130
+
131
+ <!-- Body -->
132
+ <div class="grid grid-cols-1 lg:grid-cols-[240px,1fr,280px] gap-0 min-h-[70vh]">
133
+ <!-- Pages sidebar -->
134
+ <aside class="border-r border-gray-200 bg-white p-3">
135
+ <div class="flex items-center justify-between mb-3">
136
+ <div class="text-sm font-semibold text-gray-900">Pages</div>
137
+ <button data-btn="add-page"
138
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
139
+ <i data-feather="plus" class="h-4 w-4"></i> Add
140
+ </button>
141
+ </div>
142
+
143
+ <div class="space-y-2 max-h-[60vh] overflow-auto pr-1" data-page-list></div>
144
+
145
+ <div class="mt-3 text-xs text-gray-500">
146
+ Tip: Click a page to edit. Your edits are saved to the server.
147
+ </div>
148
+ </aside>
149
+
150
+ <!-- Canvas + toolbar -->
151
+ <section class="bg-gray-50 p-3">
152
+ <!-- Toolbar -->
153
+ <div class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 mb-3">
154
+ <div class="flex flex-wrap items-center gap-2">
155
+ <button data-tool="select"
156
+ class="toolBtn inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
157
+ <i data-feather="mouse-pointer" class="h-4 w-4"></i> Select
158
+ </button>
159
+
160
+ <button data-tool="text"
161
+ class="toolBtn inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
162
+ <i data-feather="type" class="h-4 w-4"></i> Text
163
+ </button>
164
+
165
+ <button data-tool="rect"
166
+ class="toolBtn inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
167
+ <i data-feather="square" class="h-4 w-4"></i> Shape
168
+ </button>
169
+
170
+ <button data-btn="add-image"
171
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
172
+ <i data-feather="image" class="h-4 w-4"></i> Image
173
+ </button>
174
+
175
+ <input data-file="image" type="file" accept="image/*" class="hidden" />
176
+ </div>
177
+
178
+ <div class="flex items-center gap-2">
179
+ <div class="text-xs font-semibold text-gray-600">Zoom</div>
180
+ <button data-btn="zoom-out"
181
+ class="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-2 hover:bg-gray-50 transition">
182
+ <i data-feather="minus" class="h-4 w-4"></i>
183
+ </button>
184
+ <div class="text-xs font-semibold text-gray-700 w-14 text-center" data-zoom-label>100%</div>
185
+ <button data-btn="zoom-in"
186
+ class="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-2 hover:bg-gray-50 transition">
187
+ <i data-feather="plus" class="h-4 w-4"></i>
188
+ </button>
189
+ </div>
190
+ </div>
191
+
192
+ <!-- Canvas area -->
193
+ <div class="flex justify-center">
194
+ <div class="relative" data-canvas-wrap>
195
+ <div
196
+ data-canvas
197
+ class="relative bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden select-none"
198
+ style="width: min(100%, 700px); aspect-ratio: 210/297;"
199
+ aria-label="Editable A4 canvas"
200
+ >
201
+ <!-- items injected here -->
202
+ </div>
203
+ </div>
204
+ </div>
205
+
206
+ <div class="mt-3 text-xs text-gray-500">
207
+ Drag elements to move. Drag corner handles to resize. Double-click text to edit.
208
+ </div>
209
+ </section>
210
+
211
+ <!-- Properties panel -->
212
+ <aside class="border-l border-gray-200 bg-white p-3">
213
+ <div class="text-sm font-semibold text-gray-900 mb-2">Properties</div>
214
+
215
+ <div data-empty-props class="text-sm text-gray-600 rounded-lg border border-gray-200 bg-gray-50 p-3">
216
+ Select an element to edit formatting and layout options.
217
+ </div>
218
+
219
+ <div data-props class="hidden space-y-4">
220
+ <!-- Arrange -->
221
+ <div class="rounded-lg border border-gray-200 p-3">
222
+ <div class="text-xs font-semibold text-gray-600 mb-2">Arrange</div>
223
+ <div class="flex flex-wrap gap-2">
224
+ <button data-btn="bring-front"
225
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
226
+ <i data-feather="chevrons-up" class="h-4 w-4"></i> Front
227
+ </button>
228
+ <button data-btn="send-back"
229
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
230
+ <i data-feather="chevrons-down" class="h-4 w-4"></i> Back
231
+ </button>
232
+ <button data-btn="duplicate"
233
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
234
+ <i data-feather="copy" class="h-4 w-4"></i> Duplicate
235
+ </button>
236
+ <button data-btn="delete"
237
+ class="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-semibold text-red-700 hover:bg-red-100 transition">
238
+ <i data-feather="trash-2" class="h-4 w-4"></i> Delete
239
+ </button>
240
+ </div>
241
+ </div>
242
+
243
+ <!-- Text controls -->
244
+ <div data-props-text class="rounded-lg border border-gray-200 p-3 hidden">
245
+ <div class="text-xs font-semibold text-gray-600 mb-2">Text</div>
246
+
247
+ <div class="grid grid-cols-2 gap-2">
248
+ <label class="text-xs text-gray-600">
249
+ Font size
250
+ <input data-prop="fontSize" type="number" min="8" max="72"
251
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" />
252
+ </label>
253
+
254
+ <label class="text-xs text-gray-600">
255
+ Color
256
+ <input data-prop="color" type="color"
257
+ class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" />
258
+ </label>
259
+ </div>
260
+
261
+ <div class="flex flex-wrap gap-2 mt-2">
262
+ <button data-btn="bold"
263
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
264
+ <i data-feather="bold" class="h-4 w-4"></i>
265
+ </button>
266
+ <button data-btn="italic"
267
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
268
+ <i data-feather="italic" class="h-4 w-4"></i>
269
+ </button>
270
+ <button data-btn="underline"
271
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
272
+ <i data-feather="underline" class="h-4 w-4"></i>
273
+ </button>
274
+
275
+ <button data-btn="align-left"
276
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
277
+ <i data-feather="align-left" class="h-4 w-4"></i>
278
+ </button>
279
+ <button data-btn="align-center"
280
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
281
+ <i data-feather="align-center" class="h-4 w-4"></i>
282
+ </button>
283
+ <button data-btn="align-right"
284
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
285
+ <i data-feather="align-right" class="h-4 w-4"></i>
286
+ </button>
287
+ </div>
288
+ </div>
289
+
290
+ <!-- Shape controls -->
291
+ <div data-props-rect class="rounded-lg border border-gray-200 p-3 hidden">
292
+ <div class="text-xs font-semibold text-gray-600 mb-2">Shape</div>
293
+
294
+ <div class="grid grid-cols-2 gap-2">
295
+ <label class="text-xs text-gray-600">
296
+ Fill
297
+ <input data-prop="fill" type="color"
298
+ class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" />
299
+ </label>
300
+
301
+ <label class="text-xs text-gray-600">
302
+ Border
303
+ <input data-prop="stroke" type="color"
304
+ class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" />
305
+ </label>
306
+ </div>
307
+
308
+ <label class="text-xs text-gray-600 block mt-2">
309
+ Border width
310
+ <input data-prop="strokeWidth" type="number" min="0" max="12"
311
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" />
312
+ </label>
313
+ </div>
314
+
315
+ <!-- Image controls -->
316
+ <div data-props-image class="rounded-lg border border-gray-200 p-3 hidden">
317
+ <div class="text-xs font-semibold text-gray-600 mb-2">Image</div>
318
+ <button data-btn="replace-image"
319
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
320
+ <i data-feather="refresh-cw" class="h-4 w-4"></i> Replace image
321
+ </button>
322
+ <input data-file="replace" type="file" accept="image/*" class="hidden" />
323
+ </div>
324
+ </div>
325
+
326
+ <div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
327
+ <div class="font-semibold text-gray-800 mb-1">Keyboard shortcuts</div>
328
+ <ul class="list-disc pl-4 space-y-1">
329
+ <li><span class="font-semibold">Delete</span>: remove selected</li>
330
+ <li><span class="font-semibold">Ctrl/Cmd+Z</span>: undo</li>
331
+ <li><span class="font-semibold">Ctrl/Cmd+Y</span>: redo</li>
332
+ <li><span class="font-semibold">Esc</span>: close editor</li>
333
+ </ul>
334
+ </div>
335
+ </aside>
336
+ </div>
337
+ </div>
338
+ </div>
339
+ </div>
340
+ `;
341
+ }
342
+
343
+ bind() {
344
+ this.$overlay = this.querySelector("[data-overlay]");
345
+ this.$pageList = this.querySelector("[data-page-list]");
346
+ this.$canvas = this.querySelector("[data-canvas]");
347
+ this.$zoomLabel = this.querySelector("[data-zoom-label]");
348
+
349
+ this.$emptyProps = this.querySelector("[data-empty-props]");
350
+ this.$props = this.querySelector("[data-props]");
351
+ this.$propsText = this.querySelector("[data-props-text]");
352
+ this.$propsRect = this.querySelector("[data-props-rect]");
353
+ this.$propsImage = this.querySelector("[data-props-image]");
354
+
355
+ this.$imgFile = this.querySelector('[data-file="image"]');
356
+ this.$replaceFile = this.querySelector('[data-file="replace"]');
357
+
358
+ // header buttons
359
+ this.querySelector('[data-btn="close"]').addEventListener("click", () => this.close());
360
+ this.querySelector('[data-btn="save"]').addEventListener("click", () => this._savePages(true));
361
+ this.querySelector('[data-btn="undo"]').addEventListener("click", () => this.undo());
362
+ this.querySelector('[data-btn="redo"]').addEventListener("click", () => this.redo());
363
+
364
+ // tools
365
+ this.querySelectorAll(".toolBtn").forEach(btn => {
366
+ btn.addEventListener("click", () => {
367
+ this.state.tool = btn.dataset.tool;
368
+ this.updateToolbar();
369
+ });
370
+ });
371
+
372
+ // toolbar buttons
373
+ this.querySelector('[data-btn="add-image"]').addEventListener("click", () => this.$imgFile.click());
374
+ this.$imgFile.addEventListener("change", (e) => this._handleImageUpload(e, "add"));
375
+
376
+ this.querySelector('[data-btn="zoom-in"]').addEventListener("click", () => this.setZoom(this.state.zoom + 0.1));
377
+ this.querySelector('[data-btn="zoom-out"]').addEventListener("click", () => this.setZoom(this.state.zoom - 0.1));
378
+
379
+ // pages
380
+ this.querySelector('[data-btn="add-page"]').addEventListener("click", () => this.addPage());
381
+
382
+ // properties buttons
383
+ this.querySelector('[data-btn="delete"]').addEventListener("click", () => this.deleteSelected());
384
+ this.querySelector('[data-btn="duplicate"]').addEventListener("click", () => this.duplicateSelected());
385
+ this.querySelector('[data-btn="bring-front"]').addEventListener("click", () => this.bringFront());
386
+ this.querySelector('[data-btn="send-back"]').addEventListener("click", () => this.sendBack());
387
+
388
+ // text props
389
+ this.querySelector('[data-btn="bold"]').addEventListener("click", () => this.toggleTextStyle("bold"));
390
+ this.querySelector('[data-btn="italic"]').addEventListener("click", () => this.toggleTextStyle("italic"));
391
+ this.querySelector('[data-btn="underline"]').addEventListener("click", () => this.toggleTextStyle("underline"));
392
+ this.querySelector('[data-btn="align-left"]').addEventListener("click", () => this.setTextAlign("left"));
393
+ this.querySelector('[data-btn="align-center"]').addEventListener("click", () => this.setTextAlign("center"));
394
+ this.querySelector('[data-btn="align-right"]').addEventListener("click", () => this.setTextAlign("right"));
395
+
396
+ this.querySelector('[data-prop="fontSize"]').addEventListener("input", (e) => this.setProp("fontSize", Number(e.target.value || 12)));
397
+ this.querySelector('[data-prop="color"]').addEventListener("input", (e) => this.setProp("color", e.target.value));
398
+
399
+ // rect props
400
+ this.querySelector('[data-prop="fill"]').addEventListener("input", (e) => this.setProp("fill", e.target.value));
401
+ this.querySelector('[data-prop="stroke"]').addEventListener("input", (e) => this.setProp("stroke", e.target.value));
402
+ this.querySelector('[data-prop="strokeWidth"]').addEventListener("input", (e) => this.setProp("strokeWidth", Number(e.target.value || 0)));
403
+
404
+ // image replace
405
+ this.querySelector('[data-btn="replace-image"]').addEventListener("click", () => this.$replaceFile.click());
406
+ this.$replaceFile.addEventListener("change", (e) => this._handleImageUpload(e, "replace"));
407
+
408
+ // canvas interactions
409
+ this.$canvas.addEventListener("pointerdown", (e) => this.onCanvasPointerDown(e));
410
+ window.addEventListener("pointermove", (e) => this.onPointerMove(e));
411
+ window.addEventListener("pointerup", () => this.onPointerUp());
412
+
413
+ // keyboard shortcuts
414
+ window.addEventListener("keydown", (e) => {
415
+ if (!this.state.isOpen) return;
416
+
417
+ if (e.key === "Escape") {
418
+ e.preventDefault();
419
+ this.close();
420
+ }
421
+
422
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
423
+ e.preventDefault();
424
+ this.undo();
425
+ }
426
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
427
+ e.preventDefault();
428
+ this.redo();
429
+ }
430
+
431
+ if (e.key === "Delete" || e.key === "Backspace") {
432
+ // avoid deleting while typing in contenteditable
433
+ const active = document.activeElement;
434
+ const isEditingText = active && active.getAttribute && active.getAttribute("contenteditable") === "true";
435
+ if (!isEditingText) this.deleteSelected();
436
+ }
437
+ });
438
+ }
439
+
440
+ // ---------- Core helpers ----------
441
+ show() {
442
+ this.$overlay.classList.remove("hidden");
443
+ this.state.isOpen = true;
444
+ this.updateAll();
445
+ }
446
+
447
+ hide() {
448
+ this.$overlay.classList.add("hidden");
449
+ this.state.isOpen = false;
450
+ }
451
+
452
+ setZoom(z) {
453
+ const clamped = Math.max(0.6, Math.min(1.4, Number(z.toFixed(2))));
454
+ this.state.zoom = clamped;
455
+ this.updateCanvasScale();
456
+ }
457
+
458
+ get activePage() {
459
+ return this.state.pages[this.state.activePage];
460
+ }
461
+
462
+ updateAll() {
463
+ this.updateToolbar();
464
+ this.renderPageList();
465
+ this.renderCanvas();
466
+ this.updateCanvasScale();
467
+ this.updatePropsPanel();
468
+ this.updateUndoRedoButtons();
469
+ this._refreshIcons();
470
+ }
471
+
472
+ updateToolbar() {
473
+ this.querySelectorAll(".toolBtn").forEach(btn => {
474
+ const active = btn.dataset.tool === this.state.tool;
475
+ btn.classList.toggle("bg-gray-900", active);
476
+ btn.classList.toggle("text-white", active);
477
+ btn.classList.toggle("border-gray-900", active);
478
+
479
+ if (!active) {
480
+ btn.classList.add("bg-white", "text-gray-800", "border-gray-200");
481
+ btn.classList.remove("bg-gray-900", "text-white", "border-gray-900");
482
+ } else {
483
+ btn.classList.remove("bg-white", "text-gray-800", "border-gray-200");
484
+ }
485
+ });
486
+ }
487
+
488
+ updateCanvasScale() {
489
+ if (!this.$canvas) return;
490
+ this.$canvas.style.transformOrigin = "top center";
491
+ this.$canvas.style.transform = `scale(${this.state.zoom})`;
492
+ this.$zoomLabel.textContent = `${Math.round(this.state.zoom * 100)}%`;
493
+ }
494
+
495
+ _refreshIcons() {
496
+ if (window.feather && typeof window.feather.replace === "function") {
497
+ window.feather.replace();
498
+ }
499
+ }
500
+
501
+ // ---------- Storage ----------
502
+ _storageKey() {
503
+ if (this.sessionId) {
504
+ return `repex_report_pages_v1_${this.sessionId}`;
505
+ }
506
+ return "repex_report_pages_v1";
507
+ }
508
+
509
+ _loadPages() {
510
+ try {
511
+ const raw = localStorage.getItem(this._storageKey());
512
+ return raw ? JSON.parse(raw) : null;
513
+ } catch {
514
+ return null;
515
+ }
516
+ }
517
+
518
+ _savePages(showToast = false) {
519
+ try {
520
+ localStorage.setItem(this._storageKey(), JSON.stringify({ pages: this.state.pages }));
521
+ this._scheduleServerSave();
522
+ if (showToast) this._toast("Saved");
523
+ } catch {
524
+ if (showToast) this._toast("Save failed");
525
+ }
526
+ }
527
+
528
+ _apiRoot() {
529
+ if (this.apiBase) return this.apiBase.replace(/\/$/, "");
530
+ if (window.REPEX && window.REPEX.apiBase) return window.REPEX.apiBase.replace(/\/$/, "");
531
+ return "";
532
+ }
533
+
534
+ async _loadPagesFromServer() {
535
+ const base = this._apiRoot();
536
+ if (!base || !this.sessionId) return null;
537
+ try {
538
+ const res = await fetch(`${base}/sessions/${this.sessionId}/pages`);
539
+ if (!res.ok) return null;
540
+ const data = await res.json();
541
+ if (data && Array.isArray(data.pages)) {
542
+ return data.pages;
543
+ }
544
+ } catch {}
545
+ return null;
546
+ }
547
+
548
+ _scheduleServerSave() {
549
+ if (!this.sessionId) return;
550
+ if (this._saveTimer) clearTimeout(this._saveTimer);
551
+ this._saveTimer = setTimeout(() => {
552
+ this._savePagesToServer();
553
+ }, 800);
554
+ }
555
+
556
+ async _savePagesToServer() {
557
+ const base = this._apiRoot();
558
+ if (!base || !this.sessionId) return;
559
+ try {
560
+ const res = await fetch(`${base}/sessions/${this.sessionId}/pages`, {
561
+ method: "PUT",
562
+ headers: { "Content-Type": "application/json" },
563
+ body: JSON.stringify({ pages: this.state.pages }),
564
+ });
565
+ if (!res.ok) {
566
+ throw new Error("Failed");
567
+ }
568
+ } catch {
569
+ this._toast("Sync failed");
570
+ }
571
+ }
572
+
573
+ _toast(text) {
574
+ const el = document.createElement("div");
575
+ 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";
576
+ el.textContent = text;
577
+ document.body.appendChild(el);
578
+ setTimeout(() => el.remove(), 1200);
579
+ }
580
+
581
+ // ---------- Page list ----------
582
+ renderPageList() {
583
+ this.$pageList.innerHTML = "";
584
+
585
+ this.state.pages.forEach((_, idx) => {
586
+ const active = idx === this.state.activePage;
587
+
588
+ const btn = document.createElement("button");
589
+ btn.type = "button";
590
+ btn.className =
591
+ "w-full text-left rounded-lg border px-3 py-2 transition " +
592
+ (active
593
+ ? "border-gray-900 bg-gray-900 text-white"
594
+ : "border-gray-200 bg-white text-gray-800 hover:bg-gray-50");
595
+ btn.innerHTML = `
596
+ <div class="flex items-center justify-between">
597
+ <div class="text-sm font-semibold">Page ${idx + 1}</div>
598
+ <div class="text-xs ${active ? "text-white/80" : "text-gray-500"}">${this.state.pages[idx].items.length} items</div>
599
+ </div>
600
+ `;
601
+ btn.addEventListener("click", () => {
602
+ this.state.activePage = idx;
603
+ this.state.selectedId = null;
604
+ this.state.undo = [];
605
+ this.state.redo = [];
606
+ this.updateAll();
607
+ });
608
+
609
+ this.$pageList.appendChild(btn);
610
+ });
611
+ }
612
+
613
+ addPage() {
614
+ this._pushUndoSnapshot();
615
+ this.state.pages.push({ items: [] });
616
+ this.state.activePage = this.state.pages.length - 1;
617
+ this.state.selectedId = null;
618
+ this._savePages();
619
+ this.updateAll();
620
+ }
621
+
622
+ // ---------- Canvas rendering ----------
623
+ renderCanvas() {
624
+ this.$canvas.innerHTML = "";
625
+
626
+ // Click-away surface
627
+ const surface = document.createElement("div");
628
+ surface.className = "absolute inset-0";
629
+ surface.addEventListener("pointerdown", (e) => {
630
+ // only clear selection if clicking empty space
631
+ if (e.target === surface) {
632
+ this.state.selectedId = null;
633
+ this.updatePropsPanel();
634
+ this.renderCanvas();
635
+ }
636
+ });
637
+ this.$canvas.appendChild(surface);
638
+
639
+ const items = this.activePage.items;
640
+ const selectedId = this.state.selectedId;
641
+
642
+ items
643
+ .slice()
644
+ .sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
645
+ .forEach(item => {
646
+ const wrapper = document.createElement("div");
647
+ wrapper.dataset.itemId = item.id;
648
+ wrapper.className = "absolute";
649
+
650
+ // scaled px placement based on model units
651
+ const scale = this._canvasScale();
652
+ wrapper.style.left = `${item.x * scale}px`;
653
+ wrapper.style.top = `${item.y * scale}px`;
654
+ wrapper.style.width = `${item.w * scale}px`;
655
+ wrapper.style.height = `${item.h * scale}px`;
656
+ wrapper.style.zIndex = String(item.z ?? 0);
657
+
658
+ const isSelected = selectedId === item.id;
659
+ if (isSelected) wrapper.classList.add("ring-2", "ring-blue-300");
660
+
661
+ // content
662
+ if (item.type === "text") {
663
+ const content = document.createElement("div");
664
+ content.className = "w-full h-full p-2 overflow-hidden";
665
+ content.setAttribute("contenteditable", "true");
666
+ content.style.fontSize = `${(item.style?.fontSize ?? 14) * scale}px`;
667
+ content.style.fontWeight = item.style?.bold ? "700" : "400";
668
+ content.style.fontStyle = item.style?.italic ? "italic" : "normal";
669
+ content.style.textDecoration = item.style?.underline ? "underline" : "none";
670
+ content.style.color = item.style?.color ?? "#111827";
671
+ content.style.textAlign = item.style?.align ?? "left";
672
+ content.style.whiteSpace = "pre-wrap";
673
+ content.style.outline = "none";
674
+ content.innerText = item.content ?? "Double-click to edit";
675
+
676
+ // update model when typing (debounced)
677
+ let t = null;
678
+ content.addEventListener("input", () => {
679
+ clearTimeout(t);
680
+ t = setTimeout(() => {
681
+ const it = this._findItem(item.id);
682
+ if (!it) return;
683
+ it.content = content.innerText;
684
+ this._savePages();
685
+ }, 250);
686
+ });
687
+
688
+ wrapper.appendChild(content);
689
+ }
690
+
691
+ if (item.type === "image") {
692
+ const img = document.createElement("img");
693
+ img.className = "w-full h-full object-contain bg-white";
694
+ img.src = item.src;
695
+ img.alt = item.name ?? "Image";
696
+ img.draggable = false;
697
+ wrapper.appendChild(img);
698
+ }
699
+
700
+ if (item.type === "rect") {
701
+ const box = document.createElement("div");
702
+ box.className = "w-full h-full";
703
+ box.style.background = item.style?.fill ?? "#ffffff";
704
+ box.style.borderColor = item.style?.stroke ?? "#111827";
705
+ box.style.borderWidth = `${(item.style?.strokeWidth ?? 1) * scale}px`;
706
+ box.style.borderStyle = "solid";
707
+ wrapper.appendChild(box);
708
+ }
709
+
710
+ // wrapper drag handler
711
+ wrapper.addEventListener("pointerdown", (e) => this.onItemPointerDown(e, item.id));
712
+
713
+ // resize handles (selected only)
714
+ if (isSelected) {
715
+ ["nw", "ne", "sw", "se"].forEach(handle => {
716
+ const h = document.createElement("div");
717
+ h.dataset.handle = handle;
718
+ h.className =
719
+ "absolute w-3 h-3 bg-white border border-blue-300 rounded-sm";
720
+ if (handle === "nw") { h.style.left = "-6px"; h.style.top = "-6px"; }
721
+ if (handle === "ne") { h.style.right = "-6px"; h.style.top = "-6px"; }
722
+ if (handle === "sw") { h.style.left = "-6px"; h.style.bottom = "-6px"; }
723
+ if (handle === "se") { h.style.right = "-6px"; h.style.bottom = "-6px"; }
724
+
725
+ h.style.cursor = `${handle}-resize`;
726
+ h.addEventListener("pointerdown", (e) => {
727
+ e.stopPropagation();
728
+ this.startResize(e, item.id, handle);
729
+ });
730
+ wrapper.appendChild(h);
731
+ });
732
+ }
733
+
734
+ this.$canvas.appendChild(wrapper);
735
+ });
736
+ }
737
+
738
+ _canvasScale() {
739
+ // actual displayed width divided by model width
740
+ const w = this.$canvas.clientWidth;
741
+ return w / this.BASE_W;
742
+ }
743
+
744
+ // ---------- Item creation ----------
745
+ onCanvasPointerDown(e) {
746
+ // prevent adding when clicking existing item
747
+ const hit = e.target.closest("[data-item-id]");
748
+ if (hit) return;
749
+
750
+ const { x, y } = this._eventToModelPoint(e);
751
+
752
+ if (this.state.tool === "text") {
753
+ this._pushUndoSnapshot();
754
+ const id = this._id();
755
+ this.activePage.items.push({
756
+ id,
757
+ type: "text",
758
+ x: this._clamp(x, 0, this.BASE_W - 200),
759
+ y: this._clamp(y, 0, this.BASE_H - 80),
760
+ w: 220,
761
+ h: 80,
762
+ z: this._maxZ() + 1,
763
+ content: "New text",
764
+ style: { fontSize: 14, bold: false, italic: false, underline: false, color: "#111827", align: "left" }
765
+ });
766
+ this.selectItem(id);
767
+ this._savePages();
768
+ this.renderCanvas();
769
+ this.updatePropsPanel();
770
+ return;
771
+ }
772
+
773
+ if (this.state.tool === "rect") {
774
+ this._pushUndoSnapshot();
775
+ const id = this._id();
776
+ this.activePage.items.push({
777
+ id,
778
+ type: "rect",
779
+ x: this._clamp(x, 0, this.BASE_W - 200),
780
+ y: this._clamp(y, 0, this.BASE_H - 120),
781
+ w: 220,
782
+ h: 120,
783
+ z: this._maxZ() + 1,
784
+ style: { fill: "#ffffff", stroke: "#111827", strokeWidth: 1 }
785
+ });
786
+ this.selectItem(id);
787
+ this._savePages();
788
+ this.renderCanvas();
789
+ this.updatePropsPanel();
790
+ return;
791
+ }
792
+
793
+ // select tool clicking empty space clears selection
794
+ if (this.state.tool === "select") {
795
+ this.state.selectedId = null;
796
+ this.updatePropsPanel();
797
+ this.renderCanvas();
798
+ }
799
+ }
800
+
801
+ _eventToModelPoint(e) {
802
+ const canvasRect = this.$canvas.getBoundingClientRect();
803
+ const scale = canvasRect.width / this.BASE_W;
804
+ const xPx = e.clientX - canvasRect.left;
805
+ const yPx = e.clientY - canvasRect.top;
806
+ return { x: xPx / scale, y: yPx / scale };
807
+ }
808
+
809
+ // ---------- Selection / Drag / Resize ----------
810
+ selectItem(id) {
811
+ this.state.selectedId = id;
812
+ this.updatePropsPanel();
813
+ this.renderCanvas();
814
+ }
815
+
816
+ onItemPointerDown(e, id) {
817
+ // ignore if resizing handle
818
+ if (e.target && e.target.dataset && e.target.dataset.handle) return;
819
+
820
+ // select
821
+ this.selectItem(id);
822
+
823
+ const isEditingText =
824
+ e.target &&
825
+ e.target.getAttribute &&
826
+ e.target.getAttribute("contenteditable") === "true";
827
+ if (isEditingText) return;
828
+
829
+ // start drag only when using select tool
830
+ if (this.state.tool !== "select") return;
831
+
832
+ this._pushUndoSnapshot();
833
+
834
+ const it = this._findItem(id);
835
+ if (!it) return;
836
+
837
+ const { x, y } = this._eventToModelPoint(e);
838
+ this.state.dragging = {
839
+ id,
840
+ startX: x,
841
+ startY: y,
842
+ origX: it.x,
843
+ origY: it.y
844
+ };
845
+
846
+ e.preventDefault();
847
+ }
848
+
849
+ startResize(e, id, handle) {
850
+ this._pushUndoSnapshot();
851
+
852
+ const it = this._findItem(id);
853
+ if (!it) return;
854
+
855
+ const { x, y } = this._eventToModelPoint(e);
856
+ this.state.resizing = {
857
+ id,
858
+ handle,
859
+ startX: x,
860
+ startY: y,
861
+ orig: { x: it.x, y: it.y, w: it.w, h: it.h }
862
+ };
863
+ e.preventDefault();
864
+ }
865
+
866
+ onPointerMove(e) {
867
+ if (!this.state.isOpen) return;
868
+
869
+ if (this.state.dragging) {
870
+ const d = this.state.dragging;
871
+ const it = this._findItem(d.id);
872
+ if (!it) return;
873
+
874
+ const { x, y } = this._eventToModelPoint(e);
875
+ const dx = x - d.startX;
876
+ const dy = y - d.startY;
877
+
878
+ it.x = this._clamp(d.origX + dx, 0, this.BASE_W - it.w);
879
+ it.y = this._clamp(d.origY + dy, 0, this.BASE_H - it.h);
880
+
881
+ this._savePages();
882
+ this.renderCanvas();
883
+ return;
884
+ }
885
+
886
+ if (this.state.resizing) {
887
+ const r = this.state.resizing;
888
+ const it = this._findItem(r.id);
889
+ if (!it) return;
890
+
891
+ const { x, y } = this._eventToModelPoint(e);
892
+ const dx = x - r.startX;
893
+ const dy = y - r.startY;
894
+
895
+ const o = r.orig;
896
+ const minW = 40, minH = 30;
897
+
898
+ let nx = o.x, ny = o.y, nw = o.w, nh = o.h;
899
+
900
+ if (r.handle.includes("e")) nw = this._clamp(o.w + dx, minW, this.BASE_W - o.x);
901
+ if (r.handle.includes("s")) nh = this._clamp(o.h + dy, minH, this.BASE_H - o.y);
902
+ if (r.handle.includes("w")) {
903
+ nw = this._clamp(o.w - dx, minW, o.w + o.x);
904
+ nx = this._clamp(o.x + dx, 0, o.x + o.w - minW);
905
+ }
906
+ if (r.handle.includes("n")) {
907
+ nh = this._clamp(o.h - dy, minH, o.h + o.y);
908
+ ny = this._clamp(o.y + dy, 0, o.y + o.h - minH);
909
+ }
910
+
911
+ it.x = nx; it.y = ny; it.w = nw; it.h = nh;
912
+
913
+ this._savePages();
914
+ this.renderCanvas();
915
+ }
916
+ }
917
+
918
+ onPointerUp() {
919
+ if (!this.state.isOpen) return;
920
+
921
+ if (this.state.dragging) {
922
+ this.state.dragging = null;
923
+ this.updateUndoRedoButtons();
924
+ }
925
+ if (this.state.resizing) {
926
+ this.state.resizing = null;
927
+ this.updateUndoRedoButtons();
928
+ }
929
+ }
930
+
931
+ // ---------- Properties panel ----------
932
+ updatePropsPanel() {
933
+ const it = this._findItem(this.state.selectedId);
934
+ const has = !!it;
935
+
936
+ this.$emptyProps.classList.toggle("hidden", has);
937
+ this.$props.classList.toggle("hidden", !has);
938
+
939
+ // hide all groups first
940
+ this.$propsText.classList.add("hidden");
941
+ this.$propsRect.classList.add("hidden");
942
+ this.$propsImage.classList.add("hidden");
943
+
944
+ if (!it) return;
945
+
946
+ if (it.type === "text") {
947
+ this.$propsText.classList.remove("hidden");
948
+ this.querySelector('[data-prop="fontSize"]').value = it.style?.fontSize ?? 14;
949
+ this.querySelector('[data-prop="color"]').value = it.style?.color ?? "#111827";
950
+ }
951
+
952
+ if (it.type === "rect") {
953
+ this.$propsRect.classList.remove("hidden");
954
+ this.querySelector('[data-prop="fill"]').value = it.style?.fill ?? "#ffffff";
955
+ this.querySelector('[data-prop="stroke"]').value = it.style?.stroke ?? "#111827";
956
+ this.querySelector('[data-prop="strokeWidth"]').value = it.style?.strokeWidth ?? 1;
957
+ }
958
+
959
+ if (it.type === "image") {
960
+ this.$propsImage.classList.remove("hidden");
961
+ }
962
+
963
+ this._refreshIcons();
964
+ }
965
+
966
+ setProp(key, value) {
967
+ const it = this._findItem(this.state.selectedId);
968
+ if (!it) return;
969
+
970
+ this._pushUndoSnapshot();
971
+
972
+ it.style = it.style || {};
973
+ it.style[key] = value;
974
+
975
+ this._savePages();
976
+ this.renderCanvas();
977
+ this.updatePropsPanel();
978
+ this.updateUndoRedoButtons();
979
+ }
980
+
981
+ toggleTextStyle(which) {
982
+ const it = this._findItem(this.state.selectedId);
983
+ if (!it || it.type !== "text") return;
984
+
985
+ this._pushUndoSnapshot();
986
+
987
+ it.style = it.style || {};
988
+ if (which === "bold") it.style.bold = !it.style.bold;
989
+ if (which === "italic") it.style.italic = !it.style.italic;
990
+ if (which === "underline") it.style.underline = !it.style.underline;
991
+
992
+ this._savePages();
993
+ this.renderCanvas();
994
+ this.updateUndoRedoButtons();
995
+ }
996
+
997
+ setTextAlign(align) {
998
+ const it = this._findItem(this.state.selectedId);
999
+ if (!it || it.type !== "text") return;
1000
+ this.setProp("align", align);
1001
+ }
1002
+
1003
+ // ---------- Arrange ----------
1004
+ bringFront() {
1005
+ const it = this._findItem(this.state.selectedId);
1006
+ if (!it) return;
1007
+ this._pushUndoSnapshot();
1008
+ it.z = this._maxZ() + 1;
1009
+ this._savePages();
1010
+ this.renderCanvas();
1011
+ this.updateUndoRedoButtons();
1012
+ }
1013
+
1014
+ sendBack() {
1015
+ const it = this._findItem(this.state.selectedId);
1016
+ if (!it) return;
1017
+ this._pushUndoSnapshot();
1018
+ it.z = this._minZ() - 1;
1019
+ this._savePages();
1020
+ this.renderCanvas();
1021
+ this.updateUndoRedoButtons();
1022
+ }
1023
+
1024
+ duplicateSelected() {
1025
+ const it = this._findItem(this.state.selectedId);
1026
+ if (!it) return;
1027
+ this._pushUndoSnapshot();
1028
+
1029
+ const copy = JSON.parse(JSON.stringify(it));
1030
+ copy.id = this._id();
1031
+ copy.x = this._clamp(copy.x + 12, 0, this.BASE_W - copy.w);
1032
+ copy.y = this._clamp(copy.y + 12, 0, this.BASE_H - copy.h);
1033
+ copy.z = this._maxZ() + 1;
1034
+
1035
+ this.activePage.items.push(copy);
1036
+ this.state.selectedId = copy.id;
1037
+
1038
+ this._savePages();
1039
+ this.updateAll();
1040
+ this.updateUndoRedoButtons();
1041
+ }
1042
+
1043
+ deleteSelected() {
1044
+ const id = this.state.selectedId;
1045
+ if (!id) return;
1046
+
1047
+ this._pushUndoSnapshot();
1048
+
1049
+ this.activePage.items = this.activePage.items.filter(x => x.id !== id);
1050
+ this.state.selectedId = null;
1051
+
1052
+ this._savePages();
1053
+ this.updateAll();
1054
+ this.updateUndoRedoButtons();
1055
+ }
1056
+
1057
+ // ---------- Images ----------
1058
+ _handleImageUpload(e, mode) {
1059
+ const file = e.target.files && e.target.files[0];
1060
+ if (!file) return;
1061
+
1062
+ const reader = new FileReader();
1063
+ reader.onload = () => {
1064
+ if (mode === "add") {
1065
+ this._pushUndoSnapshot();
1066
+ const id = this._id();
1067
+ const w = 260, h = 180;
1068
+ this.activePage.items.push({
1069
+ id,
1070
+ type: "image",
1071
+ x: (this.BASE_W - w) / 2,
1072
+ y: (this.BASE_H - h) / 2,
1073
+ w, h,
1074
+ z: this._maxZ() + 1,
1075
+ src: reader.result,
1076
+ name: file.name
1077
+ });
1078
+ this.selectItem(id);
1079
+ this._savePages();
1080
+ this.updateAll();
1081
+ this.updateUndoRedoButtons();
1082
+ }
1083
+
1084
+ if (mode === "replace") {
1085
+ const it = this._findItem(this.state.selectedId);
1086
+ if (!it || it.type !== "image") return;
1087
+ this._pushUndoSnapshot();
1088
+ it.src = reader.result;
1089
+ it.name = file.name;
1090
+ this._savePages();
1091
+ this.updateAll();
1092
+ this.updateUndoRedoButtons();
1093
+ }
1094
+ };
1095
+ reader.readAsDataURL(file);
1096
+
1097
+ // reset input
1098
+ e.target.value = "";
1099
+ }
1100
+
1101
+ // ---------- Undo / redo ----------
1102
+ _pushUndoSnapshot() {
1103
+ // store snapshot of active page items
1104
+ const snap = JSON.stringify(this.activePage.items);
1105
+ const last = this.state.undo[this.state.undo.length - 1];
1106
+ if (last !== snap) this.state.undo.push(snap);
1107
+ // clear redo on new change
1108
+ this.state.redo = [];
1109
+ this.updateUndoRedoButtons();
1110
+ }
1111
+
1112
+ undo() {
1113
+ if (!this.state.undo.length) return;
1114
+
1115
+ const current = JSON.stringify(this.activePage.items);
1116
+ const prev = this.state.undo.pop();
1117
+ this.state.redo.push(current);
1118
+
1119
+ // restore prev
1120
+ try {
1121
+ this.activePage.items = JSON.parse(prev);
1122
+ } catch {}
1123
+ this.state.selectedId = null;
1124
+
1125
+ this._savePages();
1126
+ this.updateAll();
1127
+ }
1128
+
1129
+ redo() {
1130
+ if (!this.state.redo.length) return;
1131
+
1132
+ const current = JSON.stringify(this.activePage.items);
1133
+ const next = this.state.redo.pop();
1134
+ this.state.undo.push(current);
1135
+
1136
+ try {
1137
+ this.activePage.items = JSON.parse(next);
1138
+ } catch {}
1139
+ this.state.selectedId = null;
1140
+
1141
+ this._savePages();
1142
+ this.updateAll();
1143
+ }
1144
+
1145
+ updateUndoRedoButtons() {
1146
+ const undoBtn = this.querySelector('[data-btn="undo"]');
1147
+ const redoBtn = this.querySelector('[data-btn="redo"]');
1148
+ if (undoBtn) undoBtn.disabled = this.state.undo.length === 0;
1149
+ if (redoBtn) redoBtn.disabled = this.state.redo.length === 0;
1150
+ }
1151
+
1152
+ // ---------- Utils ----------
1153
+ _findItem(id) {
1154
+ if (!id) return null;
1155
+ return this.activePage.items.find(x => x.id === id) || null;
1156
+ }
1157
+
1158
+ _maxZ() {
1159
+ const items = this.activePage.items;
1160
+ return items.length ? Math.max(...items.map(i => i.z ?? 0)) : 0;
1161
+ }
1162
+
1163
+ _minZ() {
1164
+ const items = this.activePage.items;
1165
+ return items.length ? Math.min(...items.map(i => i.z ?? 0)) : 0;
1166
+ }
1167
+
1168
+ _id() {
1169
+ return "it_" + Math.random().toString(16).slice(2) + "_" + Date.now().toString(16);
1170
+ }
1171
+
1172
+ _clamp(n, a, b) {
1173
+ return Math.max(a, Math.min(b, n));
1174
+ }
1175
+ }
1176
+
1177
+ customElements.define("report-editor", ReportEditor);
frontend/src/index.css CHANGED
@@ -1,21 +1,26 @@
 
 
 
 
1
  * {
2
  box-sizing: border-box;
3
  }
4
 
5
- html,
6
- body,
7
- #root {
8
- width: 100%;
9
- height: 100%;
10
- margin: 0;
11
  }
12
 
13
  body {
14
- background: #000000;
15
  }
16
 
17
- .black-screen {
18
- width: 100%;
19
- height: 100%;
20
- background: #000000;
 
 
 
 
21
  }
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
  * {
6
  box-sizing: border-box;
7
  }
8
 
9
+ img {
10
+ -webkit-print-color-adjust: exact;
11
+ print-color-adjust: exact;
 
 
 
12
  }
13
 
14
  body {
15
+ @apply bg-gray-50 text-gray-900;
16
  }
17
 
18
+ @media print {
19
+ body {
20
+ background: #ffffff;
21
+ }
22
+
23
+ .no-print {
24
+ display: none !important;
25
+ }
26
  }
frontend/src/lib/api.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const BASE =
2
+ (import.meta.env.VITE_API_BASE as string | undefined) || "/api";
3
+ export const API_BASE = BASE.replace(/\/$/, "");
4
+
5
+ async function parseError(res: Response): Promise<string> {
6
+ try {
7
+ const data = await res.json();
8
+ return data?.detail || res.statusText;
9
+ } catch {
10
+ return res.statusText;
11
+ }
12
+ }
13
+
14
+ export async function request<T>(
15
+ path: string,
16
+ options: RequestInit = {},
17
+ ): Promise<T> {
18
+ const url = `${API_BASE}${path}`;
19
+ const res = await fetch(url, {
20
+ credentials: "same-origin",
21
+ ...options,
22
+ });
23
+ if (!res.ok) {
24
+ throw new Error(await parseError(res));
25
+ }
26
+ if (res.status === 204) return null as T;
27
+ const contentType = res.headers.get("content-type") || "";
28
+ if (contentType.includes("application/json")) {
29
+ return (await res.json()) as T;
30
+ }
31
+ return (await res.text()) as T;
32
+ }
33
+
34
+ export async function postForm<T>(path: string, data: FormData): Promise<T> {
35
+ return request<T>(path, { method: "POST", body: data });
36
+ }
37
+
38
+ export async function postJson<T>(
39
+ path: string,
40
+ body: unknown,
41
+ ): Promise<T> {
42
+ return request<T>(path, {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json" },
45
+ body: JSON.stringify(body),
46
+ });
47
+ }
48
+
49
+ export async function putJson<T>(path: string, body: unknown): Promise<T> {
50
+ return request<T>(path, {
51
+ method: "PUT",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify(body),
54
+ });
55
+ }
frontend/src/lib/format.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function formatBytes(bytes: number): string {
2
+ if (!Number.isFinite(bytes)) return "0 B";
3
+ const units = ["B", "KB", "MB", "GB"];
4
+ let value = bytes;
5
+ let idx = 0;
6
+ while (value >= 1024 && idx < units.length - 1) {
7
+ value /= 1024;
8
+ idx += 1;
9
+ }
10
+ const digits = value >= 10 || idx === 0 ? 0 : 1;
11
+ return `${value.toFixed(digits)} ${units[idx]}`;
12
+ }
frontend/src/lib/session.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const SESSION_KEY = "repex_session_id";
2
+
3
+ export function getStoredSessionId(): string {
4
+ return localStorage.getItem(SESSION_KEY) || "";
5
+ }
6
+
7
+ export function setStoredSessionId(id: string): void {
8
+ if (!id) return;
9
+ localStorage.setItem(SESSION_KEY, id);
10
+ }
11
+
12
+ export function clearStoredSessionId(): void {
13
+ localStorage.removeItem(SESSION_KEY);
14
+ }
15
+
16
+ export function getSessionIdFromSearch(search: string): string {
17
+ const params = new URLSearchParams(search);
18
+ return params.get("session") || "";
19
+ }
20
+
21
+ export function getSessionId(search: string): string {
22
+ return getSessionIdFromSearch(search) || getStoredSessionId();
23
+ }
24
+
25
+ export function buildSessionQuery(sessionId: string): string {
26
+ return sessionId ? `?session=${encodeURIComponent(sessionId)}` : "";
27
+ }
frontend/src/main.tsx CHANGED
@@ -2,6 +2,7 @@ import React from "react";
2
  import ReactDOM from "react-dom/client";
3
  import App from "./App";
4
  import "./index.css";
 
5
 
6
  ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
7
  <React.StrictMode>
 
2
  import ReactDOM from "react-dom/client";
3
  import App from "./App";
4
  import "./index.css";
5
+ import "./components/report-editor";
6
 
7
  ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
8
  <React.StrictMode>
frontend/src/pages/EditLayoutsPage.tsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link, useSearchParams } from "react-router-dom";
2
+ import { ArrowLeft } from "react-feather";
3
+
4
+ import { buildSessionQuery, getSessionId } from "../lib/session";
5
+ import { PageFooter } from "../components/PageFooter";
6
+ import { PageHeader } from "../components/PageHeader";
7
+ import { PageShell } from "../components/PageShell";
8
+
9
+ export default function EditLayoutsPage() {
10
+ const [searchParams] = useSearchParams();
11
+ const sessionId = getSessionId(searchParams.toString());
12
+ const sessionQuery = buildSessionQuery(sessionId);
13
+
14
+ return (
15
+ <PageShell>
16
+ <PageHeader
17
+ title="RepEx - Report Express"
18
+ subtitle="Edit Page Layouts"
19
+ right={
20
+ <Link
21
+ to={`/report-viewer${sessionQuery}`}
22
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
23
+ >
24
+ <ArrowLeft className="h-4 w-4" />
25
+ Back
26
+ </Link>
27
+ }
28
+ />
29
+
30
+ <section className="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center">
31
+ <h2 className="text-xl font-semibold text-gray-900 mb-2">
32
+ Edit Page Layouts (Coming Soon)
33
+ </h2>
34
+ <p className="text-sm text-gray-600 mb-4">
35
+ Layout tools will be added in the next iteration. For now, continue editing pages
36
+ from the report viewer.
37
+ </p>
38
+ <Link
39
+ to={`/report-viewer${sessionQuery}`}
40
+ className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 text-white font-semibold hover:bg-blue-700 transition"
41
+ >
42
+ Return to Report Viewer
43
+ </Link>
44
+ </section>
45
+
46
+ <PageFooter note="Layout tools are on the roadmap for the next release." />
47
+ </PageShell>
48
+ );
49
+ }
frontend/src/pages/ExportPage.tsx ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { Link, useSearchParams } from "react-router-dom";
3
+ import { ArrowLeft, Download, Grid, Layout } from "react-feather";
4
+
5
+ import { request } from "../lib/api";
6
+ import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
7
+ import type { Page, Session } from "../types/session";
8
+ import { PageFooter } from "../components/PageFooter";
9
+ import { PageHeader } from "../components/PageHeader";
10
+ import { PageShell } from "../components/PageShell";
11
+
12
+ export default function ExportPage() {
13
+ const [searchParams] = useSearchParams();
14
+ const sessionId = getSessionId(searchParams.toString());
15
+ const sessionQuery = buildSessionQuery(sessionId);
16
+
17
+ const [session, setSession] = useState<Session | null>(null);
18
+ const [pages, setPages] = useState<Page[]>([]);
19
+ const [error, setError] = useState("");
20
+
21
+ const [incPages, setIncPages] = useState(true);
22
+ const [incLayout, setIncLayout] = useState(true);
23
+ const [incPayload, setIncPayload] = useState(true);
24
+ const [incTimestamp, setIncTimestamp] = useState(true);
25
+
26
+ useEffect(() => {
27
+ if (!sessionId) {
28
+ setError("No active session.");
29
+ return;
30
+ }
31
+ setStoredSessionId(sessionId);
32
+ async function load() {
33
+ try {
34
+ const data = await request<Session>(`/sessions/${sessionId}`);
35
+ setSession(data);
36
+ const pageResp = await request<{ pages: Page[] }>(
37
+ `/sessions/${sessionId}/pages`,
38
+ );
39
+ setPages(Array.isArray(pageResp.pages) ? pageResp.pages : []);
40
+ } catch (err) {
41
+ const message =
42
+ err instanceof Error ? err.message : "Failed to load session.";
43
+ setError(message);
44
+ }
45
+ }
46
+ load();
47
+ }, [sessionId]);
48
+
49
+ const totals = useMemo(() => {
50
+ const totalItems = pages.reduce(
51
+ (acc, page) => acc + (page?.items?.length ?? 0),
52
+ 0,
53
+ );
54
+ return {
55
+ pages: pages.length,
56
+ items: totalItems,
57
+ photos: session?.selected_photo_ids?.length ?? 0,
58
+ docs: session?.uploads?.documents?.length ?? 0,
59
+ data: session?.uploads?.data_files?.length ?? 0,
60
+ };
61
+ }, [pages, session]);
62
+
63
+ function downloadJson() {
64
+ const pack: Record<string, unknown> = {};
65
+ if (incPages) pack.pages = pages;
66
+ if (incLayout) pack.layout = (session as Session | null)?.["layout"] ?? null;
67
+ if (incPayload) pack.payload = session;
68
+ if (incTimestamp) pack.exportedAt = new Date().toISOString();
69
+
70
+ const blob = new Blob([JSON.stringify(pack, null, 2)], {
71
+ type: "application/json",
72
+ });
73
+ const url = URL.createObjectURL(blob);
74
+ const link = document.createElement("a");
75
+ link.href = url;
76
+ link.download = `repex_report_package_${new Date()
77
+ .toISOString()
78
+ .replace(/[:.]/g, "-")}.json`;
79
+ document.body.appendChild(link);
80
+ link.click();
81
+ link.remove();
82
+ URL.revokeObjectURL(url);
83
+ }
84
+
85
+ return (
86
+ <PageShell>
87
+ <PageHeader
88
+ title="RepEx - Report Express"
89
+ subtitle="Export"
90
+ right={
91
+ <Link
92
+ to={`/report-viewer${sessionQuery}`}
93
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
94
+ >
95
+ <ArrowLeft className="h-4 w-4" />
96
+ Back
97
+ </Link>
98
+ }
99
+ />
100
+
101
+ <nav className="mb-6 no-print" aria-label="Report workflow navigation">
102
+ <div className="flex flex-wrap gap-2">
103
+ <Link
104
+ to={`/report-viewer${sessionQuery}`}
105
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
106
+ >
107
+ <Layout className="h-4 w-4" />
108
+ Report Viewer
109
+ </Link>
110
+
111
+ <Link
112
+ to={`/edit-layouts${sessionQuery}`}
113
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
114
+ >
115
+ <Grid className="h-4 w-4" />
116
+ Edit Page Layouts
117
+ </Link>
118
+
119
+ <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
120
+ <Download className="h-4 w-4" />
121
+ Export
122
+ </span>
123
+ </div>
124
+ </nav>
125
+
126
+ <section className="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-6">
127
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
128
+ <h2 className="text-lg font-semibold text-gray-900 mb-2">Export Options</h2>
129
+ <p className="text-sm text-gray-600 mb-4">
130
+ PDF export comes next. For now, you can export a report package as JSON
131
+ (pages + layout settings + payload).
132
+ </p>
133
+
134
+ <div className="space-y-4">
135
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
136
+ <div className="flex items-start justify-between gap-3">
137
+ <div>
138
+ <div className="text-sm font-semibold text-gray-900">
139
+ Report package (.json)
140
+ </div>
141
+ <div className="text-xs text-gray-500">
142
+ Includes edited pages, layout settings and upload metadata
143
+ </div>
144
+ </div>
145
+ <span className="text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-md px-2 py-1">
146
+ Available
147
+ </span>
148
+ </div>
149
+
150
+ <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
151
+ <label className="inline-flex items-center gap-2 text-sm text-gray-700">
152
+ <input
153
+ type="checkbox"
154
+ className="rounded border-gray-300"
155
+ checked={incPages}
156
+ onChange={(event) => setIncPages(event.target.checked)}
157
+ />
158
+ Include pages
159
+ </label>
160
+ <label className="inline-flex items-center gap-2 text-sm text-gray-700">
161
+ <input
162
+ type="checkbox"
163
+ className="rounded border-gray-300"
164
+ checked={incLayout}
165
+ onChange={(event) => setIncLayout(event.target.checked)}
166
+ />
167
+ Include layout settings
168
+ </label>
169
+ <label className="inline-flex items-center gap-2 text-sm text-gray-700">
170
+ <input
171
+ type="checkbox"
172
+ className="rounded border-gray-300"
173
+ checked={incPayload}
174
+ onChange={(event) => setIncPayload(event.target.checked)}
175
+ />
176
+ Include upload payload
177
+ </label>
178
+ <label className="inline-flex items-center gap-2 text-sm text-gray-700">
179
+ <input
180
+ type="checkbox"
181
+ className="rounded border-gray-300"
182
+ checked={incTimestamp}
183
+ onChange={(event) => setIncTimestamp(event.target.checked)}
184
+ />
185
+ Include timestamp
186
+ </label>
187
+ </div>
188
+
189
+ <button
190
+ type="button"
191
+ onClick={downloadJson}
192
+ disabled={!sessionId}
193
+ className="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2.5 text-white font-semibold hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
194
+ >
195
+ <Download className="h-4 w-4" />
196
+ Download JSON package
197
+ </button>
198
+ </div>
199
+
200
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
201
+ <div className="flex items-start justify-between gap-3">
202
+ <div>
203
+ <div className="text-sm font-semibold text-gray-900">PDF export</div>
204
+ <div className="text-xs text-gray-500">
205
+ Will export as print-ready A4 PDF
206
+ </div>
207
+ </div>
208
+ <span className="text-xs font-semibold text-gray-600 bg-white border border-gray-200 rounded-md px-2 py-1">
209
+ Coming soon
210
+ </span>
211
+ </div>
212
+
213
+ <button
214
+ type="button"
215
+ disabled
216
+ className="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2.5 text-gray-500 font-semibold cursor-not-allowed"
217
+ >
218
+ Export PDF (disabled)
219
+ </button>
220
+ </div>
221
+ </div>
222
+ </div>
223
+
224
+ <aside className="rounded-lg border border-gray-200 bg-white p-4">
225
+ <h2 className="text-lg font-semibold text-gray-900 mb-2">Export Summary</h2>
226
+ <p className="text-sm text-gray-600 mb-4">
227
+ This is what will be included based on your current session.
228
+ </p>
229
+
230
+ <div className="space-y-3">
231
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
232
+ <div className="text-xs font-semibold text-gray-600">Pages</div>
233
+ <div className="text-sm font-semibold text-gray-900">
234
+ {totals.pages
235
+ ? `${totals.pages} pages - ${totals.items} total items`
236
+ : "No saved pages yet"}
237
+ </div>
238
+ </div>
239
+
240
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
241
+ <div className="text-xs font-semibold text-gray-600">
242
+ Selected example photos
243
+ </div>
244
+ <div className="text-sm font-semibold text-gray-900">
245
+ {totals.photos}
246
+ </div>
247
+ </div>
248
+
249
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
250
+ <div className="text-xs font-semibold text-gray-600">Documents</div>
251
+ <div className="text-sm font-semibold text-gray-900">
252
+ {totals.docs}
253
+ </div>
254
+ </div>
255
+
256
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
257
+ <div className="text-xs font-semibold text-gray-600">Data files</div>
258
+ <div className="text-sm font-semibold text-gray-900">
259
+ {totals.data}
260
+ </div>
261
+ </div>
262
+
263
+ <div className="text-xs text-gray-500">Stored in the active server session.</div>
264
+ </div>
265
+
266
+ {error ? <p className="text-sm text-red-600 mt-3">{error}</p> : null}
267
+ </aside>
268
+ </section>
269
+
270
+ <PageFooter note="Export: JSON package now; PDF export will be added next." />
271
+ </PageShell>
272
+ );
273
+ }
frontend/src/pages/ProcessingPage.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { useNavigate, useSearchParams } from "react-router-dom";
3
+ import { Loader } from "react-feather";
4
+
5
+ import { request } from "../lib/api";
6
+ import { getSessionId, setStoredSessionId } from "../lib/session";
7
+ import type { SessionStatus } from "../types/session";
8
+
9
+ export default function ProcessingPage() {
10
+ const navigate = useNavigate();
11
+ const [searchParams] = useSearchParams();
12
+ const [message, setMessage] = useState("");
13
+
14
+ const sessionId = getSessionId(searchParams.toString());
15
+
16
+ useEffect(() => {
17
+ if (!sessionId) {
18
+ setMessage("No active session found. Return to the upload page.");
19
+ return;
20
+ }
21
+ setStoredSessionId(sessionId);
22
+
23
+ let active = true;
24
+ const poll = async () => {
25
+ try {
26
+ const status = await request<SessionStatus>(
27
+ `/sessions/${sessionId}/status`,
28
+ );
29
+ if (!active) return;
30
+ if (status?.status === "ready") {
31
+ navigate(`/review-setup?session=${encodeURIComponent(sessionId)}`);
32
+ }
33
+ } catch {
34
+ // keep polling
35
+ }
36
+ };
37
+
38
+ poll();
39
+ const timer = setInterval(poll, 2000);
40
+ return () => {
41
+ active = false;
42
+ clearInterval(timer);
43
+ };
44
+ }, [navigate, sessionId]);
45
+
46
+ return (
47
+ <main className="max-w-2xl mx-auto my-10 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
48
+ <header className="flex items-center gap-4 border-b border-gray-200 pb-4 mb-6">
49
+ <img
50
+ src="/assets/prosento-logo.png"
51
+ alt="Prosento logo"
52
+ className="h-10 w-auto object-contain"
53
+ loading="eager"
54
+ />
55
+ <div>
56
+ <h1 className="text-xl font-semibold text-gray-900">Prosento RepEx</h1>
57
+ <p className="text-sm text-gray-600">Report processing</p>
58
+ </div>
59
+ </header>
60
+
61
+ <section className="text-center">
62
+ <div className="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
63
+ <Loader className="h-5 w-5 text-blue-700 animate-spin" />
64
+ </div>
65
+ <h2 className="text-2xl font-semibold text-gray-900 mb-2">
66
+ Generating your report
67
+ </h2>
68
+ <p className="text-gray-600 mb-6">
69
+ We are preparing your files and building the final report. This may take a few minutes.
70
+ </p>
71
+
72
+ <div className="w-full rounded-full bg-gray-200 h-3 overflow-hidden">
73
+ <div className="h-full bg-blue-600 w-1/2 animate-pulse" />
74
+ </div>
75
+
76
+ <p className="text-xs text-gray-500 mt-4">
77
+ You can leave this tab open while we process your submission.
78
+ </p>
79
+
80
+ {message ? (
81
+ <p className="text-sm text-red-600 mt-6">{message}</p>
82
+ ) : null}
83
+ </section>
84
+ </main>
85
+ );
86
+ }
frontend/src/pages/ReportViewerPage.tsx ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CSSProperties } from "react";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { Link, useSearchParams } from "react-router-dom";
4
+ import {
5
+ ArrowLeft,
6
+ ChevronLeft,
7
+ ChevronRight,
8
+ Layout,
9
+ Edit3,
10
+ Grid,
11
+ Download,
12
+ } from "react-feather";
13
+
14
+ import { API_BASE, request } from "../lib/api";
15
+ import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
16
+ import type { Page, Session } from "../types/session";
17
+
18
+ const BASE_W = 595;
19
+
20
+ export default function ReportViewerPage() {
21
+ const [searchParams] = useSearchParams();
22
+ const sessionId = getSessionId(searchParams.toString());
23
+
24
+ const [session, setSession] = useState<Session | null>(null);
25
+ const [pages, setPages] = useState<Page[]>([]);
26
+ const [pageIndex, setPageIndex] = useState(0);
27
+ const [totalPages, setTotalPages] = useState(6);
28
+ const [scale, setScale] = useState(1);
29
+ const [editMode, setEditMode] = useState(false);
30
+ const [error, setError] = useState("");
31
+
32
+ const stageRef = useRef<HTMLDivElement | null>(null);
33
+ const editorRef = useRef<ReportEditorElement | null>(null);
34
+
35
+ useEffect(() => {
36
+ if (!sessionId) {
37
+ setError("No active session found. Return to upload to continue.");
38
+ return;
39
+ }
40
+ setStoredSessionId(sessionId);
41
+ }, [sessionId]);
42
+
43
+ useEffect(() => {
44
+ const handleResize = () => {
45
+ if (!stageRef.current) return;
46
+ const width = stageRef.current.clientWidth;
47
+ setScale(width / BASE_W);
48
+ };
49
+ handleResize();
50
+ window.addEventListener("resize", handleResize);
51
+ return () => window.removeEventListener("resize", handleResize);
52
+ }, []);
53
+
54
+ useEffect(() => {
55
+ if (!sessionId) return;
56
+ async function load() {
57
+ try {
58
+ const data = await request<Session>(`/sessions/${sessionId}`);
59
+ setSession(data);
60
+ setTotalPages(Math.max(data.page_count || 0, 1));
61
+ const pageResp = await request<{ pages: Page[] }>(
62
+ `/sessions/${sessionId}/pages`,
63
+ );
64
+ const loaded = Array.isArray(pageResp.pages) ? pageResp.pages : [];
65
+ setPages(loaded);
66
+ if (loaded.length) {
67
+ setTotalPages(Math.max(data.page_count || 0, loaded.length, 1));
68
+ }
69
+ } catch (err) {
70
+ const message =
71
+ err instanceof Error ? err.message : "Failed to load session.";
72
+ setError(message);
73
+ }
74
+ }
75
+ load();
76
+ }, [sessionId]);
77
+
78
+ useEffect(() => {
79
+ const handler = (event: KeyboardEvent) => {
80
+ if (editMode) return;
81
+ if (event.key === "ArrowRight") {
82
+ setPageIndex((idx) => Math.min(totalPages - 1, idx + 1));
83
+ }
84
+ if (event.key === "ArrowLeft") {
85
+ setPageIndex((idx) => Math.max(0, idx - 1));
86
+ }
87
+ };
88
+ window.addEventListener("keydown", handler);
89
+ return () => window.removeEventListener("keydown", handler);
90
+ }, [editMode, totalPages]);
91
+
92
+ useEffect(() => {
93
+ const editor = editorRef.current;
94
+ if (!editor) return;
95
+ const closeHandler = () => {
96
+ setEditMode(false);
97
+ if (sessionId) {
98
+ request<{ pages: Page[] }>(`/sessions/${sessionId}/pages`)
99
+ .then((resp) => {
100
+ const loaded = Array.isArray(resp.pages) ? resp.pages : [];
101
+ setPages(loaded);
102
+ })
103
+ .catch(() => {});
104
+ }
105
+ };
106
+ editor.addEventListener("editor-closed", closeHandler);
107
+ return () => editor.removeEventListener("editor-closed", closeHandler);
108
+ }, [sessionId]);
109
+
110
+ const pageItems = pages[pageIndex]?.items ?? [];
111
+ const hasItems = pageItems.length > 0;
112
+ const sessionQuery = buildSessionQuery(sessionId || "");
113
+
114
+ const viewerMeta = useMemo(() => {
115
+ if (!session) return "Loading...";
116
+ const selected = session.selected_photo_ids?.length ?? 0;
117
+ const docs = session.uploads?.documents?.length ?? 0;
118
+ const dataFiles = session.uploads?.data_files?.length ?? 0;
119
+ const hasEdits = pages.length > 0;
120
+ return (
121
+ `Selected example photos: ${selected} - Documents: ${docs} - Data files: ${dataFiles}` +
122
+ (hasEdits ? " - Edited pages loaded" : " - No saved edits yet")
123
+ );
124
+ }, [pages.length, session]);
125
+
126
+ function openEditor() {
127
+ if (!editorRef.current || !sessionId || !session) return;
128
+ setEditMode(true);
129
+ editorRef.current.open({
130
+ payload: session,
131
+ pageIndex,
132
+ totalPages,
133
+ sessionId,
134
+ apiBase: API_BASE,
135
+ });
136
+ }
137
+
138
+ return (
139
+ <main className="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8 print:ring-0 print:shadow-none print:rounded-none">
140
+ <header className="mb-8 border-b border-gray-200 pb-4">
141
+ <div className="grid grid-cols-[auto,1fr,auto] items-center gap-4">
142
+ <div className="flex items-center">
143
+ <img
144
+ src="/assets/prosento-logo.png"
145
+ alt="Company logo"
146
+ className="h-12 w-auto object-contain"
147
+ loading="eager"
148
+ />
149
+ </div>
150
+
151
+ <div className="text-center">
152
+ <h1 className="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
153
+ RepEx - Report Express
154
+ </h1>
155
+ <p className="text-gray-600 whitespace-nowrap">Report Viewer</p>
156
+ </div>
157
+
158
+ <div className="flex justify-end gap-2 no-print">
159
+ <Link
160
+ to={`/review-setup${sessionQuery}`}
161
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
162
+ >
163
+ <ArrowLeft className="h-4 w-4" />
164
+ Back
165
+ </Link>
166
+ </div>
167
+ </div>
168
+ </header>
169
+
170
+ <nav className="mb-6 no-print" aria-label="Report workflow navigation">
171
+ <div className="flex flex-wrap gap-2">
172
+ <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
173
+ <Layout className="h-4 w-4" />
174
+ Report Viewer
175
+ </span>
176
+
177
+ <button
178
+ type="button"
179
+ onClick={openEditor}
180
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
181
+ >
182
+ <Edit3 className="h-4 w-4" />
183
+ Edit Report
184
+ </button>
185
+
186
+ <Link
187
+ to={`/edit-layouts${sessionQuery}`}
188
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
189
+ >
190
+ <Grid className="h-4 w-4" />
191
+ Edit Page Layouts
192
+ </Link>
193
+
194
+ <Link
195
+ to={`/export${sessionQuery}`}
196
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
197
+ >
198
+ <Download className="h-4 w-4" />
199
+ Export
200
+ </Link>
201
+ </div>
202
+ </nav>
203
+
204
+ <section
205
+ id="viewerSection"
206
+ aria-label="Report viewer"
207
+ className={editMode ? "hidden" : ""}
208
+ >
209
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
210
+ <div>
211
+ <h2 className="text-xl font-semibold text-gray-800">Report Pages</h2>
212
+ <p className="text-sm text-gray-600">{viewerMeta}</p>
213
+ </div>
214
+
215
+ <div className="flex items-center gap-2 no-print">
216
+ <button
217
+ type="button"
218
+ onClick={() => setPageIndex((idx) => Math.max(0, idx - 1))}
219
+ disabled={pageIndex === 0}
220
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
221
+ >
222
+ <ChevronLeft className="h-4 w-4" />
223
+ Prev
224
+ </button>
225
+
226
+ <div className="text-sm font-semibold text-gray-700">
227
+ Page <span>{pageIndex + 1}</span> / <span>{totalPages}</span>
228
+ </div>
229
+
230
+ <button
231
+ type="button"
232
+ onClick={() =>
233
+ setPageIndex((idx) => Math.min(totalPages - 1, idx + 1))
234
+ }
235
+ disabled={pageIndex >= totalPages - 1}
236
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
237
+ >
238
+ Next
239
+ <ChevronRight className="h-4 w-4" />
240
+ </button>
241
+ </div>
242
+ </div>
243
+
244
+ <div className="flex justify-center">
245
+ <div
246
+ ref={stageRef}
247
+ className={[
248
+ "relative overflow-hidden shadow-sm rounded-xl",
249
+ hasItems ? "bg-white border border-gray-200" : "bg-red-100 border border-red-300",
250
+ ].join(" ")}
251
+ style={{
252
+ aspectRatio: "210 / 297",
253
+ width: "min(100%, 560px)",
254
+ }}
255
+ >
256
+ {hasItems
257
+ ? pageItems
258
+ .slice()
259
+ .sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
260
+ .map((item) => {
261
+ const itemStyle: CSSProperties = {
262
+ position: "absolute",
263
+ left: `${item.x * scale}px`,
264
+ top: `${item.y * scale}px`,
265
+ width: `${item.w * scale}px`,
266
+ height: `${item.h * scale}px`,
267
+ zIndex: item.z ?? 0,
268
+ };
269
+
270
+ if (item.type === "text") {
271
+ return (
272
+ <div key={item.id} style={itemStyle}>
273
+ <div
274
+ className="w-full h-full p-2 overflow-hidden"
275
+ style={{
276
+ whiteSpace: "pre-wrap",
277
+ fontSize: `${(item.style?.fontSize ?? 14) * scale}px`,
278
+ fontWeight: item.style?.bold ? 700 : 400,
279
+ fontStyle: item.style?.italic ? "italic" : "normal",
280
+ textDecoration: item.style?.underline ? "underline" : "none",
281
+ color: item.style?.color ?? "#111827",
282
+ textAlign: item.style?.align ?? "left",
283
+ }}
284
+ >
285
+ {item.content ?? ""}
286
+ </div>
287
+ </div>
288
+ );
289
+ }
290
+
291
+ if (item.type === "image") {
292
+ return (
293
+ <div key={item.id} style={itemStyle}>
294
+ <img
295
+ src={item.src}
296
+ alt={item.name ?? "Image"}
297
+ className="w-full h-full object-contain bg-white"
298
+ loading="eager"
299
+ />
300
+ </div>
301
+ );
302
+ }
303
+
304
+ return (
305
+ <div key={item.id} style={itemStyle}>
306
+ <div
307
+ className="w-full h-full"
308
+ style={{
309
+ background: item.style?.fill ?? "#ffffff",
310
+ borderStyle: "solid",
311
+ borderColor: item.style?.stroke ?? "#111827",
312
+ borderWidth: `${(item.style?.strokeWidth ?? 1) * scale}px`,
313
+ }}
314
+ />
315
+ </div>
316
+ );
317
+ })
318
+ : null}
319
+ </div>
320
+ </div>
321
+
322
+ <p className="mt-4 text-xs text-gray-500 no-print">
323
+ Tip: Use keyboard arrows (left / right) to change pages.
324
+ </p>
325
+ {error ? <p className="text-sm text-red-600 mt-2">{error}</p> : null}
326
+ </section>
327
+
328
+ <footer className="mt-12 text-center text-xs text-gray-500 no-print">
329
+ <p>Prosento - (c) 2026 All Rights Reserved</p>
330
+ <p className="mt-1">Viewer now renders saved edits from the editor.</p>
331
+ </footer>
332
+
333
+ <report-editor ref={editorRef} />
334
+ </main>
335
+ );
336
+ }
frontend/src/pages/ReviewSetupPage.tsx ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { useNavigate, useSearchParams } from "react-router-dom";
3
+ import {
4
+ CheckCircle,
5
+ Image as ImageIcon,
6
+ FileText,
7
+ Table,
8
+ CheckSquare,
9
+ Square,
10
+ ArrowRight,
11
+ } from "react-feather";
12
+
13
+ import { putJson, request } from "../lib/api";
14
+ import { getSessionId, setStoredSessionId } from "../lib/session";
15
+ import type { Session } from "../types/session";
16
+ import { PageFooter } from "../components/PageFooter";
17
+ import { PageHeader } from "../components/PageHeader";
18
+ import { PageShell } from "../components/PageShell";
19
+
20
+ export default function ReviewSetupPage() {
21
+ const navigate = useNavigate();
22
+ const [searchParams] = useSearchParams();
23
+ const sessionId = getSessionId(searchParams.toString());
24
+
25
+ const [session, setSession] = useState<Session | null>(null);
26
+ const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<string>>(
27
+ new Set(),
28
+ );
29
+ const [statusMessage, setStatusMessage] = useState("");
30
+
31
+ useEffect(() => {
32
+ if (!sessionId) {
33
+ setStatusMessage("No active session found. Return to upload to continue.");
34
+ return;
35
+ }
36
+ setStoredSessionId(sessionId);
37
+ async function loadSession() {
38
+ try {
39
+ const data = await request<Session>(`/sessions/${sessionId}`);
40
+ setSession(data);
41
+ const initial = new Set(data.selected_photo_ids || []);
42
+ setSelectedPhotoIds(initial);
43
+ } catch (err) {
44
+ const message =
45
+ err instanceof Error ? err.message : "Failed to load session.";
46
+ setStatusMessage(message);
47
+ }
48
+ }
49
+ loadSession();
50
+ }, [sessionId]);
51
+
52
+ const photos = session?.uploads?.photos ?? [];
53
+ const documents = session?.uploads?.documents ?? [];
54
+ const dataFiles = session?.uploads?.data_files ?? [];
55
+
56
+ const canContinue = selectedPhotoIds.size > 0;
57
+
58
+ const readyStatus = useMemo(() => {
59
+ if (!sessionId) return "No active session found. Return to upload to continue.";
60
+ if (!canContinue) return "Choose report example images to continue...";
61
+ return "Ready. Continue to report viewer.";
62
+ }, [canContinue, sessionId]);
63
+
64
+ async function handleContinue() {
65
+ if (!sessionId || selectedPhotoIds.size === 0) return;
66
+ try {
67
+ await putJson(`/sessions/${sessionId}/selection`, {
68
+ selected_photo_ids: Array.from(selectedPhotoIds),
69
+ });
70
+ navigate(`/report-viewer?session=${encodeURIComponent(sessionId)}`);
71
+ } catch (err) {
72
+ const message =
73
+ err instanceof Error ? err.message : "Failed to save selection.";
74
+ setStatusMessage(message);
75
+ }
76
+ }
77
+
78
+ return (
79
+ <PageShell>
80
+ <PageHeader
81
+ title="RepEx - Report Express"
82
+ subtitle="Review uploads -> pick examples -> continue to report viewer"
83
+ right={
84
+ <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700">
85
+ <CheckCircle className="h-4 w-4" />
86
+ Uploads processed
87
+ </span>
88
+ }
89
+ />
90
+
91
+ <section className="mb-8" aria-labelledby="what-next">
92
+ <h2
93
+ id="what-next"
94
+ className="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-4"
95
+ >
96
+ What happens on this page
97
+ </h2>
98
+
99
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
100
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
101
+ <div className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 border border-blue-100 mb-3">
102
+ <ImageIcon className="h-5 w-5 text-blue-700" />
103
+ </div>
104
+ <h3 className="font-semibold text-gray-900 mb-1">
105
+ Select example photos
106
+ </h3>
107
+ <p className="text-sm text-gray-600">
108
+ Choose which uploaded images should appear as example figures in the report.
109
+ </p>
110
+ </div>
111
+
112
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
113
+ <div className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50 border border-emerald-100 mb-3">
114
+ <FileText className="h-5 w-5 text-emerald-700" />
115
+ </div>
116
+ <h3 className="font-semibold text-gray-900 mb-1">Confirm documents</h3>
117
+ <p className="text-sm text-gray-600">
118
+ Ensure supporting PDFs/DOCX are correct (and later attach to export if needed).
119
+ </p>
120
+ </div>
121
+
122
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
123
+ <div className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-amber-50 border border-amber-100 mb-3">
124
+ <Table className="h-5 w-5 text-amber-700" />
125
+ </div>
126
+ <h3 className="font-semibold text-gray-900 mb-1">Use Excel/CSV data</h3>
127
+ <p className="text-sm text-gray-600">
128
+ If Excel/CSV exists, it will populate report data areas automatically in later steps.
129
+ </p>
130
+ </div>
131
+ </div>
132
+ </section>
133
+
134
+ <section className="mb-8" aria-labelledby="review-uploads">
135
+ <h2
136
+ id="review-uploads"
137
+ className="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6"
138
+ >
139
+ Review uploaded files
140
+ </h2>
141
+
142
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
143
+ <div className="lg:col-span-2">
144
+ <div className="flex items-center justify-between mb-3">
145
+ <h3 className="text-lg font-semibold text-gray-900">Photos</h3>
146
+ <span className="text-sm font-semibold text-gray-600">
147
+ {photos.length} file{photos.length === 1 ? "" : "s"}
148
+ </span>
149
+ </div>
150
+
151
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
152
+ <p className="text-sm text-gray-600 mb-3">
153
+ Select images to use as example figures in the report.{" "}
154
+ <span className="font-semibold text-gray-800">Recommended:</span>{" "}
155
+ 2-6 images.
156
+ </p>
157
+
158
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
159
+ {photos.length === 0 ? (
160
+ <div className="col-span-full text-sm text-gray-500">
161
+ No photos were uploaded.
162
+ </div>
163
+ ) : (
164
+ photos.map((photo) => {
165
+ const isChecked = selectedPhotoIds.has(photo.id);
166
+ return (
167
+ <label key={photo.id} className="cursor-pointer">
168
+ <input
169
+ type="checkbox"
170
+ className="sr-only"
171
+ checked={isChecked}
172
+ onChange={(event) => {
173
+ const next = new Set(selectedPhotoIds);
174
+ if (event.target.checked) {
175
+ next.add(photo.id);
176
+ } else {
177
+ next.delete(photo.id);
178
+ }
179
+ setSelectedPhotoIds(next);
180
+ }}
181
+ />
182
+ <div
183
+ className={[
184
+ "rounded-lg border bg-gray-50 overflow-hidden transition",
185
+ isChecked
186
+ ? "ring-2 ring-emerald-200 border-emerald-300"
187
+ : "border-gray-200",
188
+ ].join(" ")}
189
+ >
190
+ <div className="relative">
191
+ <img
192
+ src={photo.url}
193
+ alt={photo.name}
194
+ className="h-28 w-full object-cover"
195
+ loading="eager"
196
+ />
197
+ <div
198
+ className={[
199
+ "absolute top-2 right-2 inline-flex items-center justify-center rounded-full border p-1.5",
200
+ isChecked
201
+ ? "bg-emerald-50 border-emerald-200 text-emerald-700"
202
+ : "bg-white/90 border-gray-200 text-gray-700",
203
+ ].join(" ")}
204
+ >
205
+ <CheckCircle className="h-4 w-4" />
206
+ </div>
207
+ </div>
208
+ <div className="p-2">
209
+ <div className="text-xs font-semibold text-gray-900 truncate">
210
+ {photo.name}
211
+ </div>
212
+ <div className="text-xs text-gray-500">
213
+ Click to select for report
214
+ </div>
215
+ </div>
216
+ </div>
217
+ </label>
218
+ );
219
+ })
220
+ )}
221
+ </div>
222
+
223
+ <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
224
+ <div className="text-sm text-gray-600">
225
+ Selected for report:{" "}
226
+ <span className="font-semibold text-gray-900">
227
+ {selectedPhotoIds.size}
228
+ </span>
229
+ </div>
230
+ <div className="flex gap-2">
231
+ <button
232
+ type="button"
233
+ onClick={() => {
234
+ setSelectedPhotoIds(new Set(photos.map((p) => p.id)));
235
+ }}
236
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
237
+ >
238
+ <CheckSquare className="h-4 w-4" />
239
+ Select all
240
+ </button>
241
+ <button
242
+ type="button"
243
+ onClick={() => setSelectedPhotoIds(new Set())}
244
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
245
+ >
246
+ <Square className="h-4 w-4" />
247
+ Clear
248
+ </button>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </div>
253
+
254
+ <div className="space-y-6">
255
+ <div>
256
+ <div className="flex items-center justify-between mb-3">
257
+ <h3 className="text-lg font-semibold text-gray-900">Documents</h3>
258
+ <span className="text-sm font-semibold text-gray-600">
259
+ {documents.length} file{documents.length === 1 ? "" : "s"}
260
+ </span>
261
+ </div>
262
+
263
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
264
+ <ul className="space-y-2 text-sm text-gray-700">
265
+ {documents.length === 0 ? (
266
+ <li className="text-sm text-gray-500">
267
+ No supporting documents detected.
268
+ </li>
269
+ ) : (
270
+ documents.map((doc) => (
271
+ <li
272
+ key={doc.id}
273
+ className="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
274
+ >
275
+ <div className="flex items-center gap-2 min-w-0">
276
+ <FileText className="h-4 w-4 text-gray-600" />
277
+ <span className="truncate text-gray-800">{doc.name}</span>
278
+ </div>
279
+ <span className="text-xs font-semibold text-gray-600">
280
+ {doc.content_type || "File"}
281
+ </span>
282
+ </li>
283
+ ))
284
+ )}
285
+ </ul>
286
+ {documents.length === 0 ? (
287
+ <p className="text-xs text-gray-500 mt-3">
288
+ PDFs/DOCX appear here after processing.
289
+ </p>
290
+ ) : null}
291
+ </div>
292
+ </div>
293
+
294
+ <div>
295
+ <div className="flex items-center justify-between mb-3">
296
+ <h3 className="text-lg font-semibold text-gray-900">Data files</h3>
297
+ <span className="text-sm font-semibold text-gray-600">
298
+ {dataFiles.length} file{dataFiles.length === 1 ? "" : "s"}
299
+ </span>
300
+ </div>
301
+
302
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
303
+ {dataFiles.length === 0 ? (
304
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm text-gray-600">
305
+ <div className="font-semibold text-gray-800 mb-1">
306
+ No Excel/CSV detected
307
+ </div>
308
+ If you upload a CSV or Excel file, RepEx can auto-populate report data fields.
309
+ </div>
310
+ ) : (
311
+ dataFiles.map((file) => (
312
+ <div
313
+ key={file.id}
314
+ className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700 mb-2 last:mb-0"
315
+ >
316
+ <div className="flex items-center justify-between gap-3">
317
+ <div className="flex items-center gap-2 min-w-0">
318
+ <Table className="h-4 w-4 text-amber-700" />
319
+ <span className="truncate font-semibold text-gray-900">
320
+ {file.name}
321
+ </span>
322
+ </div>
323
+ <span className="text-xs font-semibold text-gray-600">
324
+ {file.content_type || "Data"}
325
+ </span>
326
+ </div>
327
+ <div className="text-xs text-gray-600 mt-1">
328
+ Will populate report data areas (tables/fields).
329
+ </div>
330
+ </div>
331
+ ))
332
+ )}
333
+ <p className="text-xs text-gray-500 mt-3">
334
+ If present, these files will populate report tables/fields automatically.
335
+ </p>
336
+ </div>
337
+ </div>
338
+ </div>
339
+ </div>
340
+ </section>
341
+
342
+ <section className="mb-4" aria-label="Continue to report viewer">
343
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
344
+ <div
345
+ className={[
346
+ "text-sm",
347
+ canContinue ? "text-emerald-700 font-semibold" : "text-amber-700 font-semibold",
348
+ ].join(" ")}
349
+ >
350
+ {readyStatus}
351
+ </div>
352
+
353
+ <button
354
+ type="button"
355
+ onClick={handleContinue}
356
+ disabled={!canContinue}
357
+ className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-white font-semibold hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
358
+ >
359
+ <ArrowRight className="h-5 w-5" />
360
+ Continue to Report Viewer
361
+ </button>
362
+ </div>
363
+
364
+ <p className="text-xs text-gray-500 mt-3">
365
+ Note: This page assumes uploads were completed on a previous processing step.
366
+ </p>
367
+ </section>
368
+
369
+ {statusMessage ? (
370
+ <p className="text-sm text-red-600">{statusMessage}</p>
371
+ ) : null}
372
+
373
+ <PageFooter note="Workflow: Processing -> Review uploads -> Report viewer -> Edit -> Export" />
374
+ </PageShell>
375
+ );
376
+ }
frontend/src/pages/UploadPage.tsx ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import {
4
+ ArrowDownCircle,
5
+ Upload,
6
+ Info,
7
+ Camera,
8
+ UploadCloud,
9
+ FileText,
10
+ FilePlus,
11
+ Edit,
12
+ } from "react-feather";
13
+
14
+ import { postForm, request } from "../lib/api";
15
+ import { formatBytes } from "../lib/format";
16
+ import { setStoredSessionId } from "../lib/session";
17
+ import type { Session } from "../types/session";
18
+
19
+ type StatusTone = "idle" | "info" | "error";
20
+
21
+ export default function UploadPage() {
22
+ const navigate = useNavigate();
23
+ const inputRef = useRef<HTMLInputElement | null>(null);
24
+ const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
25
+ const [projectName, setProjectName] = useState("");
26
+ const [inspectionDate, setInspectionDate] = useState("");
27
+ const [notes, setNotes] = useState("");
28
+ const [uploadStatus, setUploadStatus] = useState("");
29
+ const [statusTone, setStatusTone] = useState<StatusTone>("idle");
30
+ const [recentSessions, setRecentSessions] = useState<Session[]>([]);
31
+ const [dragActive, setDragActive] = useState(false);
32
+ const [isSubmitting, setIsSubmitting] = useState(false);
33
+
34
+ const fileSummary = useMemo(() => {
35
+ if (!selectedFiles.length) {
36
+ return "Supports JPG, PNG, PDF, DOCX, CSV, XLSX (Max 50MB each)";
37
+ }
38
+ return selectedFiles
39
+ .map((file) => `${file.name} (${formatBytes(file.size)})`)
40
+ .join(" - ");
41
+ }, [selectedFiles]);
42
+
43
+ useEffect(() => {
44
+ async function loadSessions() {
45
+ try {
46
+ const sessions = await request<Session[]>("/sessions");
47
+ if (Array.isArray(sessions)) {
48
+ setRecentSessions(sessions.slice(0, 5));
49
+ }
50
+ } catch {
51
+ // ignore for now
52
+ }
53
+ }
54
+ loadSessions();
55
+ }, []);
56
+
57
+ function handleBrowseClick() {
58
+ inputRef.current?.click();
59
+ }
60
+
61
+ function handleFilesChange(files: FileList | null) {
62
+ if (!files) return;
63
+ setSelectedFiles(Array.from(files));
64
+ }
65
+
66
+ function handleDrop(event: React.DragEvent<HTMLDivElement>) {
67
+ event.preventDefault();
68
+ setDragActive(false);
69
+ const files = Array.from(event.dataTransfer.files || []);
70
+ if (files.length) {
71
+ setSelectedFiles(files);
72
+ if (inputRef.current) inputRef.current.value = "";
73
+ }
74
+ }
75
+
76
+ async function handleSubmit() {
77
+ if (!selectedFiles.length) {
78
+ setUploadStatus("Please add at least one file to continue.");
79
+ setStatusTone("info");
80
+ return;
81
+ }
82
+
83
+ setIsSubmitting(true);
84
+ setUploadStatus("Uploading files...");
85
+ setStatusTone("idle");
86
+
87
+ const formData = new FormData();
88
+ formData.append("project_name", projectName.trim());
89
+ formData.append("inspection_date", inspectionDate);
90
+ formData.append("notes", notes.trim());
91
+ selectedFiles.forEach((file) => formData.append("files", file));
92
+
93
+ try {
94
+ const session = await postForm<Session>("/sessions", formData);
95
+ setStoredSessionId(session.id);
96
+ navigate(`/processing?session=${encodeURIComponent(session.id)}`);
97
+ } catch (err) {
98
+ const message =
99
+ err instanceof Error ? err.message : "Upload failed. Please try again.";
100
+ setUploadStatus(message);
101
+ setStatusTone("error");
102
+ setIsSubmitting(false);
103
+ }
104
+ }
105
+
106
+ return (
107
+ <main className="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
108
+ <header className="mb-10 border-b border-gray-200 pb-4">
109
+ <div className="grid grid-cols-[auto,1fr,auto] items-center gap-4">
110
+ <div className="flex items-center">
111
+ <img
112
+ src="/assets/prosento-logo.png"
113
+ alt="Company logo"
114
+ className="h-12 w-auto object-contain"
115
+ loading="eager"
116
+ />
117
+ </div>
118
+
119
+ <div className="text-center">
120
+ <h1 className="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
121
+ Prosento RepEx
122
+ </h1>
123
+ <p className="text-gray-600 whitespace-nowrap">
124
+ Upload photos and documents to generate professional job reports instantly
125
+ </p>
126
+ </div>
127
+
128
+ <div className="flex justify-end">
129
+ <a
130
+ href="#upload"
131
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
132
+ >
133
+ <ArrowDownCircle className="h-4 w-4" />
134
+ Upload
135
+ </a>
136
+ </div>
137
+ </div>
138
+ </header>
139
+
140
+ <section className="text-center mb-12">
141
+ <h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-3">
142
+ Generate inspection-ready reports in minutes
143
+ </h2>
144
+ <p className="text-base md:text-lg text-gray-600 mb-8">
145
+ Drag and drop files, add quick context, and produce a clean, consistent report format every time.
146
+ </p>
147
+
148
+ <div className="flex flex-col sm:flex-row justify-center gap-3">
149
+ <a
150
+ href="#upload"
151
+ className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-white font-semibold hover:bg-blue-700 transition"
152
+ >
153
+ <Upload className="h-5 w-5" />
154
+ Start Uploading
155
+ </a>
156
+
157
+ <a
158
+ href="#how-it-works"
159
+ className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-6 py-3 text-gray-800 font-semibold hover:bg-gray-50 transition"
160
+ >
161
+ <Info className="h-5 w-5" />
162
+ Learn More
163
+ </a>
164
+ </div>
165
+ </section>
166
+
167
+ <section id="how-it-works" className="mb-12">
168
+ <h2 className="text-xl md:text-2xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
169
+ How It Works
170
+ </h2>
171
+
172
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
173
+ <article className="rounded-lg border border-gray-200 bg-gray-50 p-5">
174
+ <div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
175
+ <Camera className="h-5 w-5 text-blue-700" />
176
+ </div>
177
+ <h3 className="text-base font-semibold text-gray-900 mb-1">Capture site photos</h3>
178
+ <p className="text-sm text-gray-600">
179
+ Take clear photos of structural elements and issues during your inspection.
180
+ </p>
181
+ </article>
182
+
183
+ <article className="rounded-lg border border-gray-200 bg-gray-50 p-5">
184
+ <div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50 border border-emerald-100">
185
+ <UploadCloud className="h-5 w-5 text-emerald-700" />
186
+ </div>
187
+ <h3 className="text-base font-semibold text-gray-900 mb-1">Upload documents</h3>
188
+ <p className="text-sm text-gray-600">
189
+ Add notes, measurements, and supporting PDFs/DOCX to complete the context.
190
+ </p>
191
+ </article>
192
+
193
+ <article className="rounded-lg border border-gray-200 bg-gray-50 p-5">
194
+ <div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
195
+ <FileText className="h-5 w-5 text-blue-700" />
196
+ </div>
197
+ <h3 className="text-base font-semibold text-gray-900 mb-1">Generate report</h3>
198
+ <p className="text-sm text-gray-600">
199
+ Produce a consistent, professional report that is ready for submission.
200
+ </p>
201
+ </article>
202
+ </div>
203
+ </section>
204
+
205
+ <section id="upload" className="mb-12">
206
+ <h2 className="text-xl md:text-2xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
207
+ Upload Your Files
208
+ </h2>
209
+
210
+ <div className="rounded-lg border border-gray-200 bg-white p-6">
211
+ <div
212
+ onDragEnter={() => setDragActive(true)}
213
+ onDragOver={(event) => {
214
+ event.preventDefault();
215
+ setDragActive(true);
216
+ }}
217
+ onDragLeave={(event) => {
218
+ event.preventDefault();
219
+ setDragActive(false);
220
+ }}
221
+ onDrop={handleDrop}
222
+ role="button"
223
+ tabIndex={0}
224
+ aria-label="Drag and drop files here"
225
+ className={[
226
+ "rounded-lg border-2 border-dashed p-8 text-center transition mb-6",
227
+ dragActive
228
+ ? "border-blue-500 bg-blue-50"
229
+ : "border-gray-300 bg-gray-50 hover:border-blue-400",
230
+ ].join(" ")}
231
+ >
232
+ <div className="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
233
+ <Upload className="h-5 w-5 text-blue-700" />
234
+ </div>
235
+
236
+ <h3 className="text-base font-semibold text-gray-900">Drag & drop files here</h3>
237
+ <p className="text-sm text-gray-600 mt-1 mb-4">or browse to upload</p>
238
+
239
+ <button
240
+ type="button"
241
+ onClick={handleBrowseClick}
242
+ className="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"
243
+ >
244
+ Browse Files
245
+ </button>
246
+
247
+ <input
248
+ ref={inputRef}
249
+ type="file"
250
+ className="hidden"
251
+ multiple
252
+ accept=".jpg,.jpeg,.png,.webp,.pdf,.doc,.docx,.csv,.xls,.xlsx"
253
+ onChange={(event) => handleFilesChange(event.target.files)}
254
+ />
255
+
256
+ <p className="text-xs text-gray-500 mt-4">{fileSummary}</p>
257
+ </div>
258
+
259
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
260
+ <div className="space-y-1">
261
+ <label className="block text-sm font-medium text-gray-700" htmlFor="projectName">
262
+ Project Name
263
+ </label>
264
+ <input
265
+ id="projectName"
266
+ type="text"
267
+ value={projectName}
268
+ onChange={(event) => setProjectName(event.target.value)}
269
+ placeholder="e.g., North Pit Conveyor Support"
270
+ className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
271
+ />
272
+ </div>
273
+
274
+ <div className="space-y-1">
275
+ <label className="block text-sm font-medium text-gray-700" htmlFor="inspectionDate">
276
+ Inspection Date
277
+ </label>
278
+ <input
279
+ id="inspectionDate"
280
+ type="date"
281
+ value={inspectionDate}
282
+ onChange={(event) => setInspectionDate(event.target.value)}
283
+ className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
284
+ />
285
+ </div>
286
+ </div>
287
+
288
+ <div className="space-y-1 mb-6">
289
+ <label className="block text-sm font-medium text-gray-700" htmlFor="notes">
290
+ Additional Notes
291
+ </label>
292
+ <textarea
293
+ id="notes"
294
+ rows={4}
295
+ value={notes}
296
+ onChange={(event) => setNotes(event.target.value)}
297
+ placeholder="Add any context you'd like included in the report..."
298
+ className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
299
+ />
300
+ </div>
301
+
302
+ <button
303
+ type="button"
304
+ onClick={handleSubmit}
305
+ disabled={isSubmitting}
306
+ className="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 disabled:opacity-50 disabled:cursor-not-allowed"
307
+ >
308
+ <FilePlus className="h-5 w-5" />
309
+ Generate Report
310
+ </button>
311
+ {uploadStatus ? (
312
+ <p
313
+ className={[
314
+ "text-sm mt-3",
315
+ statusTone === "error"
316
+ ? "text-red-600"
317
+ : statusTone === "info"
318
+ ? "text-amber-700"
319
+ : "text-gray-600",
320
+ ].join(" ")}
321
+ >
322
+ {uploadStatus}
323
+ </p>
324
+ ) : null}
325
+ </div>
326
+ </section>
327
+
328
+ <section className="mb-6">
329
+ <h2 className="text-xl md:text-2xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
330
+ Recent Reports
331
+ </h2>
332
+
333
+ <div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
334
+ <div className="overflow-x-auto">
335
+ <table className="min-w-full divide-y divide-gray-200">
336
+ <thead className="bg-gray-50">
337
+ <tr>
338
+ <th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
339
+ Report ID
340
+ </th>
341
+ <th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
342
+ Project
343
+ </th>
344
+ <th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
345
+ Date
346
+ </th>
347
+ <th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
348
+ Status
349
+ </th>
350
+ <th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
351
+ Actions
352
+ </th>
353
+ </tr>
354
+ </thead>
355
+
356
+ <tbody className="bg-white divide-y divide-gray-200">
357
+ {recentSessions.length === 0 ? (
358
+ <tr>
359
+ <td colSpan={5} className="px-6 py-6 text-sm text-gray-500 text-center">
360
+ No recent reports yet.
361
+ </td>
362
+ </tr>
363
+ ) : (
364
+ recentSessions.map((session) => (
365
+ <tr key={session.id}>
366
+ <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
367
+ #{session.id.slice(0, 8)}
368
+ </td>
369
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
370
+ {session.project_name || "Untitled project"}
371
+ </td>
372
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
373
+ {session.created_at
374
+ ? new Date(session.created_at).toLocaleDateString()
375
+ : "-"}
376
+ </td>
377
+ <td className="px-6 py-4 whitespace-nowrap">
378
+ <span className="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">
379
+ {session.status || "ready"}
380
+ </span>
381
+ </td>
382
+ <td className="px-6 py-4 whitespace-nowrap text-sm">
383
+ <button
384
+ type="button"
385
+ onClick={() =>
386
+ navigate(`/report-viewer?session=${session.id}`)
387
+ }
388
+ className="inline-flex items-center text-blue-700 hover:text-blue-800"
389
+ aria-label="View report"
390
+ >
391
+ <Edit className="h-4 w-4" />
392
+ </button>
393
+ </td>
394
+ </tr>
395
+ ))
396
+ )}
397
+ </tbody>
398
+ </table>
399
+ </div>
400
+ </div>
401
+ </section>
402
+
403
+ <footer className="mt-12 text-center text-xs text-gray-500">
404
+ <p>Prosento - (c) 2026 All Rights Reserved</p>
405
+ <p className="mt-1">
406
+ RepEx is a report automation interface. All uploads should comply with site data policies.
407
+ </p>
408
+ </footer>
409
+ </main>
410
+ );
411
+ }
frontend/src/types/custom-elements.d.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type React from "react";
2
+ import type { Session } from "./session";
3
+
4
+ declare global {
5
+ interface ReportEditorOpenOptions {
6
+ payload?: Session | null;
7
+ pageIndex?: number;
8
+ totalPages?: number;
9
+ sessionId?: string | null;
10
+ apiBase?: string | null;
11
+ }
12
+
13
+ interface ReportEditorElement extends HTMLElement {
14
+ open: (options?: ReportEditorOpenOptions) => void;
15
+ }
16
+
17
+ namespace JSX {
18
+ interface IntrinsicElements {
19
+ "report-editor": React.DetailedHTMLProps<
20
+ React.HTMLAttributes<ReportEditorElement>,
21
+ ReportEditorElement
22
+ >;
23
+ }
24
+ }
25
+ }
26
+
27
+ export {};
frontend/src/types/session.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type FileMeta = {
2
+ id: string;
3
+ name: string;
4
+ size: number;
5
+ content_type: string;
6
+ category: string;
7
+ url?: string;
8
+ };
9
+
10
+ export type SessionUploads = {
11
+ photos?: FileMeta[];
12
+ documents?: FileMeta[];
13
+ data_files?: FileMeta[];
14
+ };
15
+
16
+ export type Session = {
17
+ id: string;
18
+ status: string;
19
+ created_at: string;
20
+ updated_at: string;
21
+ project_name: string;
22
+ inspection_date: string;
23
+ notes: string;
24
+ uploads: SessionUploads;
25
+ selected_photo_ids: string[];
26
+ page_count: number;
27
+ };
28
+
29
+ export type SessionStatus = {
30
+ id: string;
31
+ status: string;
32
+ updated_at: string;
33
+ };
34
+
35
+ export type PageItemStyle = {
36
+ fontSize?: number;
37
+ bold?: boolean;
38
+ italic?: boolean;
39
+ underline?: boolean;
40
+ color?: string;
41
+ align?: "left" | "center" | "right";
42
+ fill?: string;
43
+ stroke?: string;
44
+ strokeWidth?: number;
45
+ };
46
+
47
+ export type PageItem = {
48
+ id: string;
49
+ type: "text" | "image" | "rect";
50
+ x: number;
51
+ y: number;
52
+ w: number;
53
+ h: number;
54
+ z?: number;
55
+ content?: string;
56
+ src?: string;
57
+ name?: string;
58
+ style?: PageItemStyle;
59
+ };
60
+
61
+ export type Page = {
62
+ items: PageItem[];
63
+ };
64
+
65
+ export type PagesResponse = {
66
+ pages: Page[];
67
+ };
frontend/src/vite-env.d.ts CHANGED
@@ -1 +1,9 @@
1
  /// <reference types="vite/client" />
 
 
 
 
 
 
 
 
 
1
  /// <reference types="vite/client" />
2
+
3
+ interface ImportMetaEnv {
4
+ readonly VITE_API_BASE?: string;
5
+ }
6
+
7
+ interface ImportMeta {
8
+ readonly env: ImportMetaEnv;
9
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
4
+ theme: {
5
+ extend: {},
6
+ },
7
+ plugins: [],
8
+ };
frontend/tsconfig.json CHANGED
@@ -3,7 +3,7 @@
3
  "target": "ES2020",
4
  "useDefineForClassFields": true,
5
  "lib": ["DOM", "DOM.Iterable", "ES2020"],
6
- "allowJs": false,
7
  "skipLibCheck": true,
8
  "esModuleInterop": false,
9
  "allowSyntheticDefaultImports": true,
 
3
  "target": "ES2020",
4
  "useDefineForClassFields": true,
5
  "lib": ["DOM", "DOM.Iterable", "ES2020"],
6
+ "allowJs": true,
7
  "skipLibCheck": true,
8
  "esModuleInterop": false,
9
  "allowSyntheticDefaultImports": true,
frontend/vite.config.ts CHANGED
@@ -5,6 +5,9 @@ export default defineConfig({
5
  plugins: [react()],
6
  server: {
7
  port: 5173,
 
 
 
8
  },
9
  preview: {
10
  port: 4173,
 
5
  plugins: [react()],
6
  server: {
7
  port: 5173,
8
+ proxy: {
9
+ "/api": "http://localhost:8000",
10
+ },
11
  },
12
  preview: {
13
  port: 4173,
server/app/api/routes/sessions.py CHANGED
@@ -21,6 +21,13 @@ from ...services import SessionStore
21
  router = APIRouter()
22
 
23
 
 
 
 
 
 
 
 
24
  def _attach_urls(session: dict) -> dict:
25
  session = dict(session)
26
  uploads = {}
@@ -75,6 +82,7 @@ def create_session(
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.")
@@ -85,6 +93,7 @@ def get_session(session_id: str, store: SessionStore = Depends(get_session_store
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.")
@@ -99,6 +108,7 @@ def update_selection(
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.")
@@ -108,6 +118,7 @@ def update_selection(
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.")
@@ -121,6 +132,7 @@ def save_pages(
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.")
@@ -134,6 +146,7 @@ def get_upload(
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.")
@@ -147,6 +160,7 @@ def get_upload(
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.")
 
21
  router = APIRouter()
22
 
23
 
24
+ def _normalize_session_id(session_id: str, store: SessionStore) -> str:
25
+ try:
26
+ return store.validate_session_id(session_id)
27
+ except ValueError as exc:
28
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
29
+
30
+
31
  def _attach_urls(session: dict) -> dict:
32
  session = dict(session)
33
  uploads = {}
 
82
 
83
  @router.get("/{session_id}", response_model=SessionResponse)
84
  def get_session(session_id: str, store: SessionStore = Depends(get_session_store)) -> SessionResponse:
85
+ session_id = _normalize_session_id(session_id, store)
86
  session = store.get_session(session_id)
87
  if not session:
88
  raise HTTPException(status_code=404, detail="Session not found.")
 
93
  def get_session_status(
94
  session_id: str, store: SessionStore = Depends(get_session_store)
95
  ) -> SessionStatusResponse:
96
+ session_id = _normalize_session_id(session_id, store)
97
  session = store.get_session(session_id)
98
  if not session:
99
  raise HTTPException(status_code=404, detail="Session not found.")
 
108
  payload: SelectionRequest,
109
  store: SessionStore = Depends(get_session_store),
110
  ) -> SessionResponse:
111
+ session_id = _normalize_session_id(session_id, store)
112
  session = store.get_session(session_id)
113
  if not session:
114
  raise HTTPException(status_code=404, detail="Session not found.")
 
118
 
119
  @router.get("/{session_id}/pages", response_model=PagesResponse)
120
  def get_pages(session_id: str, store: SessionStore = Depends(get_session_store)) -> PagesResponse:
121
+ session_id = _normalize_session_id(session_id, store)
122
  session = store.get_session(session_id)
123
  if not session:
124
  raise HTTPException(status_code=404, detail="Session not found.")
 
132
  payload: PagesRequest,
133
  store: SessionStore = Depends(get_session_store),
134
  ) -> PagesResponse:
135
+ session_id = _normalize_session_id(session_id, store)
136
  session = store.get_session(session_id)
137
  if not session:
138
  raise HTTPException(status_code=404, detail="Session not found.")
 
146
  file_id: str,
147
  store: SessionStore = Depends(get_session_store),
148
  ) -> FileResponse:
149
+ session_id = _normalize_session_id(session_id, store)
150
  session = store.get_session(session_id)
151
  if not session:
152
  raise HTTPException(status_code=404, detail="Session not found.")
 
160
  def export_package(
161
  session_id: str, store: SessionStore = Depends(get_session_store)
162
  ) -> FileResponse:
163
+ session_id = _normalize_session_id(session_id, store)
164
  session = store.get_session(session_id)
165
  if not session:
166
  raise HTTPException(status_code=404, detail="Session not found.")
server/app/main.py CHANGED
@@ -1,8 +1,8 @@
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
@@ -21,8 +21,24 @@ def create_app() -> FastAPI:
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
 
 
1
  from pathlib import Path
2
 
3
+ from fastapi import FastAPI, HTTPException
4
  from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.responses import FileResponse
6
 
7
  from .api.router import api_router
8
  from .core.config import get_settings
 
21
  )
22
  app.include_router(api_router, prefix=settings.api_prefix)
23
 
24
+ static_root = Path(__file__).resolve().parents[2] / "frontend" / "dist"
25
+ if static_root.exists():
26
+ index_file = static_root / "index.html"
27
+
28
+ @app.get("/", include_in_schema=False)
29
+ def serve_index() -> FileResponse:
30
+ return FileResponse(index_file)
31
+
32
+ @app.get("/{full_path:path}", include_in_schema=False)
33
+ def serve_spa(full_path: str) -> FileResponse:
34
+ if full_path.startswith("api/"):
35
+ raise HTTPException(status_code=404)
36
+ candidate = (static_root / full_path).resolve()
37
+ if not str(candidate).startswith(str(static_root.resolve())):
38
+ raise HTTPException(status_code=404)
39
+ if candidate.is_file():
40
+ return FileResponse(candidate)
41
+ return FileResponse(index_file)
42
  return app
43
 
44
 
server/app/services/session_store.py CHANGED
@@ -17,6 +17,7 @@ from ..core.config import get_settings
17
  IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
18
  DOC_EXTS = {".pdf", ".doc", ".docx"}
19
  DATA_EXTS = {".csv", ".xls", ".xlsx"}
 
20
 
21
 
22
  @dataclass
@@ -50,6 +51,15 @@ def _category_for(filename: str) -> str:
50
  return "documents"
51
 
52
 
 
 
 
 
 
 
 
 
 
53
  class SessionStore:
54
  def __init__(self, base_dir: Optional[Path] = None) -> None:
55
  settings = get_settings()
@@ -87,6 +97,9 @@ class SessionStore:
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():
@@ -171,7 +184,11 @@ class SessionStore:
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)
 
17
  IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
18
  DOC_EXTS = {".pdf", ".doc", ".docx"}
19
  DATA_EXTS = {".csv", ".xls", ".xlsx"}
20
+ SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
21
 
22
 
23
  @dataclass
 
51
  return "documents"
52
 
53
 
54
+ def _validate_session_id(session_id: str) -> str:
55
+ if not session_id:
56
+ raise ValueError("Invalid session id.")
57
+ normalized = session_id.lower()
58
+ if not SESSION_ID_RE.match(normalized):
59
+ raise ValueError("Invalid session id.")
60
+ return normalized
61
+
62
+
63
  class SessionStore:
64
  def __init__(self, base_dir: Optional[Path] = None) -> None:
65
  settings = get_settings()
 
97
  self._save_session(session)
98
  return session
99
 
100
+ def validate_session_id(self, session_id: str) -> str:
101
+ return _validate_session_id(session_id)
102
+
103
  def get_session(self, session_id: str) -> Optional[dict]:
104
  session_path = self._session_file(session_id)
105
  if not session_path.exists():
 
184
  )
185
 
186
  def _session_dir(self, session_id: str) -> Path:
187
+ safe_id = _validate_session_id(session_id)
188
+ path = (self.sessions_dir / safe_id).resolve()
189
+ if not str(path).startswith(str(self.sessions_dir.resolve())):
190
+ raise ValueError("Invalid session id.")
191
+ return path
192
 
193
  def session_dir(self, session_id: str) -> Path:
194
  return self._session_dir(session_id)