SCGR commited on
Commit
84aedaf
·
1 Parent(s): 67e2453
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. frontend/package-lock.json +110 -0
  2. frontend/package.json +2 -0
  3. frontend/src/pages/AdminPage/AdminPage.tsx +73 -73
  4. frontend/src/pages/AnalyticsPage/AnalyticsPage.module.css +80 -0
  5. frontend/src/pages/AnalyticsPage/AnalyticsPage.tsx +988 -93
  6. frontend/src/pages/ExplorePage/ExplorePage.module.css +204 -1
  7. frontend/src/pages/ExplorePage/ExplorePage.tsx +579 -25
  8. frontend/src/pages/HelpPage.tsx +47 -2
  9. frontend/src/pages/MapDetailsPage/MapDetailPage.module.css +97 -1
  10. frontend/src/pages/MapDetailsPage/MapDetailPage.tsx +491 -22
  11. frontend/src/pages/UploadPage/UploadPage.module.css +15 -1
  12. frontend/src/pages/UploadPage/UploadPage.tsx +227 -2
  13. go-web-app-develop/.changeset/README.md +8 -0
  14. go-web-app-develop/.changeset/config.json +15 -0
  15. go-web-app-develop/.changeset/lovely-kids-boil.md +5 -0
  16. go-web-app-develop/.changeset/pre.json +15 -0
  17. go-web-app-develop/.changeset/solid-clubs-care.md +8 -0
  18. go-web-app-develop/.changeset/sweet-gifts-cheer.md +9 -0
  19. go-web-app-develop/.changeset/whole-lions-guess.md +7 -0
  20. go-web-app-develop/.dockerignore +148 -0
  21. go-web-app-develop/.github/ISSUE_TEMPLATE/01_bug_report.yml +92 -0
  22. go-web-app-develop/.github/ISSUE_TEMPLATE/02_feature_request.yml +39 -0
  23. go-web-app-develop/.github/ISSUE_TEMPLATE/03_epic_request.yml +37 -0
  24. go-web-app-develop/.github/ISSUE_TEMPLATE/config.yml +5 -0
  25. go-web-app-develop/.github/dependabot.yml +27 -0
  26. go-web-app-develop/.github/pull_request_template.md +30 -0
  27. go-web-app-develop/.github/workflows/add-issue-to-backlog.yml +16 -0
  28. go-web-app-develop/.github/workflows/chromatic.yml +127 -0
  29. go-web-app-develop/.github/workflows/ci.yml +304 -0
  30. go-web-app-develop/.github/workflows/publish-nginx-serve.yml +147 -0
  31. go-web-app-develop/.github/workflows/publish-storybook-nginx-serve.yml +127 -0
  32. go-web-app-develop/.gitignore +43 -0
  33. go-web-app-develop/.npmrc +1 -0
  34. go-web-app-develop/COLLABORATING.md +18 -0
  35. go-web-app-develop/CONTRIBUTING.md +81 -0
  36. go-web-app-develop/LICENSE +21 -0
  37. go-web-app-develop/README.md +117 -0
  38. go-web-app-develop/app/CHANGELOG.md +729 -0
  39. go-web-app-develop/app/env.ts +29 -0
  40. go-web-app-develop/app/eslint.config.js +165 -0
  41. go-web-app-develop/app/index.html +69 -0
  42. go-web-app-develop/app/package.json +119 -0
  43. go-web-app-develop/app/postcss.config.cjs +8 -0
  44. go-web-app-develop/app/public/go-icon.svg +4 -0
  45. go-web-app-develop/app/scripts/translatte/README.md +59 -0
  46. go-web-app-develop/app/scripts/translatte/commands/applyMigrations.test.ts +104 -0
  47. go-web-app-develop/app/scripts/translatte/commands/applyMigrations.ts +177 -0
  48. go-web-app-develop/app/scripts/translatte/commands/exportMigration.ts +62 -0
  49. go-web-app-develop/app/scripts/translatte/commands/generateMigration.test.ts +102 -0
  50. go-web-app-develop/app/scripts/translatte/commands/generateMigration.ts +195 -0
frontend/package-lock.json CHANGED
@@ -10,6 +10,8 @@
10
  "dependencies": {
11
  "@ifrc-go/icons": "^2.0.1",
12
  "@ifrc-go/ui": "^1.3.0",
 
 
13
  "lucide-react": "^0.525.0",
14
  "react": "^18.2.0",
15
  "react-dom": "^18.2.0",
@@ -1995,6 +1997,15 @@
1995
  "dev": true,
1996
  "license": "MIT"
1997
  },
 
 
 
 
 
 
 
 
 
1998
  "node_modules/@types/node": {
1999
  "version": "24.1.0",
2000
  "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
@@ -2604,6 +2615,12 @@
2604
  "url": "https://opencollective.com/core-js"
2605
  }
2606
  },
 
 
 
 
 
 
2607
  "node_modules/cross-spawn": {
2608
  "version": "7.0.6",
2609
  "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3268,6 +3285,12 @@
3268
  "node": ">= 4"
3269
  }
3270
  },
 
 
 
 
 
 
3271
  "node_modules/import-fresh": {
3272
  "version": "3.3.1",
3273
  "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3295,6 +3318,12 @@
3295
  "node": ">=0.8.19"
3296
  }
3297
  },
 
 
 
 
 
 
3298
  "node_modules/is-extglob": {
3299
  "version": "2.1.1",
3300
  "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3337,6 +3366,12 @@
3337
  "node": ">=0.10.0"
3338
  }
3339
  },
 
 
 
 
 
 
3340
  "node_modules/isexe": {
3341
  "version": "2.0.0",
3342
  "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3420,6 +3455,18 @@
3420
  "node": ">=6"
3421
  }
3422
  },
 
 
 
 
 
 
 
 
 
 
 
 
3423
  "node_modules/keyv": {
3424
  "version": "4.5.4",
3425
  "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3444,6 +3491,15 @@
3444
  "node": ">= 0.8.0"
3445
  }
3446
  },
 
 
 
 
 
 
 
 
 
3447
  "node_modules/lightningcss": {
3448
  "version": "1.30.1",
3449
  "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
@@ -3928,6 +3984,12 @@
3928
  "url": "https://github.com/sponsors/sindresorhus"
3929
  }
3930
  },
 
 
 
 
 
 
3931
  "node_modules/parent-module": {
3932
  "version": "1.0.1",
3933
  "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4031,6 +4093,12 @@
4031
  "node": ">= 0.8.0"
4032
  }
4033
  },
 
 
 
 
 
 
4034
  "node_modules/prop-types": {
4035
  "version": "15.8.1",
4036
  "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -4276,6 +4344,21 @@
4276
  }
4277
  }
4278
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4279
  "node_modules/resolve-from": {
4280
  "version": "4.0.0",
4281
  "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -4360,6 +4443,12 @@
4360
  "queue-microtask": "^1.2.2"
4361
  }
4362
  },
 
 
 
 
 
 
4363
  "node_modules/sanitize-html": {
4364
  "version": "2.17.0",
4365
  "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
@@ -4396,6 +4485,12 @@
4396
  "node": ">=10"
4397
  }
4398
  },
 
 
 
 
 
 
4399
  "node_modules/shebang-command": {
4400
  "version": "2.0.0",
4401
  "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4428,6 +4523,15 @@
4428
  "node": ">=0.10.0"
4429
  }
4430
  },
 
 
 
 
 
 
 
 
 
4431
  "node_modules/strip-json-comments": {
4432
  "version": "3.1.1",
4433
  "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4704,6 +4808,12 @@
4704
  }
4705
  }
4706
  },
 
 
 
 
 
 
4707
  "node_modules/vite": {
4708
  "version": "7.1.3",
4709
  "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
 
10
  "dependencies": {
11
  "@ifrc-go/icons": "^2.0.1",
12
  "@ifrc-go/ui": "^1.3.0",
13
+ "@types/jszip": "^3.4.0",
14
+ "jszip": "^3.10.1",
15
  "lucide-react": "^0.525.0",
16
  "react": "^18.2.0",
17
  "react-dom": "^18.2.0",
 
1997
  "dev": true,
1998
  "license": "MIT"
1999
  },
2000
+ "node_modules/@types/jszip": {
2001
+ "version": "3.4.0",
2002
+ "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
2003
+ "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==",
2004
+ "license": "MIT",
2005
+ "dependencies": {
2006
+ "jszip": "*"
2007
+ }
2008
+ },
2009
  "node_modules/@types/node": {
2010
  "version": "24.1.0",
2011
  "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
 
2615
  "url": "https://opencollective.com/core-js"
2616
  }
2617
  },
2618
+ "node_modules/core-util-is": {
2619
+ "version": "1.0.3",
2620
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
2621
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
2622
+ "license": "MIT"
2623
+ },
2624
  "node_modules/cross-spawn": {
2625
  "version": "7.0.6",
2626
  "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
 
3285
  "node": ">= 4"
3286
  }
3287
  },
3288
+ "node_modules/immediate": {
3289
+ "version": "3.0.6",
3290
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
3291
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
3292
+ "license": "MIT"
3293
+ },
3294
  "node_modules/import-fresh": {
3295
  "version": "3.3.1",
3296
  "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
 
3318
  "node": ">=0.8.19"
3319
  }
3320
  },
3321
+ "node_modules/inherits": {
3322
+ "version": "2.0.4",
3323
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
3324
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
3325
+ "license": "ISC"
3326
+ },
3327
  "node_modules/is-extglob": {
3328
  "version": "2.1.1",
3329
  "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
 
3366
  "node": ">=0.10.0"
3367
  }
3368
  },
3369
+ "node_modules/isarray": {
3370
+ "version": "1.0.0",
3371
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
3372
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
3373
+ "license": "MIT"
3374
+ },
3375
  "node_modules/isexe": {
3376
  "version": "2.0.0",
3377
  "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
 
3455
  "node": ">=6"
3456
  }
3457
  },
3458
+ "node_modules/jszip": {
3459
+ "version": "3.10.1",
3460
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
3461
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
3462
+ "license": "(MIT OR GPL-3.0-or-later)",
3463
+ "dependencies": {
3464
+ "lie": "~3.3.0",
3465
+ "pako": "~1.0.2",
3466
+ "readable-stream": "~2.3.6",
3467
+ "setimmediate": "^1.0.5"
3468
+ }
3469
+ },
3470
  "node_modules/keyv": {
3471
  "version": "4.5.4",
3472
  "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
 
3491
  "node": ">= 0.8.0"
3492
  }
3493
  },
3494
+ "node_modules/lie": {
3495
+ "version": "3.3.0",
3496
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
3497
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
3498
+ "license": "MIT",
3499
+ "dependencies": {
3500
+ "immediate": "~3.0.5"
3501
+ }
3502
+ },
3503
  "node_modules/lightningcss": {
3504
  "version": "1.30.1",
3505
  "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
 
3984
  "url": "https://github.com/sponsors/sindresorhus"
3985
  }
3986
  },
3987
+ "node_modules/pako": {
3988
+ "version": "1.0.11",
3989
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
3990
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
3991
+ "license": "(MIT AND Zlib)"
3992
+ },
3993
  "node_modules/parent-module": {
3994
  "version": "1.0.1",
3995
  "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
 
4093
  "node": ">= 0.8.0"
4094
  }
4095
  },
4096
+ "node_modules/process-nextick-args": {
4097
+ "version": "2.0.1",
4098
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
4099
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
4100
+ "license": "MIT"
4101
+ },
4102
  "node_modules/prop-types": {
4103
  "version": "15.8.1",
4104
  "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
 
4344
  }
4345
  }
4346
  },
4347
+ "node_modules/readable-stream": {
4348
+ "version": "2.3.8",
4349
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
4350
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
4351
+ "license": "MIT",
4352
+ "dependencies": {
4353
+ "core-util-is": "~1.0.0",
4354
+ "inherits": "~2.0.3",
4355
+ "isarray": "~1.0.0",
4356
+ "process-nextick-args": "~2.0.0",
4357
+ "safe-buffer": "~5.1.1",
4358
+ "string_decoder": "~1.1.1",
4359
+ "util-deprecate": "~1.0.1"
4360
+ }
4361
+ },
4362
  "node_modules/resolve-from": {
4363
  "version": "4.0.0",
4364
  "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
 
4443
  "queue-microtask": "^1.2.2"
4444
  }
4445
  },
4446
+ "node_modules/safe-buffer": {
4447
+ "version": "5.1.2",
4448
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
4449
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
4450
+ "license": "MIT"
4451
+ },
4452
  "node_modules/sanitize-html": {
4453
  "version": "2.17.0",
4454
  "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
 
4485
  "node": ">=10"
4486
  }
4487
  },
4488
+ "node_modules/setimmediate": {
4489
+ "version": "1.0.5",
4490
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
4491
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
4492
+ "license": "MIT"
4493
+ },
4494
  "node_modules/shebang-command": {
4495
  "version": "2.0.0",
4496
  "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
 
4523
  "node": ">=0.10.0"
4524
  }
4525
  },
4526
+ "node_modules/string_decoder": {
4527
+ "version": "1.1.1",
4528
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
4529
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
4530
+ "license": "MIT",
4531
+ "dependencies": {
4532
+ "safe-buffer": "~5.1.0"
4533
+ }
4534
+ },
4535
  "node_modules/strip-json-comments": {
4536
  "version": "3.1.1",
4537
  "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
 
4808
  }
4809
  }
4810
  },
4811
+ "node_modules/util-deprecate": {
4812
+ "version": "1.0.2",
4813
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
4814
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
4815
+ "license": "MIT"
4816
+ },
4817
  "node_modules/vite": {
4818
  "version": "7.1.3",
4819
  "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
frontend/package.json CHANGED
@@ -30,6 +30,8 @@
30
  "dependencies": {
31
  "@ifrc-go/icons": "^2.0.1",
32
  "@ifrc-go/ui": "^1.3.0",
 
 
33
  "lucide-react": "^0.525.0",
34
  "react": "^18.2.0",
35
  "react-dom": "^18.2.0",
 
30
  "dependencies": {
31
  "@ifrc-go/icons": "^2.0.1",
32
  "@ifrc-go/ui": "^1.3.0",
33
+ "@types/jszip": "^3.4.0",
34
+ "jszip": "^3.10.1",
35
  "lucide-react": "^0.525.0",
36
  "react": "^18.2.0",
37
  "react-dom": "^18.2.0",
frontend/src/pages/AdminPage/AdminPage.tsx CHANGED
@@ -11,11 +11,11 @@ export default function AdminPage() {
11
  const [error, setError] = useState('');
12
  const [isLoggingIn, setIsLoggingIn] = useState(false);
13
 
14
- const [availableModels, setAvailableModels] = useState<Array<{
15
- m_code: string;
16
- label: string;
17
- model_type: string;
18
- is_available: boolean;
19
  provider?: string;
20
  model_id?: string;
21
  config?: {
@@ -24,7 +24,7 @@ export default function AdminPage() {
24
  model?: string;
25
  stub?: boolean;
26
  };
27
- }>>([]);
28
  const [selectedModel, setSelectedModel] = useState<string>('');
29
 
30
  // Model management state
@@ -57,31 +57,31 @@ export default function AdminPage() {
57
  }
58
  }, [isAuthenticated]);
59
 
60
- const fetchModels = () => {
61
- fetch('/api/models')
62
- .then(r => r.json())
63
- .then(modelsData => {
64
  console.log('Models data received:', modelsData);
65
- setAvailableModels(modelsData.models || []);
66
-
67
- const persistedModel = localStorage.getItem(SELECTED_MODEL_KEY);
68
- if (modelsData.models && modelsData.models.length > 0) {
69
- if (persistedModel === 'random') {
70
- // Keep random selection
71
- setSelectedModel('random');
72
- } else if (persistedModel && modelsData.models.find((m: { m_code: string; is_available: boolean }) => m.m_code === persistedModel && m.is_available)) {
73
- setSelectedModel(persistedModel);
74
- } else {
75
- const firstAvailableModel = modelsData.models.find((m: { is_available: boolean }) => m.is_available) || modelsData.models[0];
76
- setSelectedModel(firstAvailableModel.m_code);
77
- localStorage.setItem(SELECTED_MODEL_KEY, firstAvailableModel.m_code);
78
- }
79
- }
80
- })
81
- .catch(() => {
82
- // Handle error silently
83
- });
84
- };
85
 
86
  const toggleModelAvailability = async (modelCode: string, currentStatus: boolean) => {
87
  try {
@@ -317,9 +317,9 @@ Model "${newModelData.label}" added successfully!
317
  return (
318
  <PageContainer>
319
  <div className="mx-auto max-w-md px-4 sm:px-6 lg:px-8 py-6 sm:py-10">
320
- <div className="text-center mb-8">
321
  <Heading level={2}>Admin Login</Heading>
322
- </div>
323
 
324
  <form onSubmit={handleLogin} className="space-y-6">
325
  <div>
@@ -393,12 +393,12 @@ Model "${newModelData.label}" added successfully!
393
  <SelectInput
394
  label="Model"
395
  name="selected-model"
396
- value={selectedModel}
397
  onChange={(newValue) => handleModelChange(newValue || '')}
398
  options={[
399
  { value: 'random', label: 'Random' },
400
  ...availableModels
401
- .filter(model => model.is_available)
402
  .map(model => ({
403
  value: model.m_code,
404
  label: model.label
@@ -473,7 +473,7 @@ Model "${newModelData.label}" added successfully!
473
  Delete
474
  </Button>
475
  </div>
476
- </td>
477
  </tr>
478
  ))}
479
  </tbody>
@@ -490,7 +490,7 @@ Model "${newModelData.label}" added successfully!
490
  >
491
  Add New Model
492
  </Button>
493
- </div>
494
  )}
495
 
496
  {/* Add Model Form - now below the table */}
@@ -560,13 +560,13 @@ Model "${newModelData.label}" added successfully!
560
  >
561
  Save Model
562
  </Button>
563
- <Button
564
  name="cancel-add"
565
- variant="secondary"
566
  onClick={() => setShowAddModelForm(false)}
567
  >
568
  Cancel
569
- </Button>
570
  </div>
571
  </div>
572
  )}
@@ -639,10 +639,10 @@ Model "${newModelData.label}" added successfully!
639
  >
640
  Update Model
641
  </Button>
642
- <Button
643
  name="cancel-edit"
644
- variant="secondary"
645
- onClick={() => {
646
  setShowEditModelForm(false);
647
  setEditingModel(null);
648
  setNewModelData({
@@ -652,27 +652,27 @@ Model "${newModelData.label}" added successfully!
652
  provider: 'huggingface',
653
  model_id: '',
654
  is_available: false
655
- });
656
- }}
657
- >
658
  Cancel
659
- </Button>
660
- </div>
661
  </div>
662
  )}
663
- </div>
664
- </Container>
665
 
666
 
667
 
668
  {/* Utilities Section */}
669
- <Container
670
  heading="Utilities"
671
- headingLevel={2}
672
- withHeaderBorder
673
- withInternalPadding
674
- >
675
- <div className="flex flex-wrap gap-4">
676
  <Button
677
  name="test-connection"
678
  variant="secondary"
@@ -702,17 +702,17 @@ Model "${newModelData.label}" added successfully!
702
  Test Connection
703
  </Button>
704
 
705
- <Button
706
- name="view-schemas"
707
- variant="secondary"
708
- onClick={() => {
709
- fetch('/api/schemas', {
710
- headers: {
711
- 'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
712
- }
713
- })
714
- .then(r => r.json())
715
- .then(data => {
716
  console.log('Schemas Response:', data);
717
 
718
  let results = '';
@@ -741,13 +741,13 @@ Model "${newModelData.label}" added successfully!
741
  setTestResults(results);
742
  setTestResultsTitle('Schemas Error');
743
  setShowTestResultsModal(true);
744
- });
745
- }}
746
- >
747
- View Schemas
748
- </Button>
749
- </div>
750
- </Container>
751
  </div>
752
  </div>
753
 
 
11
  const [error, setError] = useState('');
12
  const [isLoggingIn, setIsLoggingIn] = useState(false);
13
 
14
+ const [availableModels, setAvailableModels] = useState<Array<{
15
+ m_code: string;
16
+ label: string;
17
+ model_type: string;
18
+ is_available: boolean;
19
  provider?: string;
20
  model_id?: string;
21
  config?: {
 
24
  model?: string;
25
  stub?: boolean;
26
  };
27
+ }>>([]);
28
  const [selectedModel, setSelectedModel] = useState<string>('');
29
 
30
  // Model management state
 
57
  }
58
  }, [isAuthenticated]);
59
 
60
+ const fetchModels = () => {
61
+ fetch('/api/models')
62
+ .then(r => r.json())
63
+ .then(modelsData => {
64
  console.log('Models data received:', modelsData);
65
+ setAvailableModels(modelsData.models || []);
66
+
67
+ const persistedModel = localStorage.getItem(SELECTED_MODEL_KEY);
68
+ if (modelsData.models && modelsData.models.length > 0) {
69
+ if (persistedModel === 'random') {
70
+ // Keep random selection
71
+ setSelectedModel('random');
72
+ } else if (persistedModel && modelsData.models.find((m: { m_code: string; is_available: boolean }) => m.m_code === persistedModel && m.is_available)) {
73
+ setSelectedModel(persistedModel);
74
+ } else {
75
+ const firstAvailableModel = modelsData.models.find((m: { is_available: boolean }) => m.is_available) || modelsData.models[0];
76
+ setSelectedModel(firstAvailableModel.m_code);
77
+ localStorage.setItem(SELECTED_MODEL_KEY, firstAvailableModel.m_code);
78
+ }
79
+ }
80
+ })
81
+ .catch(() => {
82
+ // Handle error silently
83
+ });
84
+ };
85
 
86
  const toggleModelAvailability = async (modelCode: string, currentStatus: boolean) => {
87
  try {
 
317
  return (
318
  <PageContainer>
319
  <div className="mx-auto max-w-md px-4 sm:px-6 lg:px-8 py-6 sm:py-10">
320
+ <div className="text-center mb-8">
321
  <Heading level={2}>Admin Login</Heading>
322
+ </div>
323
 
324
  <form onSubmit={handleLogin} className="space-y-6">
325
  <div>
 
393
  <SelectInput
394
  label="Model"
395
  name="selected-model"
396
+ value={selectedModel}
397
  onChange={(newValue) => handleModelChange(newValue || '')}
398
  options={[
399
  { value: 'random', label: 'Random' },
400
  ...availableModels
401
+ .filter(model => model.is_available)
402
  .map(model => ({
403
  value: model.m_code,
404
  label: model.label
 
473
  Delete
474
  </Button>
475
  </div>
476
+ </td>
477
  </tr>
478
  ))}
479
  </tbody>
 
490
  >
491
  Add New Model
492
  </Button>
493
+ </div>
494
  )}
495
 
496
  {/* Add Model Form - now below the table */}
 
560
  >
561
  Save Model
562
  </Button>
563
+ <Button
564
  name="cancel-add"
565
+ variant="secondary"
566
  onClick={() => setShowAddModelForm(false)}
567
  >
568
  Cancel
569
+ </Button>
570
  </div>
571
  </div>
572
  )}
 
639
  >
640
  Update Model
641
  </Button>
642
+ <Button
643
  name="cancel-edit"
644
+ variant="secondary"
645
+ onClick={() => {
646
  setShowEditModelForm(false);
647
  setEditingModel(null);
648
  setNewModelData({
 
652
  provider: 'huggingface',
653
  model_id: '',
654
  is_available: false
655
+ });
656
+ }}
657
+ >
658
  Cancel
659
+ </Button>
660
+ </div>
661
  </div>
662
  )}
663
+ </div>
664
+ </Container>
665
 
666
 
667
 
668
  {/* Utilities Section */}
669
+ <Container
670
  heading="Utilities"
671
+ headingLevel={2}
672
+ withHeaderBorder
673
+ withInternalPadding
674
+ >
675
+ <div className="flex flex-wrap gap-4">
676
  <Button
677
  name="test-connection"
678
  variant="secondary"
 
702
  Test Connection
703
  </Button>
704
 
705
+ <Button
706
+ name="view-schemas"
707
+ variant="secondary"
708
+ onClick={() => {
709
+ fetch('/api/schemas', {
710
+ headers: {
711
+ 'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
712
+ }
713
+ })
714
+ .then(r => r.json())
715
+ .then(data => {
716
  console.log('Schemas Response:', data);
717
 
718
  let results = '';
 
741
  setTestResults(results);
742
  setTestResultsTitle('Schemas Error');
743
  setShowTestResultsModal(true);
744
+ });
745
+ }}
746
+ >
747
+ View Schemas
748
+ </Button>
749
+ </div>
750
+ </Container>
751
  </div>
752
  </div>
753
 
frontend/src/pages/AnalyticsPage/AnalyticsPage.module.css CHANGED
@@ -93,6 +93,86 @@
93
  }
94
  }
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  @media (max-width: 768px) {
97
  .summaryStats {
98
  grid-template-columns: 1fr;
 
93
  }
94
  }
95
 
96
+ .userInteractionCards {
97
+ display: grid;
98
+ grid-template-columns: 1fr;
99
+ gap: var(--go-ui-spacing-lg);
100
+ }
101
+
102
+ .userInteractionCard {
103
+ background-color: var(--go-ui-color-white);
104
+ border-radius: var(--go-ui-border-radius-lg);
105
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
106
+ padding: var(--go-ui-spacing-xl);
107
+ text-align: center;
108
+ box-shadow: var(--go-ui-box-shadow-sm);
109
+ transition: box-shadow 0.2s ease-in-out;
110
+ }
111
+
112
+ .userInteractionCard:hover {
113
+ box-shadow: var(--go-ui-box-shadow-md);
114
+ }
115
+
116
+ .userInteractionCardValue {
117
+ font-size: var(--go-ui-font-size-2xl);
118
+ font-weight: var(--go-ui-font-weight-bold);
119
+ color: var(--go-ui-color-text);
120
+ margin-bottom: var(--go-ui-spacing-sm);
121
+ }
122
+
123
+ .userInteractionCardLabel {
124
+ font-size: var(--go-ui-font-size-sm);
125
+ color: var(--go-ui-color-text-secondary);
126
+ margin-bottom: var(--go-ui-spacing-lg);
127
+ }
128
+
129
+ .userInteractionCardButton {
130
+ width: 100%;
131
+ }
132
+
133
+ .summaryStatsCards {
134
+ display: grid;
135
+ grid-template-columns: 1fr;
136
+ gap: var(--go-ui-spacing-lg);
137
+ margin-bottom: var(--go-ui-spacing-lg);
138
+ }
139
+
140
+ .summaryStatsCard {
141
+ background-color: var(--go-ui-color-white);
142
+ border-radius: var(--go-ui-border-radius-lg);
143
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
144
+ padding: var(--go-ui-spacing-xl);
145
+ text-align: center;
146
+ box-shadow: var(--go-ui-box-shadow-sm);
147
+ transition: box-shadow 0.2s ease-in-out;
148
+ }
149
+
150
+ .summaryStatsCard:hover {
151
+ box-shadow: var(--go-ui-box-shadow-md);
152
+ }
153
+
154
+ .summaryStatsCardValue {
155
+ font-size: var(--go-ui-font-size-2xl);
156
+ font-weight: var(--go-ui-font-weight-bold);
157
+ color: var(--go-ui-color-text);
158
+ margin-bottom: var(--go-ui-spacing-sm);
159
+ }
160
+
161
+ .summaryStatsCardLabel {
162
+ font-size: var(--go-ui-font-size-sm);
163
+ color: var(--go-ui-color-text-secondary);
164
+ }
165
+
166
+ @media (min-width: 768px) {
167
+ .userInteractionCards {
168
+ grid-template-columns: repeat(3, 1fr);
169
+ }
170
+
171
+ .summaryStatsCards {
172
+ grid-template-columns: repeat(2, 1fr);
173
+ }
174
+ }
175
+
176
  @media (max-width: 768px) {
177
  .summaryStats {
178
  grid-template-columns: 1fr;
frontend/src/pages/AnalyticsPage/AnalyticsPage.tsx CHANGED
@@ -1,12 +1,7 @@
1
  import {
2
- PageContainer,
3
- PieChart,
4
- KeyFigure,
5
- Spinner,
6
- Container,
7
- ProgressBar,
8
- SegmentInput,
9
- Table,
10
  } from '@ifrc-go/ui';
11
  import {
12
  createStringColumn,
@@ -14,6 +9,7 @@ import {
14
  numericIdSelector
15
  } from '@ifrc-go/ui/utils';
16
  import { useState, useEffect, useMemo, useCallback } from 'react';
 
17
  import styles from './AnalyticsPage.module.css';
18
 
19
  interface AnalyticsData {
@@ -28,8 +24,17 @@ interface AnalyticsData {
28
  avgContext: number;
29
  avgUsability: number;
30
  totalScore: number;
 
31
  };
32
  };
 
 
 
 
 
 
 
 
33
  }
34
 
35
  interface LookupData {
@@ -70,6 +75,24 @@ interface ModelData {
70
  totalScore: number;
71
  }
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  interface MapData {
74
  source?: string;
75
  event_type?: string;
@@ -78,35 +101,83 @@ interface MapData {
78
  accuracy?: number;
79
  context?: number;
80
  usability?: number;
 
 
 
 
 
81
  }
82
 
83
  export default function AnalyticsPage() {
 
84
  const [data, setData] = useState<AnalyticsData | null>(null);
85
  const [loading, setLoading] = useState(true);
86
- const [view, setView] = useState<'general' | 'vlm'>('general');
87
  const [sourcesLookup, setSourcesLookup] = useState<LookupData[]>([]);
88
  const [typesLookup, setTypesLookup] = useState<LookupData[]>([]);
89
  const [regionsLookup, setRegionsLookup] = useState<LookupData[]>([]);
 
 
 
90
 
91
  const viewOptions = [
92
- { key: 'general' as const, label: 'General Analytics' },
93
- { key: 'vlm' as const, label: 'VLM Analytics' }
94
  ];
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  const fetchAnalytics = useCallback(async () => {
97
  setLoading(true);
98
  try {
99
- const res = await fetch('/api/images/');
100
  const maps = await res.json();
101
 
 
 
 
 
 
 
 
102
  const analytics: AnalyticsData = {
103
  totalCaptions: maps.length,
104
  sources: {},
105
  types: {},
106
  regions: {},
107
  models: {},
 
 
 
 
 
 
 
108
  };
109
 
 
110
  maps.forEach((map: MapData) => {
111
  if (map.source) analytics.sources[map.source] = (analytics.sources[map.source] || 0) + 1;
112
  if (map.event_type) analytics.types[map.event_type] = (analytics.types[map.event_type] || 0) + 1;
@@ -117,11 +188,22 @@ export default function AnalyticsPage() {
117
  }
118
  if (map.model) {
119
  const m = map.model;
120
- const ctr = analytics.models[m] ||= { count: 0, avgAccuracy: 0, avgContext: 0, avgUsability: 0, totalScore: 0 };
121
  ctr.count++;
122
  if (map.accuracy != null) ctr.avgAccuracy += map.accuracy;
123
  if (map.context != null) ctr.avgContext += map.context;
124
  if (map.usability != null) ctr.avgUsability += map.usability;
 
 
 
 
 
 
 
 
 
 
 
125
  }
126
  });
127
 
@@ -146,7 +228,7 @@ export default function AnalyticsPage() {
146
  const allModels = ['GPT-4', 'Claude', 'Gemini', 'Llama', 'Other'];
147
  allModels.forEach(model => {
148
  if (!analytics.models[model]) {
149
- analytics.models[model] = { count: 0, avgAccuracy: 0, avgContext: 0, avgUsability: 0, totalScore: 0 };
150
  }
151
  });
152
 
@@ -159,6 +241,60 @@ export default function AnalyticsPage() {
159
  }
160
  });
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  setData(analytics);
163
  } catch {
164
 
@@ -166,7 +302,7 @@ export default function AnalyticsPage() {
166
  } finally {
167
  setLoading(false);
168
  }
169
- }, [sourcesLookup, typesLookup, regionsLookup]);
170
 
171
  const fetchLookupData = useCallback(async () => {
172
  try {
@@ -181,10 +317,19 @@ export default function AnalyticsPage() {
181
  setSourcesLookup(sources);
182
  setTypesLookup(types);
183
  setRegionsLookup(regions);
184
- } catch {
 
185
  }
186
  }, []);
187
 
 
 
 
 
 
 
 
 
188
  useEffect(() => {
189
  fetchLookupData();
190
  }, []);
@@ -200,74 +345,109 @@ export default function AnalyticsPage() {
200
  return source ? source.label : code;
201
  }, [sourcesLookup]);
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  const getTypeLabel = useCallback((code: string) => {
204
  const type = typesLookup.find(t => t.t_code === code);
205
  return type ? type.label : code;
206
  }, [typesLookup]);
207
 
208
- const regionsTableData = useMemo(() => {
209
- if (!data || !regionsLookup.length) return [];
210
-
211
- const allRegions = regionsLookup.reduce((acc, region) => {
212
- if (region.r_code) {
213
- acc[region.r_code] = {
214
- name: region.label,
215
- count: data.regions[region.r_code] || 0
216
- };
217
- }
218
- return acc;
219
- }, {} as Record<string, { name: string; count: number }>);
220
-
221
- return Object.entries(allRegions)
222
- .sort(([,a], [,b]) => b.count - a.count)
223
- .map(([, { name, count }], index) => ({
224
- id: index + 1,
225
- name,
226
- count,
227
- percentage: data.totalCaptions > 0 ? Math.round((count / data.totalCaptions) * 100) : 0
228
- }));
229
- }, [data, regionsLookup]);
230
-
231
- const typesTableData = useMemo(() => {
232
  if (!data) return [];
233
 
234
- return Object.entries(data.types)
235
- .sort(([,a], [,b]) => b - a)
236
- .map(([typeKey, count], index) => ({
 
237
  id: index + 1,
238
- name: getTypeLabel(typeKey),
239
- count,
240
- percentage: Math.round((count / data.totalCaptions) * 100)
 
 
241
  }));
242
- }, [data, getTypeLabel]);
243
 
244
- const sourcesTableData = useMemo(() => {
245
  if (!data) return [];
246
 
247
- return Object.entries(data.sources)
248
- .sort(([,a], [,b]) => b - a)
249
- .map(([sourceKey, count], index) => ({
250
- id: index + 1,
251
- name: getSourceLabel(sourceKey),
252
- count,
253
- percentage: Math.round((count / data.totalCaptions) * 100)
254
- }));
255
- }, [data, getSourceLabel]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
- const modelsTableData = useMemo(() => {
 
258
  if (!data) return [];
259
 
260
  return Object.entries(data.models)
261
- .sort(([,a], [,b]) => b.totalScore - a.totalScore)
262
- .map(([model, stats], index) => ({
263
- id: index + 1,
264
- name: model,
265
- count: stats.count,
266
- accuracy: stats.avgAccuracy,
267
- context: stats.avgContext,
268
- usability: stats.avgUsability,
269
- totalScore: stats.totalScore
270
- }));
 
 
 
 
 
 
 
271
  }, [data]);
272
 
273
  const regionsColumns = useMemo(() => [
@@ -383,8 +563,423 @@ export default function AnalyticsPage() {
383
  maximumFractionDigits: 0,
384
  },
385
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  ], []);
387
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  if (loading) {
389
  return (
390
  <PageContainer>
@@ -405,9 +1000,7 @@ export default function AnalyticsPage() {
405
  );
406
  }
407
 
408
- const sourcesChartData = Object.entries(data.sources).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value }));
409
- const typesChartData = Object.entries(data.types).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value }));
410
- const regionsChartData = Object.entries(data.regions).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value }));
411
 
412
  const ifrcColors = [
413
  '#F5333F', '#F64752', '#F75C65', '#F87079', '#F9858C', '#FA999F', '#FBADB2', '#FCC2C5'
@@ -421,7 +1014,7 @@ export default function AnalyticsPage() {
421
  name="analytics-view"
422
  value={view}
423
  onChange={(value) => {
424
- if (value === 'general' || value === 'vlm') {
425
  setView(value);
426
  }
427
  }}
@@ -431,27 +1024,29 @@ export default function AnalyticsPage() {
431
  />
432
  </div>
433
 
434
- {view === 'general' ? (
435
  <div className={styles.chartGrid}>
436
  <Container heading="Summary Statistics" headingLevel={3} withHeaderBorder withInternalPadding>
437
- <div className={styles.summaryStats}>
438
- <KeyFigure
439
- value={data.totalCaptions}
440
- label="Total Captions"
441
- compactValue
442
- />
443
- <KeyFigure
444
- value={2000}
445
- label="Target Amount"
446
- compactValue
447
- />
 
 
448
  </div>
449
  <div className={styles.progressSection}>
450
  <div className={styles.progressLabel}>
451
  <span>Progress towards target</span>
452
- <span>{Math.round((data.totalCaptions / 2000) * 100)}%</span>
453
  </div>
454
- <ProgressBar value={data.totalCaptions} totalValue={2000} />
455
  </div>
456
  </Container>
457
 
@@ -459,7 +1054,7 @@ export default function AnalyticsPage() {
459
  <div className={styles.chartSection}>
460
  <div className={styles.chartContainer}>
461
  <PieChart
462
- data={regionsChartData}
463
  valueSelector={d => d.value}
464
  labelSelector={d => d.name}
465
  keySelector={d => d.name}
@@ -469,7 +1064,7 @@ export default function AnalyticsPage() {
469
  </div>
470
  <div className={styles.tableContainer}>
471
  <Table
472
- data={regionsTableData}
473
  columns={regionsColumns}
474
  keySelector={numericIdSelector}
475
  filtered={false}
@@ -483,7 +1078,7 @@ export default function AnalyticsPage() {
483
  <div className={styles.chartSection}>
484
  <div className={styles.chartContainer}>
485
  <PieChart
486
- data={sourcesChartData}
487
  valueSelector={d => d.value}
488
  labelSelector={d => d.name}
489
  keySelector={d => d.name}
@@ -493,7 +1088,7 @@ export default function AnalyticsPage() {
493
  </div>
494
  <div className={styles.tableContainer}>
495
  <Table
496
- data={sourcesTableData}
497
  columns={sourcesColumns}
498
  keySelector={numericIdSelector}
499
  filtered={false}
@@ -507,7 +1102,7 @@ export default function AnalyticsPage() {
507
  <div className={styles.chartSection}>
508
  <div className={styles.chartContainer}>
509
  <PieChart
510
- data={typesChartData}
511
  valueSelector={d => d.value}
512
  labelSelector={d => d.name}
513
  keySelector={d => d.name}
@@ -517,7 +1112,7 @@ export default function AnalyticsPage() {
517
  </div>
518
  <div className={styles.tableContainer}>
519
  <Table
520
- data={typesTableData}
521
  columns={typesColumns}
522
  keySelector={numericIdSelector}
523
  filtered={false}
@@ -525,14 +1120,284 @@ export default function AnalyticsPage() {
525
  />
526
  </div>
527
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
528
  </Container>
529
  </div>
530
  ) : (
531
  <div className={styles.chartGrid}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  <Container heading="Model Performance" headingLevel={3} withHeaderBorder withInternalPadding>
533
  <div className={styles.modelPerformance}>
534
  <Table
535
- data={modelsTableData}
536
  columns={modelsColumns}
537
  keySelector={numericIdSelector}
538
  filtered={false}
@@ -540,9 +1405,39 @@ export default function AnalyticsPage() {
540
  />
541
  </div>
542
  </Container>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  </div>
544
  )}
545
  </div>
 
 
546
  </PageContainer>
547
  );
548
  }
 
1
  import {
2
+ PageContainer, Button,
3
+ Container, Spinner, SegmentInput,
4
+ Table, PieChart, ProgressBar,
 
 
 
 
 
5
  } from '@ifrc-go/ui';
6
  import {
7
  createStringColumn,
 
9
  numericIdSelector
10
  } from '@ifrc-go/ui/utils';
11
  import { useState, useEffect, useMemo, useCallback } from 'react';
12
+ import { useSearchParams } from 'react-router-dom';
13
  import styles from './AnalyticsPage.module.css';
14
 
15
  interface AnalyticsData {
 
24
  avgContext: number;
25
  avgUsability: number;
26
  totalScore: number;
27
+ deleteCount: number;
28
  };
29
  };
30
+ modelEditTimes: { [key: string]: number[] };
31
+ percentageModified: number;
32
+ modelPercentageData: { [key: string]: number[] };
33
+ totalDeleteCount: number;
34
+ deleteRate: number;
35
+ // Add separated image data for proper filtering
36
+ crisisMaps: MapData[];
37
+ droneImages: MapData[];
38
  }
39
 
40
  interface LookupData {
 
75
  totalScore: number;
76
  }
77
 
78
+ interface EditTimeData {
79
+ id: number;
80
+ name: string;
81
+ count: number;
82
+ avgEditTime: number;
83
+ minEditTime: number;
84
+ maxEditTime: number;
85
+ }
86
+
87
+ interface PercentageModifiedData {
88
+ id: number;
89
+ name: string;
90
+ count: number;
91
+ avgPercentageModified: number;
92
+ minPercentageModified: number;
93
+ maxPercentageModified: number;
94
+ }
95
+
96
  interface MapData {
97
  source?: string;
98
  event_type?: string;
 
101
  accuracy?: number;
102
  context?: number;
103
  usability?: number;
104
+ created_at?: string;
105
+ updated_at?: string;
106
+ generated?: string;
107
+ edited?: string;
108
+ image_type?: string;
109
  }
110
 
111
  export default function AnalyticsPage() {
112
+ const [searchParams] = useSearchParams();
113
  const [data, setData] = useState<AnalyticsData | null>(null);
114
  const [loading, setLoading] = useState(true);
115
+ const [view, setView] = useState<'crisis_maps' | 'drone_images'>('crisis_maps');
116
  const [sourcesLookup, setSourcesLookup] = useState<LookupData[]>([]);
117
  const [typesLookup, setTypesLookup] = useState<LookupData[]>([]);
118
  const [regionsLookup, setRegionsLookup] = useState<LookupData[]>([]);
119
+ const [showEditTimeModal, setShowEditTimeModal] = useState(false);
120
+ const [showPercentageModal, setShowPercentageModal] = useState(false);
121
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
122
 
123
  const viewOptions = [
124
+ { key: 'crisis_maps' as const, label: 'Crisis Maps' },
125
+ { key: 'drone_images' as const, label: 'Drone Images' }
126
  ];
127
 
128
+ // Helper function to calculate word similarity
129
+ const calculateWordSimilarity = useCallback((text1: string, text2: string): number => {
130
+ if (!text1 || !text2) return 0;
131
+
132
+ // Split into words, lowercase, and remove punctuation
133
+ const words1 = text1.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(word => word.length > 0);
134
+ const words2 = text2.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(word => word.length > 0);
135
+
136
+ if (words1.length === 0 && words2.length === 0) return 1; // Both empty = 100% similar
137
+ if (words1.length === 0 || words2.length === 0) return 0; // One empty = 0% similar
138
+
139
+ // Create sets of unique words
140
+ const set1 = new Set(words1);
141
+ const set2 = new Set(words2);
142
+
143
+ // Calculate intersection and union
144
+ const intersection = new Set([...set1].filter(word => set2.has(word)));
145
+ const union = new Set([...set1, ...set2]);
146
+
147
+ // Calculate similarity
148
+ const similarity = intersection.size / union.size;
149
+ return similarity;
150
+ }, []);
151
+
152
  const fetchAnalytics = useCallback(async () => {
153
  setLoading(true);
154
  try {
155
+ const res = await fetch('/api/images');
156
  const maps = await res.json();
157
 
158
+ // Calculate edit times for each model
159
+ const modelEditTimes: { [key: string]: number[] } = {};
160
+
161
+ // Separate images by type for proper filtering
162
+ const crisisMaps = maps.filter((map: MapData) => map.image_type === 'crisis_map');
163
+ const droneImages = maps.filter((map: MapData) => map.image_type === 'drone_image');
164
+
165
  const analytics: AnalyticsData = {
166
  totalCaptions: maps.length,
167
  sources: {},
168
  types: {},
169
  regions: {},
170
  models: {},
171
+ modelEditTimes: modelEditTimes,
172
+ percentageModified: 0,
173
+ modelPercentageData: {},
174
+ totalDeleteCount: 0,
175
+ deleteRate: 0,
176
+ crisisMaps: crisisMaps,
177
+ droneImages: droneImages,
178
  };
179
 
180
+ // Process all images for global analytics
181
  maps.forEach((map: MapData) => {
182
  if (map.source) analytics.sources[map.source] = (analytics.sources[map.source] || 0) + 1;
183
  if (map.event_type) analytics.types[map.event_type] = (analytics.types[map.event_type] || 0) + 1;
 
188
  }
189
  if (map.model) {
190
  const m = map.model;
191
+ const ctr = analytics.models[m] ||= { count: 0, avgAccuracy: 0, avgContext: 0, avgUsability: 0, totalScore: 0, deleteCount: 0 };
192
  ctr.count++;
193
  if (map.accuracy != null) ctr.avgAccuracy += map.accuracy;
194
  if (map.context != null) ctr.avgContext += map.context;
195
  if (map.usability != null) ctr.avgUsability += map.usability;
196
+
197
+ // Calculate edit time if both timestamps exist
198
+ if (map.created_at && map.updated_at) {
199
+ const created = new Date(map.created_at).getTime();
200
+ const updated = new Date(map.updated_at).getTime();
201
+ const editTimeMs = updated - created;
202
+ if (editTimeMs > 0) {
203
+ if (!modelEditTimes[m]) modelEditTimes[m] = [];
204
+ modelEditTimes[m].push(editTimeMs);
205
+ }
206
+ }
207
  }
208
  });
209
 
 
228
  const allModels = ['GPT-4', 'Claude', 'Gemini', 'Llama', 'Other'];
229
  allModels.forEach(model => {
230
  if (!analytics.models[model]) {
231
+ analytics.models[model] = { count: 0, avgAccuracy: 0, avgContext: 0, avgUsability: 0, totalScore: 0, deleteCount: 0 };
232
  }
233
  });
234
 
 
241
  }
242
  });
243
 
244
+ // Calculate percentage modified (median)
245
+ const textPairs = maps.filter((map: MapData) => map.generated && map.edited);
246
+
247
+ if (textPairs.length > 0) {
248
+ const similarities = textPairs.map((map: MapData) =>
249
+ calculateWordSimilarity(map.generated!, map.edited!)
250
+ );
251
+ const sortedSimilarities = [...similarities].sort((a, b) => a - b);
252
+ const mid = Math.floor(sortedSimilarities.length / 2);
253
+ const medianSimilarity = sortedSimilarities.length % 2 === 0
254
+ ? (sortedSimilarities[mid - 1] + sortedSimilarities[mid]) / 2
255
+ : sortedSimilarities[mid];
256
+ analytics.percentageModified = Math.round((1 - medianSimilarity) * 100);
257
+ }
258
+
259
+ // Calculate percentage modified per model (median)
260
+ const modelPercentageData: { [key: string]: number[] } = {};
261
+
262
+ maps.forEach((map: MapData) => {
263
+ if (map.model && map.generated && map.edited) {
264
+ const similarity = calculateWordSimilarity(map.generated, map.edited);
265
+ const percentageModified = Math.round((1 - similarity) * 100);
266
+
267
+ if (!modelPercentageData[map.model]) {
268
+ modelPercentageData[map.model] = [];
269
+ }
270
+ modelPercentageData[map.model].push(percentageModified);
271
+ }
272
+ });
273
+
274
+ analytics.modelPercentageData = modelPercentageData;
275
+
276
+ // Fetch model data including delete counts
277
+ try {
278
+ const modelsRes = await fetch('/api/admin/models');
279
+ if (modelsRes.ok) {
280
+ const modelsData = await modelsRes.json();
281
+
282
+ // Update delete counts for each model
283
+ modelsData.forEach((model: { m_code: string; delete_count: number }) => {
284
+ if (analytics.models[model.m_code]) {
285
+ analytics.models[model.m_code].deleteCount = model.delete_count || 0;
286
+ }
287
+ });
288
+
289
+ // Calculate total delete count and delete rate
290
+ const totalDeleteCount = modelsData.reduce((sum: number, model: { delete_count: number }) => sum + (model.delete_count || 0), 0);
291
+ analytics.totalDeleteCount = totalDeleteCount;
292
+ analytics.deleteRate = totalDeleteCount > 0 ? Math.round((totalDeleteCount / (totalDeleteCount + maps.length)) * 100) : 0;
293
+ }
294
+ } catch (error) {
295
+ console.log('Could not fetch model delete counts:', error);
296
+ }
297
+
298
  setData(analytics);
299
  } catch {
300
 
 
302
  } finally {
303
  setLoading(false);
304
  }
305
+ }, [sourcesLookup, typesLookup, regionsLookup, calculateWordSimilarity]);
306
 
307
  const fetchLookupData = useCallback(async () => {
308
  try {
 
317
  setSourcesLookup(sources);
318
  setTypesLookup(types);
319
  setRegionsLookup(regions);
320
+ } catch (error) {
321
+ console.log('Could not fetch lookup data:', error);
322
  }
323
  }, []);
324
 
325
+ // Set initial view based on URL parameter
326
+ useEffect(() => {
327
+ const viewParam = searchParams.get('view');
328
+ if (viewParam === 'crisis_maps' || viewParam === 'drone_images') {
329
+ setView(viewParam);
330
+ }
331
+ }, [searchParams]);
332
+
333
  useEffect(() => {
334
  fetchLookupData();
335
  }, []);
 
345
  return source ? source.label : code;
346
  }, [sourcesLookup]);
347
 
348
+ const getMedianEditTime = useCallback((editTimes: number[]) => {
349
+ if (editTimes.length === 0) return 0;
350
+ const sortedTimes = [...editTimes].sort((a, b) => a - b);
351
+ const mid = Math.floor(sortedTimes.length / 2);
352
+ return sortedTimes.length % 2 === 0
353
+ ? Math.round((sortedTimes[mid - 1] + sortedTimes[mid]) / 2)
354
+ : sortedTimes[mid];
355
+ }, []);
356
+
357
+ const formatEditTime = useCallback((ms: number) => {
358
+ const seconds = Math.floor(ms / 1000);
359
+ const minutes = Math.floor(seconds / 60);
360
+ const hours = Math.floor(minutes / 60);
361
+
362
+ if (hours > 0) {
363
+ return `${hours}h ${minutes % 60}m`;
364
+ } else if (minutes > 0) {
365
+ return `${minutes}m ${seconds % 60}s`;
366
+ } else {
367
+ return `${seconds}s`;
368
+ }
369
+ }, []);
370
+
371
  const getTypeLabel = useCallback((code: string) => {
372
  const type = typesLookup.find(t => t.t_code === code);
373
  return type ? type.label : code;
374
  }, [typesLookup]);
375
 
376
+ const editTimeTableData = useMemo(() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  if (!data) return [];
378
 
379
+ return Object.entries(data.modelEditTimes || {})
380
+ .filter(([, editTimes]) => editTimes.length > 0)
381
+ .sort(([, a], [, b]) => getMedianEditTime(b) - getMedianEditTime(a))
382
+ .map(([model, editTimes], index) => ({
383
  id: index + 1,
384
+ name: model,
385
+ count: editTimes.length,
386
+ avgEditTime: getMedianEditTime(editTimes),
387
+ minEditTime: Math.min(...editTimes),
388
+ maxEditTime: Math.max(...editTimes)
389
  }));
390
+ }, [data, getMedianEditTime]);
391
 
392
+ const percentageModifiedTableData = useMemo(() => {
393
  if (!data) return [];
394
 
395
+ return Object.entries(data.modelPercentageData || {})
396
+ .filter(([, percentages]) => percentages.length > 0)
397
+ .sort(([, a], [, b]) => {
398
+ const sortedA = [...a].sort((x, y) => x - y);
399
+ const sortedB = [...b].sort((x, y) => x - y);
400
+ const midA = Math.floor(sortedA.length / 2);
401
+ const midB = Math.floor(sortedB.length / 2);
402
+ const medianA = sortedA.length % 2 === 0
403
+ ? (sortedA[midA - 1] + sortedA[midA]) / 2
404
+ : sortedA[midA];
405
+ const medianB = sortedB.length % 2 === 0
406
+ ? (sortedB[midB - 1] + sortedB[midB]) / 2
407
+ : sortedB[midB];
408
+ return medianB - medianA;
409
+ })
410
+ .map(([model, percentages], index) => {
411
+ const sortedPercentages = [...percentages].sort((a, b) => a - b);
412
+ const mid = Math.floor(sortedPercentages.length / 2);
413
+ const medianPercentage = sortedPercentages.length % 2 === 0
414
+ ? Math.round((sortedPercentages[mid - 1] + sortedPercentages[mid]) / 2)
415
+ : sortedPercentages[mid];
416
+
417
+ return {
418
+ id: index + 1,
419
+ name: model,
420
+ count: percentages.length,
421
+ avgPercentageModified: medianPercentage,
422
+ minPercentageModified: Math.min(...percentages),
423
+ maxPercentageModified: Math.max(...percentages)
424
+ };
425
+ });
426
+ }, [data]);
427
+
428
 
429
+
430
+ const modelConsistencyData = useMemo(() => {
431
  if (!data) return [];
432
 
433
  return Object.entries(data.models)
434
+ .filter(([, model]) => model.count > 0)
435
+ .map(([name, model], index) => {
436
+ // Calculate consistency based on how close accuracy, context, and usability are
437
+ const scores = [model.avgAccuracy, model.avgContext, model.avgUsability];
438
+ const mean = scores.reduce((sum, score) => sum + score, 0) / scores.length;
439
+ const variance = scores.reduce((sum, score) => sum + Math.pow(score - mean, 2), 0) / scores.length;
440
+ const consistency = Math.round(100 - Math.sqrt(variance)); // Lower variance = higher consistency
441
+
442
+ return {
443
+ id: index + 1,
444
+ name,
445
+ consistency: Math.max(0, consistency),
446
+ avgScore: Math.round(mean),
447
+ count: model.count
448
+ };
449
+ })
450
+ .sort((a, b) => b.consistency - a.consistency);
451
  }, [data]);
452
 
453
  const regionsColumns = useMemo(() => [
 
563
  maximumFractionDigits: 0,
564
  },
565
  ),
566
+
567
+ ], [formatEditTime]);
568
+
569
+ const editTimeColumns = useMemo(() => [
570
+ createStringColumn<EditTimeData, number>(
571
+ 'name',
572
+ 'Model',
573
+ (item) => item.name,
574
+ ),
575
+ createNumberColumn<EditTimeData, number>(
576
+ 'count',
577
+ 'Count',
578
+ (item) => item.count,
579
+ ),
580
+ createStringColumn<EditTimeData, number>(
581
+ 'avgEditTime',
582
+ 'Median Edit Time',
583
+ (item) => formatEditTime(item.avgEditTime),
584
+ ),
585
+ createStringColumn<EditTimeData, number>(
586
+ 'minEditTime',
587
+ 'Min Edit Time',
588
+ (item) => formatEditTime(item.minEditTime),
589
+ ),
590
+ createStringColumn<EditTimeData, number>(
591
+ 'maxEditTime',
592
+ 'Max Edit Time',
593
+ (item) => formatEditTime(item.maxEditTime),
594
+ ),
595
+ ], [formatEditTime]);
596
+
597
+ const percentageModifiedColumns = useMemo(() => [
598
+ createStringColumn<PercentageModifiedData, number>(
599
+ 'name',
600
+ 'Model',
601
+ (item) => item.name,
602
+ ),
603
+ createNumberColumn<PercentageModifiedData, number>(
604
+ 'count',
605
+ 'Count',
606
+ (item) => item.count,
607
+ ),
608
+ createNumberColumn<PercentageModifiedData, number>(
609
+ 'avgPercentageModified',
610
+ 'Median % Modified',
611
+ (item) => item.avgPercentageModified,
612
+ {
613
+ suffix: '%',
614
+ maximumFractionDigits: 0,
615
+ },
616
+ ),
617
+ createNumberColumn<PercentageModifiedData, number>(
618
+ 'minPercentageModified',
619
+ 'Min % Modified',
620
+ (item) => item.minPercentageModified,
621
+ {
622
+ suffix: '%',
623
+ maximumFractionDigits: 0,
624
+ },
625
+ ),
626
+ createNumberColumn<PercentageModifiedData, number>(
627
+ 'maxPercentageModified',
628
+ 'Max % Modified',
629
+ (item) => item.maxPercentageModified,
630
+ {
631
+ suffix: '%',
632
+ maximumFractionDigits: 0,
633
+ },
634
+ ),
635
  ], []);
636
 
637
+
638
+
639
+ const qualityBySourceColumns = useMemo(() => [
640
+ createStringColumn<{ source: string; avgQuality: number; count: number }, number>(
641
+ 'source',
642
+ 'Source',
643
+ (item) => item.source,
644
+ ),
645
+ createNumberColumn<{ source: string; avgQuality: number; count: number }, number>(
646
+ 'avgQuality',
647
+ 'Average Quality',
648
+ (item) => item.avgQuality,
649
+ {
650
+ suffix: '%',
651
+ maximumFractionDigits: 0,
652
+ },
653
+ ),
654
+ createNumberColumn<{ source: string; avgQuality: number; count: number }, number>(
655
+ 'count',
656
+ 'Count',
657
+ (item) => item.count,
658
+ ),
659
+ ], []);
660
+
661
+ const modelConsistencyColumns = useMemo(() => [
662
+ createStringColumn<{ name: string; consistency: number; avgScore: number; count: number }, number>(
663
+ 'name',
664
+ 'Model',
665
+ (item) => item.name,
666
+ ),
667
+ createNumberColumn<{ name: string; consistency: number; avgScore: number; count: number }, number>(
668
+ 'consistency',
669
+ 'Consistency',
670
+ (item) => item.consistency,
671
+ {
672
+ suffix: '%',
673
+ maximumFractionDigits: 0,
674
+ },
675
+ ),
676
+ createNumberColumn<{ name: string; consistency: number; avgScore: number; count: number }, number>(
677
+ 'avgScore',
678
+ 'Average Score',
679
+ (item) => item.avgScore,
680
+ {
681
+ suffix: '%',
682
+ maximumFractionDigits: 0,
683
+ },
684
+ ),
685
+ createNumberColumn<{ name: string; consistency: number; avgScore: number; count: number }, number>(
686
+ 'count',
687
+ 'Count',
688
+ (item) => item.count,
689
+ ),
690
+ ], []);
691
+
692
+ // Helper functions to filter data by image type
693
+ const getImageTypeCount = useCallback((imageType: string) => {
694
+ if (!data) return 0;
695
+
696
+ if (imageType === 'crisis_map') {
697
+ return data.crisisMaps.length;
698
+ } else if (imageType === 'drone_image') {
699
+ return data.droneImages.length;
700
+ }
701
+
702
+ return 0;
703
+ }, [data]);
704
+
705
+ const getImageTypeRegionsChartData = useCallback((imageType: string) => {
706
+ if (!data) return [];
707
+
708
+ // Get the appropriate image set based on type
709
+ const images = imageType === 'crisis_map' ? data.crisisMaps : data.droneImages;
710
+
711
+ // Calculate regions for this specific image type
712
+ const regions: { [key: string]: number } = {};
713
+ images.forEach((map: MapData) => {
714
+ if (map.countries) {
715
+ map.countries.forEach((c) => {
716
+ if (c.r_code) regions[c.r_code] = (regions[c.r_code] || 0) + 1;
717
+ });
718
+ }
719
+ });
720
+
721
+ return Object.entries(regions)
722
+ .filter(([, value]) => value > 0)
723
+ .map(([name, value]) => ({ name, value }));
724
+ }, [data]);
725
+
726
+ const getImageTypeRegionsTableData = useCallback((imageType: string) => {
727
+ if (!data) return [];
728
+
729
+ // Get the appropriate image set based on type
730
+ const images = imageType === 'crisis_map' ? data.crisisMaps : data.droneImages;
731
+
732
+ // Calculate regions for this specific image type
733
+ const regions: { [key: string]: number } = {};
734
+ images.forEach((map: MapData) => {
735
+ if (map.countries) {
736
+ map.countries.forEach((c) => {
737
+ if (c.r_code) regions[c.r_code] = (regions[c.r_code] || 0) + 1;
738
+ });
739
+ }
740
+ });
741
+
742
+ // Convert to table data format
743
+ const allRegions = regionsLookup.reduce((acc, region) => {
744
+ if (region.r_code) {
745
+ acc[region.r_code] = {
746
+ name: region.label,
747
+ count: regions[region.r_code] || 0
748
+ };
749
+ }
750
+ return acc;
751
+ }, {} as Record<string, { name: string; count: number }>);
752
+
753
+ return Object.entries(allRegions)
754
+ .sort(([,a], [,b]) => b.count - a.count)
755
+ .map(([, { name, count }], index) => ({
756
+ id: index + 1,
757
+ name,
758
+ count,
759
+ percentage: images.length > 0 ? Math.round((count / images.length) * 100) : 0
760
+ }));
761
+ }, [data, regionsLookup]);
762
+
763
+ const getImageTypeSourcesChartData = useCallback((imageType: string) => {
764
+ if (!data) return [];
765
+
766
+ // Get the appropriate image set based on type
767
+ const images = imageType === 'crisis_map' ? data.crisisMaps : data.droneImages;
768
+
769
+ // Calculate sources for this specific image type
770
+ const sources: { [key: string]: number } = {};
771
+ images.forEach((map: MapData) => {
772
+ if (map.source) sources[map.source] = (sources[map.source] || 0) + 1;
773
+ });
774
+
775
+ return Object.entries(sources)
776
+ .filter(([, value]) => value > 0)
777
+ .map(([name, value]) => ({ name, value }));
778
+ }, [data]);
779
+
780
+ const getImageTypeSourcesTableData = useCallback((imageType: string) => {
781
+ if (!data) return [];
782
+
783
+ // Get the appropriate image set based on type
784
+ const images = imageType === 'crisis_map' ? data.crisisMaps : data.droneImages;
785
+
786
+ // Calculate sources for this specific image type
787
+ const sources: { [key: string]: number } = {};
788
+ images.forEach((map: MapData) => {
789
+ if (map.source) sources[map.source] = (sources[map.source] || 0) + 1;
790
+ });
791
+
792
+ // Convert to table data format
793
+ return Object.entries(sources)
794
+ .sort(([,a], [,b]) => b - a)
795
+ .map(([sourceKey, count], index) => ({
796
+ id: index + 1,
797
+ name: getSourceLabel(sourceKey),
798
+ count,
799
+ percentage: images.length > 0 ? Math.round((count / images.length) * 100) : 0
800
+ }));
801
+ }, [data, getSourceLabel]);
802
+
803
+ const getImageTypeTypesChartData = useCallback((imageType: string) => {
804
+ if (!data) return [];
805
+
806
+ // Get the appropriate image set based on type
807
+ const images = imageType === 'crisis_map' ? data.crisisMaps : data.droneImages;
808
+
809
+ // Calculate types for this specific image type
810
+ const types: { [key: string]: number } = {};
811
+ images.forEach((map: MapData) => {
812
+ if (map.event_type) types[map.event_type] = (types[map.event_type] || 0) + 1;
813
+ });
814
+
815
+ return Object.entries(types)
816
+ .filter(([, value]) => value > 0)
817
+ .map(([name, value]) => ({ name, value }));
818
+ }, [data]);
819
+
820
+ const getImageTypeTypesTableData = useCallback((imageType: string) => {
821
+ if (!data) return [];
822
+
823
+ // Get the appropriate image set based on type
824
+ const images = imageType === 'crisis_map' ? data.crisisMaps : data.droneImages;
825
+
826
+ // Calculate types for this specific image type
827
+ const types: { [key: string]: number } = {};
828
+ images.forEach((map: MapData) => {
829
+ if (map.event_type) types[map.event_type] = (types[map.event_type] || 0) + 1;
830
+ });
831
+
832
+ // Convert to table data format
833
+ return Object.entries(types)
834
+ .sort(([,a], [,b]) => b - a)
835
+ .map(([typeKey, count], index) => ({
836
+ id: index + 1,
837
+ name: getTypeLabel(typeKey),
838
+ count,
839
+ percentage: images.length > 0 ? Math.round((count / images.length) * 100) : 0
840
+ }));
841
+ }, [data, getTypeLabel]);
842
+
843
+ const getImageTypeMedianEditTime = useCallback((imageType: string) => {
844
+ if (!data) return 'No data available';
845
+ // Filter edit times by image type
846
+ const filteredEditTimes = Object.entries(data.modelEditTimes).filter(([modelName]) => {
847
+ if (imageType === 'crisis_map') {
848
+ return modelName.includes('GPT') || modelName.includes('Claude') || modelName.includes('Gemini') || modelName.includes('STUB');
849
+ } else if (imageType === 'drone_image') {
850
+ return modelName.includes('Llama') || modelName.includes('Other');
851
+ }
852
+ return true;
853
+ });
854
+
855
+ const editTimes = filteredEditTimes.flatMap(([, times]) => times);
856
+ if (editTimes.length === 0) return 'No data available';
857
+ return formatEditTime(getMedianEditTime(editTimes));
858
+ }, [data, formatEditTime, getMedianEditTime]);
859
+
860
+ const getImageTypePercentageModified = useCallback(() => {
861
+ if (!data) return 'No data available';
862
+ const total = data.totalCaptions || 0;
863
+ const modified = data.percentageModified || 0;
864
+
865
+ return total > 0 ? Math.round((modified / total) * 100) : 0;
866
+ }, [data]);
867
+
868
+ const getImageTypeDeleteRate = useCallback(() => {
869
+ if (!data) return 'No data available';
870
+ // For now, we'll return the global delete rate since we don't have image_type filtering in the backend
871
+ // In a real implementation, you'd calculate this based on filtered data
872
+ return data.deleteRate >= 0 ? `${data.deleteRate}%` : 'No data available';
873
+ }, [data]);
874
+
875
+ const getImageTypeEditTimeTableData = useCallback((imageType: string) => {
876
+ if (!data) return [];
877
+ // Filter edit time data by image type
878
+ const filteredData = editTimeTableData.filter(d => {
879
+ if (imageType === 'crisis_map') {
880
+ return d.name.includes('GPT') || d.name.includes('Claude') || d.name.includes('Gemini') || d.name.includes('STUB');
881
+ } else if (imageType === 'drone_image') {
882
+ return d.name.includes('Llama') || d.name.includes('Other');
883
+ }
884
+ return true;
885
+ });
886
+ return filteredData;
887
+ }, [data, editTimeTableData]);
888
+
889
+ const getImageTypePercentageTableData = useCallback((imageType: string) => {
890
+ if (!data) return [];
891
+ // Filter percentage data by image type
892
+ const filteredData = percentageModifiedTableData.filter(d => {
893
+ if (imageType === 'crisis_map') {
894
+ return d.name.includes('GPT') || d.name.includes('Claude') || d.name.includes('Gemini') || d.name.includes('STUB');
895
+ } else if (imageType === 'drone_image') {
896
+ return d.name.includes('Llama') || d.name.includes('Other');
897
+ }
898
+ return true;
899
+ });
900
+ return filteredData;
901
+ }, [data, percentageModifiedTableData]);
902
+
903
+ const getImageTypeModelsTableData = useCallback((imageType: string) => {
904
+ if (!data) return [];
905
+
906
+ // Get the appropriate image set based on type
907
+ const images = imageType === 'crisis_map' ? data.crisisMaps : data.droneImages;
908
+
909
+ // Calculate models for this specific image type
910
+ const modelStats: { [key: string]: { count: number; totalAccuracy: number; totalContext: number; totalUsability: number } } = {};
911
+
912
+ images.forEach((map: MapData) => {
913
+ if (map.model) {
914
+ if (!modelStats[map.model]) {
915
+ modelStats[map.model] = { count: 0, totalAccuracy: 0, totalContext: 0, totalUsability: 0 };
916
+ }
917
+ modelStats[map.model].count++;
918
+ if (map.accuracy != null) modelStats[map.model].totalAccuracy += map.accuracy;
919
+ if (map.context != null) modelStats[map.model].totalContext += map.context;
920
+ if (map.usability != null) modelStats[map.model].totalUsability += map.usability;
921
+ }
922
+ });
923
+
924
+ // Convert to table data format
925
+ return Object.entries(modelStats)
926
+ .map(([modelName, stats], index) => ({
927
+ id: index + 1,
928
+ name: modelName,
929
+ count: stats.count,
930
+ accuracy: stats.count > 0 ? Math.round(stats.totalAccuracy / stats.count) : 0,
931
+ context: stats.count > 0 ? Math.round(stats.totalContext / stats.count) : 0,
932
+ usability: stats.count > 0 ? Math.round(stats.totalUsability / stats.count) : 0,
933
+ totalScore: stats.count > 0 ? Math.round((stats.totalAccuracy + stats.totalContext + stats.totalUsability) / (3 * stats.count)) : 0
934
+ }))
935
+ .sort((a, b) => b.totalScore - a.totalScore);
936
+ }, [data]);
937
+
938
+ const getImageTypeQualityBySourceTableData = useCallback((imageType: string) => {
939
+ if (!data) return [];
940
+
941
+ // Get the appropriate image set based on type
942
+ const images = imageType === 'crisis_map' ? data.crisisMaps : data.droneImages;
943
+
944
+ // Calculate quality by source for this specific image type
945
+ const sourceQuality: { [key: string]: { total: number; count: number; totalImages: number } } = {};
946
+
947
+ images.forEach((map: MapData) => {
948
+ if (map.source) {
949
+ if (!sourceQuality[map.source]) {
950
+ sourceQuality[map.source] = { total: 0, count: 0, totalImages: 0 };
951
+ }
952
+ sourceQuality[map.source].totalImages += 1;
953
+ if (map.accuracy != null) {
954
+ sourceQuality[map.source].total += map.accuracy;
955
+ sourceQuality[map.source].count += 1;
956
+ }
957
+ }
958
+ });
959
+
960
+ // Convert to table data format
961
+ return Object.entries(sourceQuality).map(([source, stats], index) => ({
962
+ id: index + 1,
963
+ source: getSourceLabel(source),
964
+ avgQuality: stats.count > 0 ? Math.round(stats.total / stats.count) : 0,
965
+ count: stats.totalImages
966
+ }));
967
+ }, [data, getSourceLabel]);
968
+
969
+ const getImageTypeModelConsistencyTableData = useCallback((imageType: string) => {
970
+ if (!data) return [];
971
+ // Filter model consistency table data by image type
972
+ const filteredData = modelConsistencyData.filter(d => {
973
+ if (imageType === 'crisis_map') {
974
+ return d.name.includes('GPT') || d.name.includes('Claude') || d.name.includes('Gemini') || d.name.includes('STUB');
975
+ } else if (imageType === 'drone_image') {
976
+ return d.name.includes('Llama') || d.name.includes('Other');
977
+ }
978
+ return true;
979
+ });
980
+ return filteredData;
981
+ }, [data, modelConsistencyData]);
982
+
983
  if (loading) {
984
  return (
985
  <PageContainer>
 
1000
  );
1001
  }
1002
 
1003
+
 
 
1004
 
1005
  const ifrcColors = [
1006
  '#F5333F', '#F64752', '#F75C65', '#F87079', '#F9858C', '#FA999F', '#FBADB2', '#FCC2C5'
 
1014
  name="analytics-view"
1015
  value={view}
1016
  onChange={(value) => {
1017
+ if (value === 'crisis_maps' || value === 'drone_images') {
1018
  setView(value);
1019
  }
1020
  }}
 
1024
  />
1025
  </div>
1026
 
1027
+ {view === 'crisis_maps' ? (
1028
  <div className={styles.chartGrid}>
1029
  <Container heading="Summary Statistics" headingLevel={3} withHeaderBorder withInternalPadding>
1030
+ <div className={styles.summaryStatsCards}>
1031
+ <div className={styles.summaryStatsCard}>
1032
+ <div className={styles.summaryStatsCardValue}>
1033
+ {getImageTypeCount('crisis_map')}
1034
+ </div>
1035
+ <div className={styles.summaryStatsCardLabel}>Total Crisis Maps</div>
1036
+ </div>
1037
+ <div className={styles.summaryStatsCard}>
1038
+ <div className={styles.summaryStatsCardValue}>
1039
+ 2000
1040
+ </div>
1041
+ <div className={styles.summaryStatsCardLabel}>Target Amount</div>
1042
+ </div>
1043
  </div>
1044
  <div className={styles.progressSection}>
1045
  <div className={styles.progressLabel}>
1046
  <span>Progress towards target</span>
1047
+ <span>{Math.round((getImageTypeCount('crisis_map') / 2000) * 100)}%</span>
1048
  </div>
1049
+ <ProgressBar value={getImageTypeCount('crisis_map')} totalValue={2000} />
1050
  </div>
1051
  </Container>
1052
 
 
1054
  <div className={styles.chartSection}>
1055
  <div className={styles.chartContainer}>
1056
  <PieChart
1057
+ data={getImageTypeRegionsChartData('crisis_map')}
1058
  valueSelector={d => d.value}
1059
  labelSelector={d => d.name}
1060
  keySelector={d => d.name}
 
1064
  </div>
1065
  <div className={styles.tableContainer}>
1066
  <Table
1067
+ data={getImageTypeRegionsTableData('crisis_map')}
1068
  columns={regionsColumns}
1069
  keySelector={numericIdSelector}
1070
  filtered={false}
 
1078
  <div className={styles.chartSection}>
1079
  <div className={styles.chartContainer}>
1080
  <PieChart
1081
+ data={getImageTypeSourcesChartData('crisis_map')}
1082
  valueSelector={d => d.value}
1083
  labelSelector={d => d.name}
1084
  keySelector={d => d.name}
 
1088
  </div>
1089
  <div className={styles.tableContainer}>
1090
  <Table
1091
+ data={getImageTypeSourcesTableData('crisis_map')}
1092
  columns={sourcesColumns}
1093
  keySelector={numericIdSelector}
1094
  filtered={false}
 
1102
  <div className={styles.chartSection}>
1103
  <div className={styles.chartContainer}>
1104
  <PieChart
1105
+ data={getImageTypeTypesChartData('crisis_map')}
1106
  valueSelector={d => d.value}
1107
  labelSelector={d => d.name}
1108
  keySelector={d => d.name}
 
1112
  </div>
1113
  <div className={styles.tableContainer}>
1114
  <Table
1115
+ data={getImageTypeTypesTableData('crisis_map')}
1116
  columns={typesColumns}
1117
  keySelector={numericIdSelector}
1118
  filtered={false}
 
1120
  />
1121
  </div>
1122
  </div>
1123
+ </Container>
1124
+
1125
+ {/* New Analytics Containers */}
1126
+ <Container heading="User Interaction Statistics" headingLevel={3} withHeaderBorder withInternalPadding>
1127
+ <div className={styles.userInteractionCards}>
1128
+ {/* Median Edit Time Card */}
1129
+ <div className={styles.userInteractionCard}>
1130
+ <div className={styles.userInteractionCardValue}>
1131
+ {getImageTypeMedianEditTime('crisis_map')}
1132
+ </div>
1133
+ <div className={styles.userInteractionCardLabel}>Median Edit Time</div>
1134
+ <Button
1135
+ name="view-edit-time-details"
1136
+ variant="secondary"
1137
+ onClick={() => setShowEditTimeModal(!showEditTimeModal)}
1138
+ className={styles.userInteractionCardButton}
1139
+ >
1140
+ {showEditTimeModal ? 'Hide Details' : 'View Details'}
1141
+ </Button>
1142
+ </div>
1143
+
1144
+ {/* Median % Modified Card */}
1145
+ <div className={styles.userInteractionCard}>
1146
+ <div className={styles.userInteractionCardValue}>
1147
+ {getImageTypePercentageModified()}
1148
+ </div>
1149
+ <div className={styles.userInteractionCardLabel}>Median % Modified</div>
1150
+ <Button
1151
+ name="view-percentage-details"
1152
+ variant="secondary"
1153
+ onClick={() => setShowPercentageModal(!showPercentageModal)}
1154
+ className={styles.userInteractionCardButton}
1155
+ >
1156
+ {showPercentageModal ? 'Hide Details' : 'View Details'}
1157
+ </Button>
1158
+ </div>
1159
+
1160
+ {/* Delete Rate Card */}
1161
+ <div className={styles.userInteractionCard}>
1162
+ <div className={styles.userInteractionCardValue}>
1163
+ {getImageTypeDeleteRate()}
1164
+ </div>
1165
+ <div className={styles.userInteractionCardLabel}>Delete Rate</div>
1166
+ <Button
1167
+ name="view-delete-details"
1168
+ variant="secondary"
1169
+ onClick={() => setShowDeleteModal(!showDeleteModal)}
1170
+ className={styles.userInteractionCardButton}
1171
+ >
1172
+ {showDeleteModal ? 'Hide Details' : 'View Details'}
1173
+ </Button>
1174
+ </div>
1175
+ </div>
1176
+
1177
+ {/* Edit Time Details Table */}
1178
+ {showEditTimeModal && (
1179
+ <div className={styles.modelPerformance}>
1180
+ <Table
1181
+ data={getImageTypeEditTimeTableData('crisis_map')}
1182
+ columns={editTimeColumns}
1183
+ keySelector={numericIdSelector}
1184
+ filtered={false}
1185
+ pending={false}
1186
+ />
1187
+ </div>
1188
+ )}
1189
+
1190
+ {/* Percentage Modified Details Table */}
1191
+ {showPercentageModal && (
1192
+ <div className={styles.modelPerformance}>
1193
+ <Table
1194
+ data={getImageTypePercentageTableData('crisis_map')}
1195
+ columns={percentageModifiedColumns}
1196
+ keySelector={numericIdSelector}
1197
+ filtered={false}
1198
+ pending={false}
1199
+ />
1200
+ </div>
1201
+ )}
1202
+ </Container>
1203
+
1204
+ <Container heading="Model Performance" headingLevel={3} withHeaderBorder withInternalPadding>
1205
+ <div className={styles.modelPerformance}>
1206
+ <Table
1207
+ data={getImageTypeModelsTableData('crisis_map')}
1208
+ columns={modelsColumns}
1209
+ keySelector={numericIdSelector}
1210
+ filtered={false}
1211
+ pending={false}
1212
+ />
1213
+ </div>
1214
+ </Container>
1215
+
1216
+
1217
+
1218
+ <Container heading="Quality-Source Correlation" headingLevel={3} withHeaderBorder withInternalPadding>
1219
+ <div className={styles.tableContainer}>
1220
+ <Table
1221
+ data={getImageTypeQualityBySourceTableData('crisis_map')}
1222
+ columns={qualityBySourceColumns}
1223
+ keySelector={numericIdSelector}
1224
+ filtered={false}
1225
+ pending={false}
1226
+ />
1227
+ </div>
1228
+ </Container>
1229
+
1230
+ <Container heading="Model Consistency Analysis" headingLevel={3} withHeaderBorder withInternalPadding>
1231
+ <div className={styles.tableContainer}>
1232
+ <Table
1233
+ data={getImageTypeModelConsistencyTableData('crisis_map')}
1234
+ columns={modelConsistencyColumns}
1235
+ keySelector={numericIdSelector}
1236
+ filtered={false}
1237
+ pending={false}
1238
+ />
1239
+ </div>
1240
  </Container>
1241
  </div>
1242
  ) : (
1243
  <div className={styles.chartGrid}>
1244
+ <Container heading="Summary Statistics" headingLevel={3} withHeaderBorder withInternalPadding>
1245
+ <div className={styles.summaryStatsCards}>
1246
+ <div className={styles.summaryStatsCard}>
1247
+ <div className={styles.summaryStatsCardValue}>
1248
+ {getImageTypeCount('drone_image')}
1249
+ </div>
1250
+ <div className={styles.summaryStatsCardLabel}>Total Drone Images</div>
1251
+ </div>
1252
+ <div className={styles.summaryStatsCard}>
1253
+ <div className={styles.summaryStatsCardValue}>
1254
+ 2000
1255
+ </div>
1256
+ <div className={styles.summaryStatsCardLabel}>Target Amount</div>
1257
+ </div>
1258
+ </div>
1259
+ <div className={styles.progressSection}>
1260
+ <div className={styles.progressLabel}>
1261
+ <span>Progress towards target</span>
1262
+ <span>{Math.round((getImageTypeCount('drone_image') / 2000) * 100)}%</span>
1263
+ </div>
1264
+ <ProgressBar value={getImageTypeCount('drone_image')} totalValue={2000} />
1265
+ </div>
1266
+ </Container>
1267
+
1268
+ <Container heading="Regions Distribution" headingLevel={3} withHeaderBorder withInternalPadding>
1269
+ <div className={styles.chartSection}>
1270
+ <div className={styles.chartContainer}>
1271
+ <PieChart
1272
+ data={getImageTypeRegionsChartData('drone_image')}
1273
+ valueSelector={d => d.value}
1274
+ labelSelector={d => d.name}
1275
+ keySelector={d => d.name}
1276
+ colors={ifrcColors}
1277
+ showPercentageInLegend
1278
+ />
1279
+ </div>
1280
+ <div className={styles.tableContainer}>
1281
+ <Table
1282
+ data={getImageTypeRegionsTableData('drone_image')}
1283
+ columns={regionsColumns}
1284
+ keySelector={numericIdSelector}
1285
+ filtered={false}
1286
+ pending={false}
1287
+ />
1288
+ </div>
1289
+ </div>
1290
+ </Container>
1291
+
1292
+
1293
+
1294
+ <Container heading="Types Distribution" headingLevel={3} withHeaderBorder withInternalPadding>
1295
+ <div className={styles.chartSection}>
1296
+ <div className={styles.chartContainer}>
1297
+ <PieChart
1298
+ data={getImageTypeTypesChartData('drone_image')}
1299
+ valueSelector={d => d.value}
1300
+ labelSelector={d => d.name}
1301
+ keySelector={d => d.name}
1302
+ colors={ifrcColors}
1303
+ showPercentageInLegend
1304
+ />
1305
+ </div>
1306
+ <div className={styles.tableContainer}>
1307
+ <Table
1308
+ data={getImageTypeTypesTableData('drone_image')}
1309
+ columns={typesColumns}
1310
+ keySelector={numericIdSelector}
1311
+ filtered={false}
1312
+ pending={false}
1313
+ />
1314
+ </div>
1315
+ </div>
1316
+ </Container>
1317
+
1318
+ {/* User Interaction Statistics Box */}
1319
+ <Container heading="User Interaction Statistics" headingLevel={3} withHeaderBorder withInternalPadding>
1320
+ <div className={styles.userInteractionCards}>
1321
+ {/* Median Edit Time Card */}
1322
+ <div className={styles.userInteractionCard}>
1323
+ <div className={styles.userInteractionCardValue}>
1324
+ {getImageTypeMedianEditTime('drone_image')}
1325
+ </div>
1326
+ <div className={styles.userInteractionCardLabel}>Median Edit Time</div>
1327
+ <Button
1328
+ name="view-edit-time-details"
1329
+ variant="secondary"
1330
+ onClick={() => setShowEditTimeModal(!showEditTimeModal)}
1331
+ className={styles.userInteractionCardButton}
1332
+ >
1333
+ {showEditTimeModal ? 'Hide Details' : 'View Details'}
1334
+ </Button>
1335
+ </div>
1336
+
1337
+ {/* Median % Modified Card */}
1338
+ <div className={styles.userInteractionCard}>
1339
+ <div className={styles.userInteractionCardValue}>
1340
+ {getImageTypePercentageModified()}
1341
+ </div>
1342
+ <div className={styles.userInteractionCardLabel}>Median % Modified</div>
1343
+ <Button
1344
+ name="view-percentage-details"
1345
+ variant="secondary"
1346
+ onClick={() => setShowPercentageModal(!showPercentageModal)}
1347
+ className={styles.userInteractionCardButton}
1348
+ >
1349
+ {showPercentageModal ? 'Hide Details' : 'View Details'}
1350
+ </Button>
1351
+ </div>
1352
+
1353
+ {/* Delete Rate Card */}
1354
+ <div className={styles.userInteractionCard}>
1355
+ <div className={styles.userInteractionCardValue}>
1356
+ {getImageTypeDeleteRate()}
1357
+ </div>
1358
+ <div className={styles.userInteractionCardLabel}>Delete Rate</div>
1359
+ <Button
1360
+ name="view-delete-details"
1361
+ variant="secondary"
1362
+ onClick={() => setShowDeleteModal(!showDeleteModal)}
1363
+ className={styles.userInteractionCardButton}
1364
+ >
1365
+ {showDeleteModal ? 'Hide Details' : 'View Details'}
1366
+ </Button>
1367
+ </div>
1368
+ </div>
1369
+
1370
+ {/* Edit Time Details Table */}
1371
+ {showEditTimeModal && (
1372
+ <div className={styles.modelPerformance}>
1373
+ <Table
1374
+ data={getImageTypeEditTimeTableData('drone_image')}
1375
+ columns={editTimeColumns}
1376
+ keySelector={numericIdSelector}
1377
+ filtered={false}
1378
+ pending={false}
1379
+ />
1380
+ </div>
1381
+ )}
1382
+
1383
+ {/* Percentage Modified Details Table */}
1384
+ {showPercentageModal && (
1385
+ <div className={styles.modelPerformance}>
1386
+ <Table
1387
+ data={getImageTypePercentageTableData('drone_image')}
1388
+ columns={percentageModifiedColumns}
1389
+ keySelector={numericIdSelector}
1390
+ filtered={false}
1391
+ pending={false}
1392
+ />
1393
+ </div>
1394
+ )}
1395
+ </Container>
1396
+
1397
  <Container heading="Model Performance" headingLevel={3} withHeaderBorder withInternalPadding>
1398
  <div className={styles.modelPerformance}>
1399
  <Table
1400
+ data={getImageTypeModelsTableData('drone_image')}
1401
  columns={modelsColumns}
1402
  keySelector={numericIdSelector}
1403
  filtered={false}
 
1405
  />
1406
  </div>
1407
  </Container>
1408
+
1409
+
1410
+
1411
+ <Container heading="Quality-Source Correlation" headingLevel={3} withHeaderBorder withInternalPadding>
1412
+ <div className={styles.tableContainer}>
1413
+ <Table
1414
+ data={getImageTypeQualityBySourceTableData('drone_image')}
1415
+ columns={qualityBySourceColumns}
1416
+ keySelector={numericIdSelector}
1417
+ filtered={false}
1418
+ pending={false}
1419
+ />
1420
+ </div>
1421
+ </Container>
1422
+
1423
+
1424
+
1425
+ <Container heading="Model Consistency Analysis" headingLevel={3} withHeaderBorder withInternalPadding>
1426
+ <div className={styles.tableContainer}>
1427
+ <Table
1428
+ data={getImageTypeModelConsistencyTableData('drone_image')}
1429
+ columns={modelConsistencyColumns}
1430
+ keySelector={numericIdSelector}
1431
+ filtered={false}
1432
+ pending={false}
1433
+ />
1434
+ </div>
1435
+ </Container>
1436
  </div>
1437
  )}
1438
  </div>
1439
+
1440
+
1441
  </PageContainer>
1442
  );
1443
  }
frontend/src/pages/ExplorePage/ExplorePage.module.css CHANGED
@@ -1,7 +1,8 @@
1
  .tabSelector {
2
  margin-bottom: var(--go-ui-spacing-lg);
3
  display: flex;
4
- justify-content: center;
 
5
  }
6
 
7
  .metadataTags {
@@ -128,3 +129,205 @@
128
  padding: var(--go-ui-spacing-2xs) var(--go-ui-spacing-xs);
129
  }
130
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  .tabSelector {
2
  margin-bottom: var(--go-ui-spacing-lg);
3
  display: flex;
4
+ justify-content: space-between;
5
+ align-items: center;
6
  }
7
 
8
  .metadataTags {
 
129
  padding: var(--go-ui-spacing-2xs) var(--go-ui-spacing-xs);
130
  }
131
  }
132
+
133
+ /* Modal styles for export selection */
134
+ .fullSizeModalOverlay {
135
+ position: fixed;
136
+ top: 0;
137
+ left: 0;
138
+ right: 0;
139
+ bottom: 0;
140
+ background-color: rgba(0, 0, 0, 0.8);
141
+ display: flex;
142
+ justify-content: center;
143
+ align-items: center;
144
+ z-index: 1000;
145
+ padding: var(--go-ui-spacing-lg);
146
+ }
147
+
148
+ .fullSizeModalContent {
149
+ background-color: var(--go-ui-color-white);
150
+ border-radius: var(--go-ui-border-radius-lg);
151
+ max-width: 95vw;
152
+ max-height: 95vh;
153
+ overflow: hidden;
154
+ box-shadow: var(--go-ui-box-shadow-xl);
155
+ display: flex;
156
+ flex-direction: column;
157
+ }
158
+
159
+ .ratingWarningContent {
160
+ padding: var(--go-ui-spacing-xl);
161
+ text-align: center;
162
+ max-width: 500px;
163
+ }
164
+
165
+ .ratingWarningTitle {
166
+ font-size: var(--go-ui-font-size-lg);
167
+ font-weight: var(--go-ui-font-weight-semibold);
168
+ color: var(--go-ui-color-gray-900);
169
+ margin-bottom: var(--go-ui-spacing-md);
170
+ }
171
+
172
+ .ratingWarningText {
173
+ font-size: var(--go-ui-font-size-md);
174
+ color: var(--go-ui-color-gray-700);
175
+ line-height: 1.6;
176
+ margin-bottom: var(--go-ui-spacing-xl);
177
+ }
178
+
179
+ .ratingWarningButtons {
180
+ display: flex;
181
+ gap: var(--go-ui-spacing-md);
182
+ justify-content: center;
183
+ flex-wrap: wrap;
184
+ margin-top: var(--go-ui-spacing-xl);
185
+ text-align: center;
186
+ }
187
+
188
+ /* Filter status step styles */
189
+ .filterStatusContainer {
190
+ margin-bottom: var(--go-ui-spacing-lg);
191
+ text-align: center;
192
+ }
193
+
194
+ .filterStatusMessage {
195
+ font-size: var(--go-ui-font-size-md);
196
+ font-weight: var(--go-ui-font-weight-medium);
197
+ color: var(--go-ui-color-gray-700);
198
+ margin-bottom: var(--go-ui-spacing-md);
199
+ }
200
+
201
+ .filterStatusCount {
202
+ font-size: var(--go-ui-font-size-lg);
203
+ font-weight: var(--go-ui-font-weight-semibold);
204
+ color: var(--go-ui-color-gray-900);
205
+ margin-bottom: var(--go-ui-spacing-md);
206
+ display: block;
207
+ }
208
+
209
+ .activeFiltersList {
210
+ display: flex;
211
+ flex-wrap: wrap;
212
+ gap: var(--go-ui-spacing-sm);
213
+ justify-content: center;
214
+ margin-bottom: var(--go-ui-spacing-lg);
215
+ }
216
+
217
+ .activeFilter {
218
+ background-color: var(--go-ui-color-gray-100);
219
+ color: var(--go-ui-color-gray-700);
220
+ padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
221
+ border-radius: var(--go-ui-border-radius-sm);
222
+ font-size: var(--go-ui-font-size-sm);
223
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-gray-200);
224
+ }
225
+
226
+ .filterStatusActions {
227
+ display: flex;
228
+ gap: var(--go-ui-spacing-md);
229
+ justify-content: center;
230
+ flex-wrap: wrap;
231
+ }
232
+
233
+ /* Export mode and split configuration styles */
234
+ .exportModeSection {
235
+ margin-bottom: var(--go-ui-spacing-lg);
236
+ text-align: center;
237
+ }
238
+
239
+ .exportModeLabel {
240
+ font-size: var(--go-ui-font-size-sm);
241
+ font-weight: var(--go-ui-font-weight-medium);
242
+ color: var(--go-ui-color-gray-700);
243
+ margin-bottom: var(--go-ui-spacing-sm);
244
+ }
245
+
246
+ .splitConfigSection {
247
+ margin-bottom: var(--go-ui-spacing-lg);
248
+ text-align: center;
249
+ }
250
+
251
+ .splitConfigTitle {
252
+ font-size: 1rem;
253
+ font-weight: 600;
254
+ margin-bottom: var(--go-ui-spacing-md);
255
+ color: var(--go-ui-color-text-primary);
256
+ }
257
+
258
+ .splitInputsContainer {
259
+ display: flex;
260
+ justify-content: center;
261
+ gap: var(--go-ui-spacing-md);
262
+ margin-bottom: var(--go-ui-spacing-md);
263
+ flex-wrap: wrap;
264
+ }
265
+
266
+ .splitInputGroup {
267
+ display: flex;
268
+ flex-direction: column;
269
+ align-items: center;
270
+ text-align: center;
271
+ }
272
+
273
+ .splitInputLabel {
274
+ margin-bottom: var(--go-ui-spacing-xs);
275
+ font-weight: 500;
276
+ color: var(--go-ui-color-text-primary);
277
+ }
278
+
279
+ .splitInput {
280
+ width: 80px;
281
+ padding: var(--go-ui-spacing-sm);
282
+ border: 1px solid var(--go-ui-color-border);
283
+ border-radius: var(--go-ui-border-radius);
284
+ font-size: 1rem;
285
+ text-align: center;
286
+ }
287
+
288
+ .splitInput:focus {
289
+ outline: none;
290
+ border-color: var(--go-ui-color-red-50);
291
+ box-shadow: 0 0 0 2px var(--go-ui-color-red-20);
292
+ }
293
+
294
+ .splitInput:hover {
295
+ border-color: var(--go-ui-color-gray-400);
296
+ }
297
+
298
+ .splitTotal {
299
+ font-size: var(--go-ui-font-size-xs);
300
+ color: var(--go-ui-color-gray-500);
301
+ text-align: center;
302
+ margin-top: var(--go-ui-spacing-xs);
303
+ }
304
+
305
+ .splitTotalError {
306
+ color: var(--go-ui-color-negative);
307
+ margin-left: var(--go-ui-spacing-xs);
308
+ }
309
+
310
+ .checkboxesContainer {
311
+ text-align: center;
312
+ margin-bottom: var(--go-ui-spacing-lg);
313
+ }
314
+
315
+ /* Responsive adjustments for modal */
316
+ @media (max-width: 768px) {
317
+ .fullSizeModalOverlay {
318
+ padding: var(--go-ui-spacing-sm);
319
+ }
320
+
321
+ .fullSizeModalContent {
322
+ max-width: 100vw;
323
+ max-height: 100vh;
324
+ }
325
+
326
+ .splitInputsContainer {
327
+ gap: var(--go-ui-spacing-md);
328
+ }
329
+
330
+ .splitInput {
331
+ width: 3.5rem;
332
+ }
333
+ }
frontend/src/pages/ExplorePage/ExplorePage.tsx CHANGED
@@ -1,9 +1,8 @@
1
- import { PageContainer, TextInput, SelectInput, MultiSelectInput, Container, SegmentInput, Spinner, Button } from '@ifrc-go/ui';
2
  import { useState, useEffect, useMemo } from 'react';
3
  import { useNavigate } from 'react-router-dom';
4
- import styles from './ExplorePage.module.css';
5
  import { useFilterContext } from '../../contexts/FilterContext';
6
- import { useAdmin } from '../../contexts/AdminContext';
7
 
8
  interface ImageWithCaptionOut {
9
  image_id: string;
@@ -31,7 +30,6 @@ interface ImageWithCaptionOut {
31
 
32
  export default function ExplorePage() {
33
  const navigate = useNavigate();
34
- const { isAuthenticated } = useAdmin();
35
  const [view, setView] = useState<'explore' | 'mapDetails'>('explore');
36
  const [captions, setCaptions] = useState<ImageWithCaptionOut[]>([]);
37
 
@@ -54,6 +52,14 @@ export default function ExplorePage() {
54
  const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
55
  const [isLoadingFilters, setIsLoadingFilters] = useState(true);
56
  const [isLoadingContent, setIsLoadingContent] = useState(true);
 
 
 
 
 
 
 
 
57
 
58
  const viewOptions = [
59
  { key: 'explore' as const, label: 'List' },
@@ -163,6 +169,301 @@ export default function ExplorePage() {
163
  });
164
  }, [captions, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, showReferenceExamples]);
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
  return (
168
  <PageContainer>
@@ -183,6 +484,23 @@ export default function ExplorePage() {
183
  keySelector={(o) => o.key}
184
  labelSelector={(o) => o.label}
185
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  </div>
187
 
188
  {view === 'explore' ? (
@@ -200,26 +518,24 @@ export default function ExplorePage() {
200
  />
201
  </Container>
202
 
203
- {/* Reference Examples Filter - Admin Only */}
204
- {isAuthenticated && (
205
- <Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
206
- <Button
207
- name="reference-examples"
208
- variant={showReferenceExamples ? "primary" : "secondary"}
209
- onClick={() => setShowReferenceExamples(!showReferenceExamples)}
210
- className="whitespace-nowrap"
211
- >
212
- <span className="mr-2">
213
- {showReferenceExamples ? (
214
- <span className="text-yellow-400">★</span>
215
- ) : (
216
- <span className="text-yellow-400">☆</span>
217
- )}
218
- </span>
219
- Reference Examples
220
- </Button>
221
- </Container>
222
- )}
223
 
224
  <Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
225
  <Button
@@ -347,7 +663,7 @@ export default function ExplorePage() {
347
  <h3 className={styles.mapItemTitle}>
348
  <div className="flex items-center gap-2">
349
  <span>{c.title || 'Untitled'}</span>
350
- {isAuthenticated && c.starred && (
351
  <span className="text-red-500 text-lg" title="Starred image">★</span>
352
  )}
353
  </div>
@@ -399,6 +715,244 @@ export default function ExplorePage() {
399
  </div>
400
  )}
401
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  </PageContainer>
403
  );
404
  }
 
 
1
  import { useState, useEffect, useMemo } from 'react';
2
  import { useNavigate } from 'react-router-dom';
3
+ import { PageContainer, TextInput, SelectInput, MultiSelectInput, Container, SegmentInput, Spinner, Button, Checkbox } from '@ifrc-go/ui';
4
  import { useFilterContext } from '../../contexts/FilterContext';
5
+ import styles from './ExplorePage.module.css';
6
 
7
  interface ImageWithCaptionOut {
8
  image_id: string;
 
30
 
31
  export default function ExplorePage() {
32
  const navigate = useNavigate();
 
33
  const [view, setView] = useState<'explore' | 'mapDetails'>('explore');
34
  const [captions, setCaptions] = useState<ImageWithCaptionOut[]>([]);
35
 
 
52
  const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
53
  const [isLoadingFilters, setIsLoadingFilters] = useState(true);
54
  const [isLoadingContent, setIsLoadingContent] = useState(true);
55
+ const [showExportModal, setShowExportModal] = useState(false);
56
+ const [exportModalStage, setExportModalStage] = useState<'filters' | 'export'>('filters');
57
+ const [exportMode, setExportMode] = useState<'standard' | 'fine-tuning'>('standard');
58
+ const [trainSplit, setTrainSplit] = useState(80);
59
+ const [testSplit, setTestSplit] = useState(10);
60
+ const [valSplit, setValSplit] = useState(10);
61
+ const [crisisMapsSelected, setCrisisMapsSelected] = useState(true);
62
+ const [droneImagesSelected, setDroneImagesSelected] = useState(true);
63
 
64
  const viewOptions = [
65
  { key: 'explore' as const, label: 'List' },
 
169
  });
170
  }, [captions, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, showReferenceExamples]);
171
 
172
+ const exportDataset = async (images: ImageWithCaptionOut[], mode: 'standard' | 'fine-tuning' = 'fine-tuning') => {
173
+ if (images.length === 0) {
174
+ alert('No images to export');
175
+ return;
176
+ }
177
+
178
+ try {
179
+ // Create a JSZip instance
180
+ const JSZip = (await import('jszip')).default;
181
+ const zip = new JSZip();
182
+
183
+ // Separate images by type
184
+ const crisisMaps = images.filter(img => img.image_type === 'crisis_map');
185
+ const droneImages = images.filter(img => img.image_type === 'drone_image');
186
+
187
+ // Create crisis_maps dataset
188
+ if (crisisMaps.length > 0) {
189
+ const crisisFolder = zip.folder('crisis_maps_dataset');
190
+ const crisisImagesFolder = crisisFolder?.folder('images');
191
+
192
+ if (crisisImagesFolder) {
193
+ // Download crisis map images and add to zip
194
+ const crisisImagePromises = crisisMaps.map(async (image, index) => {
195
+ try {
196
+ const response = await fetch(image.image_url);
197
+ if (!response.ok) throw new Error(`Failed to fetch image ${image.image_id}`);
198
+
199
+ const blob = await response.blob();
200
+ const fileExtension = image.file_key.split('.').pop() || 'jpg';
201
+ const fileName = `${String(index + 1).padStart(4, '0')}.${fileExtension}`;
202
+
203
+ crisisImagesFolder.file(fileName, blob);
204
+ return { success: true, fileName, image };
205
+ } catch (error) {
206
+ console.error(`Failed to process image ${image.image_id}:`, error);
207
+ return { success: false, fileName: '', image };
208
+ }
209
+ });
210
+
211
+ const crisisImageResults = await Promise.all(crisisImagePromises);
212
+ const successfulCrisisImages = crisisImageResults.filter(result => result.success);
213
+
214
+ if (mode === 'fine-tuning') {
215
+ // Create train.jsonl, test.jsonl, and val.jsonl with stratified sampling
216
+ const crisisTrainData: any[] = [];
217
+ const crisisTestData: any[] = [];
218
+ const crisisValData: any[] = [];
219
+
220
+ // Group crisis images by source for stratified sampling
221
+ const crisisImagesBySource = new Map<string, any[]>();
222
+ successfulCrisisImages.forEach(result => {
223
+ const source = result.image.source || 'unknown';
224
+ if (!crisisImagesBySource.has(source)) {
225
+ crisisImagesBySource.set(source, []);
226
+ }
227
+ crisisImagesBySource.get(source)!.push(result);
228
+ });
229
+
230
+ // Distribute images from each source proportionally across splits
231
+ crisisImagesBySource.forEach((images, source) => {
232
+ const totalImages = images.length;
233
+ const trainCount = Math.floor(totalImages * (trainSplit / 100));
234
+ const testCount = Math.floor(totalImages * (testSplit / 100));
235
+ const valCount = totalImages - trainCount - testCount;
236
+
237
+ // Shuffle images within each source group for randomness
238
+ const shuffledImages = [...images].sort(() => Math.random() - 0.5);
239
+
240
+ // Add to train set
241
+ crisisTrainData.push(...shuffledImages.slice(0, trainCount).map(result => ({
242
+ image: `images/${result.fileName}`,
243
+ caption: result.image.edited || result.image.generated || '',
244
+ metadata: {
245
+ image_id: result.image.image_id,
246
+ title: result.image.title,
247
+ source: result.image.source,
248
+ event_type: result.image.event_type,
249
+ image_type: result.image.image_type,
250
+ countries: result.image.countries,
251
+ starred: result.image.starred
252
+ }
253
+ })));
254
+
255
+ // Add to test set
256
+ crisisTestData.push(...shuffledImages.slice(trainCount, trainCount + testCount).map(result => ({
257
+ image: `images/${result.fileName}`,
258
+ caption: result.image.edited || result.image.generated || '',
259
+ metadata: {
260
+ image_id: result.image.image_id,
261
+ title: result.image.title,
262
+ source: result.image.source,
263
+ event_type: result.image.event_type,
264
+ image_type: result.image.image_type,
265
+ countries: result.image.countries,
266
+ starred: result.image.starred
267
+ }
268
+ })));
269
+
270
+ // Add to validation set
271
+ crisisValData.push(...shuffledImages.slice(trainCount + testCount).map(result => ({
272
+ image: `images/${result.fileName}`,
273
+ caption: result.image.edited || result.image.generated || '',
274
+ metadata: {
275
+ image_id: result.image.image_id,
276
+ title: result.image.title,
277
+ source: result.image.source,
278
+ event_type: result.image.event_type,
279
+ image_type: result.image.image_type,
280
+ countries: result.image.countries,
281
+ starred: result.image.starred
282
+ }
283
+ })));
284
+ });
285
+
286
+ // Add JSONL files to crisis folder
287
+ if (crisisFolder) {
288
+ crisisFolder.file('train.jsonl', JSON.stringify(crisisTrainData, null, 2));
289
+ crisisFolder.file('test.jsonl', JSON.stringify(crisisTestData, null, 2));
290
+ crisisFolder.file('val.jsonl', JSON.stringify(crisisValData, null, 2));
291
+ }
292
+ } else {
293
+ // Standard mode: create individual JSON files for each image
294
+ successfulCrisisImages.forEach((result, index) => {
295
+ const jsonData = {
296
+ image: `images/${result.fileName}`,
297
+ caption: result.image.edited || result.image.generated || '',
298
+ metadata: {
299
+ image_id: result.image.image_id,
300
+ title: result.image.title,
301
+ source: result.image.source,
302
+ event_type: result.image.event_type,
303
+ image_type: result.image.image_type,
304
+ countries: result.image.countries,
305
+ starred: result.image.starred
306
+ }
307
+ };
308
+
309
+ if (crisisFolder) {
310
+ crisisFolder.file(`${String(index + 1).padStart(4, '0')}.json`, JSON.stringify(jsonData, null, 2));
311
+ }
312
+ });
313
+ }
314
+ }
315
+ }
316
+
317
+ // Create drone_images dataset
318
+ if (droneImages.length > 0) {
319
+ const droneFolder = zip.folder('drone_images_dataset');
320
+ const droneImagesFolder = droneFolder?.folder('images');
321
+
322
+ if (droneImagesFolder) {
323
+ // Download drone images and add to zip
324
+ const droneImagePromises = droneImages.map(async (image, index) => {
325
+ try {
326
+ const response = await fetch(image.image_url);
327
+ if (!response.ok) throw new Error(`Failed to fetch image ${image.image_id}`);
328
+
329
+ const blob = await response.blob();
330
+ const fileExtension = image.file_key.split('.').pop() || 'jpg';
331
+ const fileName = `${String(index + 1).padStart(4, '0')}.${fileExtension}`;
332
+
333
+ droneImagesFolder.file(fileName, blob);
334
+ return { success: true, fileName, image };
335
+ } catch (error) {
336
+ console.error(`Failed to process image ${image.image_id}:`, error);
337
+ return { success: false, fileName: '', image };
338
+ }
339
+ });
340
+
341
+ const droneImageResults = await Promise.all(droneImagePromises);
342
+ const successfulDroneImages = droneImageResults.filter(result => result.success);
343
+
344
+ if (mode === 'fine-tuning') {
345
+ // Create train.jsonl, test.jsonl, and val.jsonl with stratified sampling
346
+ const droneTrainData: any[] = [];
347
+ const droneTestData: any[] = [];
348
+ const droneValData: any[] = [];
349
+
350
+ // Group drone images by event type for stratified sampling
351
+ const droneImagesByEventType = new Map<string, any[]>();
352
+ successfulDroneImages.forEach(result => {
353
+ const eventType = result.image.event_type || 'unknown';
354
+ if (!droneImagesByEventType.has(eventType)) {
355
+ droneImagesByEventType.set(eventType, []);
356
+ }
357
+ droneImagesByEventType.get(eventType)!.push(result);
358
+ });
359
+
360
+ // Distribute images from each event type proportionally across splits
361
+ droneImagesByEventType.forEach((images, eventType) => {
362
+ const totalImages = images.length;
363
+ const trainCount = Math.floor(totalImages * (trainSplit / 100));
364
+ const testCount = Math.floor(totalImages * (testSplit / 100));
365
+ const valCount = totalImages - trainCount - testCount;
366
+
367
+ // Shuffle images within each event type group for randomness
368
+ const shuffledImages = [...images].sort(() => Math.random() - 0.5);
369
+
370
+ // Add to train set
371
+ droneTrainData.push(...shuffledImages.slice(0, trainCount).map(result => ({
372
+ image: `images/${result.fileName}`,
373
+ caption: result.image.edited || result.image.generated || '',
374
+ metadata: {
375
+ image_id: result.image.image_id,
376
+ title: result.image.title,
377
+ source: result.image.source,
378
+ event_type: result.image.event_type,
379
+ image_type: result.image.image_type,
380
+ countries: result.image.countries,
381
+ starred: result.image.starred
382
+ }
383
+ })));
384
+
385
+ // Add to test set
386
+ droneTestData.push(...shuffledImages.slice(trainCount, trainCount + testCount).map(result => ({
387
+ image: `images/${result.fileName}`,
388
+ caption: result.image.edited || result.image.generated || '',
389
+ metadata: {
390
+ image_id: result.image.image_id,
391
+ title: result.image.title,
392
+ source: result.image.source,
393
+ event_type: result.image.event_type,
394
+ image_type: result.image.image_type,
395
+ countries: result.image.countries,
396
+ starred: result.image.starred
397
+ }
398
+ })));
399
+
400
+ // Add to validation set
401
+ droneValData.push(...shuffledImages.slice(trainCount + testCount).map(result => ({
402
+ image: `images/${result.fileName}`,
403
+ caption: result.image.edited || result.image.generated || '',
404
+ metadata: {
405
+ image_id: result.image.image_id,
406
+ title: result.image.title,
407
+ source: result.image.source,
408
+ event_type: result.image.event_type,
409
+ image_type: result.image.image_type,
410
+ countries: result.image.countries,
411
+ starred: result.image.starred
412
+ }
413
+ })));
414
+ });
415
+
416
+ // Add JSONL files to drone folder
417
+ if (droneFolder) {
418
+ droneFolder.file('train.jsonl', JSON.stringify(droneTrainData, null, 2));
419
+ droneFolder.file('test.jsonl', JSON.stringify(droneTestData, null, 2));
420
+ droneFolder.file('val.jsonl', JSON.stringify(droneValData, null, 2));
421
+ }
422
+ } else {
423
+ // Standard mode: create individual JSON files for each image
424
+ successfulDroneImages.forEach((result, index) => {
425
+ const jsonData = {
426
+ image: `images/${result.fileName}`,
427
+ caption: result.image.edited || result.image.generated || '',
428
+ metadata: {
429
+ image_id: result.image.image_id,
430
+ title: result.image.title,
431
+ source: result.image.source,
432
+ event_type: result.image.event_type,
433
+ image_type: result.image.image_type,
434
+ countries: result.image.countries,
435
+ starred: result.image.starred
436
+ }
437
+ };
438
+
439
+ if (droneFolder) {
440
+ droneFolder.file(`${String(index + 1).padStart(4, '0')}.json`, JSON.stringify(jsonData, null, 2));
441
+ }
442
+ });
443
+ }
444
+ }
445
+ }
446
+
447
+ // Generate and download zip
448
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
449
+ const url = URL.createObjectURL(zipBlob);
450
+ const link = document.createElement('a');
451
+ link.href = url;
452
+ link.download = `datasets_${mode}_${new Date().toISOString().split('T')[0]}.zip`;
453
+ document.body.appendChild(link);
454
+ link.click();
455
+ document.body.removeChild(link);
456
+ URL.revokeObjectURL(url);
457
+
458
+ const totalImages = (crisisMaps.length || 0) + (droneImages.length || 0);
459
+ console.log(`Exported ${mode} datasets with ${totalImages} total images:`);
460
+ if (crisisMaps.length > 0) console.log(`- Crisis maps: ${crisisMaps.length} images`);
461
+ if (droneImages.length > 0) console.log(`- Drone images: ${droneImages.length} images`);
462
+ } catch (error) {
463
+ console.error('Export failed:', error);
464
+ alert('Failed to export dataset. Please try again.');
465
+ }
466
+ };
467
 
468
  return (
469
  <PageContainer>
 
484
  keySelector={(o) => o.key}
485
  labelSelector={(o) => o.label}
486
  />
487
+
488
+ {/* Export Dataset Button */}
489
+ <Button
490
+ name="export-dataset"
491
+ variant="secondary"
492
+ onClick={() => {
493
+ setShowExportModal(true);
494
+ // Skip to export stage if no filters are applied
495
+ if (search || srcFilter || catFilter || regionFilter || countryFilter || imageTypeFilter || showReferenceExamples) {
496
+ setExportModalStage('filters');
497
+ } else {
498
+ setExportModalStage('export');
499
+ }
500
+ }}
501
+ >
502
+ Export Dataset
503
+ </Button>
504
  </div>
505
 
506
  {view === 'explore' ? (
 
518
  />
519
  </Container>
520
 
521
+ {/* Reference Examples Filter - Available to all users */}
522
+ <Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
523
+ <Button
524
+ name="reference-examples"
525
+ variant={showReferenceExamples ? "primary" : "secondary"}
526
+ onClick={() => setShowReferenceExamples(!showReferenceExamples)}
527
+ className="whitespace-nowrap"
528
+ >
529
+ <span className="mr-2">
530
+ {showReferenceExamples ? (
531
+ <span className="text-yellow-400">★</span>
532
+ ) : (
533
+ <span className="text-yellow-400">☆</span>
534
+ )}
535
+ </span>
536
+ Reference Examples
537
+ </Button>
538
+ </Container>
 
 
539
 
540
  <Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
541
  <Button
 
663
  <h3 className={styles.mapItemTitle}>
664
  <div className="flex items-center gap-2">
665
  <span>{c.title || 'Untitled'}</span>
666
+ {c.starred && (
667
  <span className="text-red-500 text-lg" title="Starred image">★</span>
668
  )}
669
  </div>
 
715
  </div>
716
  )}
717
  </div>
718
+
719
+ {/* Export Selection Modal */}
720
+ {showExportModal && (
721
+ <div className={styles.fullSizeModalOverlay} onClick={() => setShowExportModal(false)}>
722
+ <div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
723
+ <div className={styles.ratingWarningContent}>
724
+ {exportModalStage === 'filters' ? (
725
+ <>
726
+ <h3 className={styles.ratingWarningTitle}>Export Dataset</h3>
727
+
728
+ {/* Filter Status Message */}
729
+ <div className={styles.filterStatusContainer}>
730
+ {(search || srcFilter || catFilter || regionFilter || countryFilter || imageTypeFilter || showReferenceExamples) ? (
731
+ <>
732
+ <div className={styles.filterStatusMessage}>
733
+ Filters are being applied
734
+ </div>
735
+ <div className={styles.filterStatusCount}>
736
+ {filtered.length} of {captions.length} examples
737
+ </div>
738
+ <div className={styles.activeFiltersList}>
739
+ {search && <span className={styles.activeFilter}>Search: "{search}"</span>}
740
+ {srcFilter && <span className={styles.activeFilter}>Source: {sources.find(s => s.s_code === srcFilter)?.label || srcFilter}</span>}
741
+ {catFilter && <span className={styles.activeFilter}>Category: {types.find(t => t.t_code === catFilter)?.label || catFilter}</span>}
742
+ {regionFilter && <span className={styles.activeFilter}>Region: {regions.find(r => r.r_code === regionFilter)?.label || regionFilter}</span>}
743
+ {countryFilter && <span className={styles.activeFilter}>Country: {countries.find(c => c.c_code === countryFilter)?.label || countryFilter}</span>}
744
+ {imageTypeFilter && <span className={styles.activeFilter}>Type: {imageTypes.find(it => it.image_type === imageTypeFilter)?.label || imageTypeFilter}</span>}
745
+ {showReferenceExamples && <span className={styles.activeFilter}>Reference Examples Only</span>}
746
+ </div>
747
+ <div className={styles.filterStatusActions}>
748
+ <Button
749
+ name="clear-filters-modal"
750
+ variant="secondary"
751
+ size={1}
752
+ onClick={clearAllFilters}
753
+ >
754
+ Clear Filters
755
+ </Button>
756
+ <Button
757
+ name="continue-with-filters"
758
+ variant="primary"
759
+ size={1}
760
+ onClick={() => setExportModalStage('export')}
761
+ >
762
+ Continue
763
+ </Button>
764
+ </div>
765
+ </>
766
+ ) : (
767
+ <>
768
+ <div className={styles.filterStatusCount}>
769
+ {captions.length} examples available
770
+ </div>
771
+ <Button
772
+ name="continue-no-filters"
773
+ variant="primary"
774
+ size={1}
775
+ onClick={() => setExportModalStage('export')}
776
+ >
777
+ Continue
778
+ </Button>
779
+ </>
780
+ )}
781
+ </div>
782
+
783
+ <div className={styles.ratingWarningButtons}>
784
+ <Button
785
+ name="cancel-export"
786
+ variant="tertiary"
787
+ onClick={() => {
788
+ setShowExportModal(false);
789
+ setExportModalStage('filters');
790
+ }}
791
+ >
792
+ Cancel
793
+ </Button>
794
+ </div>
795
+ </>
796
+ ) : (
797
+ <>
798
+ <h3 className={styles.ratingWarningTitle}>Export Dataset</h3>
799
+
800
+ {/* Export Mode Switch */}
801
+ <div className={styles.exportModeSection}>
802
+ <SegmentInput
803
+ name="export-mode"
804
+ value={exportMode}
805
+ onChange={(value) => {
806
+ if (value === 'standard' || value === 'fine-tuning') {
807
+ setExportMode(value);
808
+ }
809
+ }}
810
+ options={[
811
+ { key: 'standard' as const, label: 'Standard' },
812
+ { key: 'fine-tuning' as const, label: 'Fine-tuning' }
813
+ ]}
814
+ keySelector={(o) => o.key}
815
+ labelSelector={(o) => o.label}
816
+ />
817
+ </div>
818
+
819
+ {/* Train/Test/Val Split Configuration - Only show for Fine-tuning mode */}
820
+ {exportMode === 'fine-tuning' && (
821
+ <div className={styles.splitConfigSection}>
822
+ <div className={styles.splitConfigTitle}>Dataset Split Configuration</div>
823
+ <div className={styles.splitInputsContainer}>
824
+ <div className={styles.splitInputGroup}>
825
+ <label htmlFor="train-split" className={styles.splitInputLabel}>Train (%)</label>
826
+ <input
827
+ id="train-split"
828
+ type="number"
829
+ min="0"
830
+ max="100"
831
+ value={trainSplit}
832
+ onChange={(e) => {
833
+ const newTrain = parseInt(e.target.value) || 0;
834
+ const remaining = 100 - newTrain;
835
+ if (remaining >= 0) {
836
+ setTrainSplit(newTrain);
837
+ // Distribute remaining between test and val
838
+ if (testSplit + valSplit > remaining) {
839
+ setTestSplit(Math.floor(remaining / 2));
840
+ setValSplit(remaining - Math.floor(remaining / 2));
841
+ }
842
+ }
843
+ }}
844
+ className={styles.splitInput}
845
+ />
846
+ </div>
847
+
848
+ <div className={styles.splitInputGroup}>
849
+ <label htmlFor="test-split" className={styles.splitInputLabel}>Test (%)</label>
850
+ <input
851
+ id="test-split"
852
+ type="number"
853
+ min="0"
854
+ max="100"
855
+ value={testSplit}
856
+ onChange={(e) => {
857
+ const newTest = parseInt(e.target.value) || 0;
858
+ const remaining = 100 - trainSplit - newTest;
859
+ if (remaining >= 0) {
860
+ setTestSplit(newTest);
861
+ setValSplit(remaining);
862
+ }
863
+ }}
864
+ className={styles.splitInput}
865
+ />
866
+ </div>
867
+
868
+ <div className={styles.splitInputGroup}>
869
+ <label htmlFor="val-split" className={styles.splitInputLabel}>Val (%)</label>
870
+ <input
871
+ id="val-split"
872
+ type="number"
873
+ min="0"
874
+ max="100"
875
+ value={valSplit}
876
+ onChange={(e) => {
877
+ const newVal = parseInt(e.target.value) || 0;
878
+ const remaining = 100 - trainSplit - newVal;
879
+ if (remaining >= 0) {
880
+ setValSplit(newVal);
881
+ setTestSplit(remaining);
882
+ }
883
+ }}
884
+ className={styles.splitInput}
885
+ />
886
+ </div>
887
+ </div>
888
+
889
+ {trainSplit + testSplit + valSplit !== 100 && (
890
+ <div className={styles.splitTotal}>
891
+ <span className={styles.splitTotalError}>Must equal 100%</span>
892
+ </div>
893
+ )}
894
+ </div>
895
+ )}
896
+
897
+ <div className={styles.checkboxesContainer}>
898
+ <div className="flex items-center gap-3">
899
+ <Checkbox
900
+ name="crisis-maps"
901
+ label={`Crisis Maps (${filtered.filter(img => img.image_type === 'crisis_map').length} images)`}
902
+ value={crisisMapsSelected}
903
+ onChange={(value, name) => setCrisisMapsSelected(value)}
904
+ disabled={isLoadingFilters}
905
+ />
906
+ </div>
907
+
908
+ <div className="flex items-center gap-3">
909
+ <Checkbox
910
+ name="drone-images"
911
+ label={`Drone Images (${filtered.filter(img => img.image_type === 'drone_image').length} images)`}
912
+ value={droneImagesSelected}
913
+ onChange={(value, name) => setDroneImagesSelected(value)}
914
+ disabled={isLoadingFilters}
915
+ />
916
+ </div>
917
+ </div>
918
+
919
+ <div className={styles.ratingWarningButtons}>
920
+ {(search || srcFilter || catFilter || regionFilter || countryFilter || imageTypeFilter || showReferenceExamples) && (
921
+ <Button
922
+ name="back-to-filters"
923
+ variant="secondary"
924
+ onClick={() => setExportModalStage('filters')}
925
+ >
926
+ Back to Filters
927
+ </Button>
928
+ )}
929
+ <Button
930
+ name="confirm-export"
931
+ onClick={() => {
932
+ if (!crisisMapsSelected && !droneImagesSelected) {
933
+ alert('Please select at least one image type to export.');
934
+ return;
935
+ }
936
+
937
+ const selectedTypes: string[] = [];
938
+ if (crisisMapsSelected) selectedTypes.push('crisis_map');
939
+ if (droneImagesSelected) selectedTypes.push('drone_image');
940
+
941
+ const filteredByType = filtered.filter(img => selectedTypes.includes(img.image_type));
942
+ exportDataset(filteredByType, exportMode);
943
+ setShowExportModal(false);
944
+ setExportModalStage('filters');
945
+ }}
946
+ >
947
+ Export Selected
948
+ </Button>
949
+ </div>
950
+ </>
951
+ )}
952
+ </div>
953
+ </div>
954
+ </div>
955
+ )}
956
  </PageContainer>
957
  );
958
  }
frontend/src/pages/HelpPage.tsx CHANGED
@@ -1,10 +1,28 @@
1
- import { PageContainer, Heading, Container } from '@ifrc-go/ui';
 
 
2
 
3
  export default function HelpPage() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  return (
5
  <PageContainer className="py-10">
6
  <Container withInternalPadding className="max-w-4xl mx-auto">
7
- <Heading level={2} className="text-center mb-12 text-gray-900">Help &amp; Support</Heading>
8
 
9
  <div className="space-y-8">
10
  <Container withInternalPadding className="p-8">
@@ -16,6 +34,15 @@ export default function HelpPage() {
16
  cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
17
  culpa qui officia deserunt mollit anim id est laborum.
18
  </p>
 
 
 
 
 
 
 
 
 
19
  </Container>
20
 
21
  <Container withInternalPadding className="p-8">
@@ -26,6 +53,15 @@ export default function HelpPage() {
26
  dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit,
27
  sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
28
  </p>
 
 
 
 
 
 
 
 
 
29
  </Container>
30
 
31
  <Container withInternalPadding className="p-8">
@@ -36,6 +72,15 @@ export default function HelpPage() {
36
  provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum
37
  fuga. Et harum quidem rerum facilis est et expedita distinctio.
38
  </p>
 
 
 
 
 
 
 
 
 
39
  </Container>
40
  </div>
41
  </Container>
 
1
+ import { PageContainer, Heading, Container, Button } from '@ifrc-go/ui';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { useFilterContext } from '../contexts/FilterContext';
4
 
5
  export default function HelpPage() {
6
+ const navigate = useNavigate();
7
+ const { setShowReferenceExamples } = useFilterContext();
8
+
9
+ const handleUploadNow = () => {
10
+ navigate('/upload');
11
+ };
12
+
13
+ const handleSeeExamples = () => {
14
+ setShowReferenceExamples(true);
15
+ navigate('/explore');
16
+ };
17
+
18
+ const handleViewVlmDetails = () => {
19
+ navigate('/analytics?view=crisis_maps');
20
+ };
21
+
22
  return (
23
  <PageContainer className="py-10">
24
  <Container withInternalPadding className="max-w-4xl mx-auto">
25
+
26
 
27
  <div className="space-y-8">
28
  <Container withInternalPadding className="p-8">
 
34
  cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
35
  culpa qui officia deserunt mollit anim id est laborum.
36
  </p>
37
+ <div className="mt-6">
38
+ <Button
39
+ name="upload-now"
40
+ variant="primary"
41
+ onClick={handleUploadNow}
42
+ >
43
+ Upload now →
44
+ </Button>
45
+ </div>
46
  </Container>
47
 
48
  <Container withInternalPadding className="p-8">
 
53
  dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit,
54
  sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
55
  </p>
56
+ <div className="mt-6">
57
+ <Button
58
+ name="see-examples"
59
+ variant="primary"
60
+ onClick={handleSeeExamples}
61
+ >
62
+ See examples →
63
+ </Button>
64
+ </div>
65
  </Container>
66
 
67
  <Container withInternalPadding className="p-8">
 
72
  provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum
73
  fuga. Et harum quidem rerum facilis est et expedita distinctio.
74
  </p>
75
+ <div className="mt-6">
76
+ <Button
77
+ name="view-vlm-details"
78
+ variant="primary"
79
+ onClick={handleViewVlmDetails}
80
+ >
81
+ View VLM details →
82
+ </Button>
83
+ </div>
84
  </Container>
85
  </div>
86
  </Container>
frontend/src/pages/MapDetailsPage/MapDetailPage.module.css CHANGED
@@ -1,7 +1,8 @@
1
  .tabSelector {
2
  margin-bottom: var(--go-ui-spacing-lg);
3
  display: flex;
4
- justify-content: center;
 
5
  }
6
 
7
  .backButton {
@@ -239,6 +240,8 @@
239
  gap: var(--go-ui-spacing-md);
240
  justify-content: center;
241
  flex-wrap: wrap;
 
 
242
  }
243
 
244
  /* Responsive adjustments for modal */
@@ -252,3 +255,96 @@
252
  max-height: 100vh;
253
  }
254
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  .tabSelector {
2
  margin-bottom: var(--go-ui-spacing-lg);
3
  display: flex;
4
+ justify-content: space-between;
5
+ align-items: center;
6
  }
7
 
8
  .backButton {
 
240
  gap: var(--go-ui-spacing-md);
241
  justify-content: center;
242
  flex-wrap: wrap;
243
+ margin-top: var(--go-ui-spacing-xl);
244
+ text-align: center;
245
  }
246
 
247
  /* Responsive adjustments for modal */
 
255
  max-height: 100vh;
256
  }
257
  }
258
+
259
+ /* Export mode and split configuration styles */
260
+ .exportModeSection {
261
+ margin-bottom: var(--go-ui-spacing-lg);
262
+ text-align: center;
263
+ }
264
+
265
+ .exportModeLabel {
266
+ font-size: var(--go-ui-font-size-sm);
267
+ font-weight: var(--go-ui-font-weight-medium);
268
+ color: var(--go-ui-color-gray-700);
269
+ margin-bottom: var(--go-ui-spacing-sm);
270
+ }
271
+
272
+ .splitConfigSection {
273
+ margin-bottom: var(--go-ui-spacing-lg);
274
+ text-align: center;
275
+ }
276
+
277
+ .splitConfigTitle {
278
+ font-size: 1rem;
279
+ font-weight: 600;
280
+ margin-bottom: var(--go-ui-spacing-md);
281
+ color: var(--go-ui-color-text-primary);
282
+ }
283
+
284
+ .splitInputsContainer {
285
+ display: flex;
286
+ justify-content: center;
287
+ gap: var(--go-ui-spacing-md);
288
+ margin-bottom: var(--go-ui-spacing-md);
289
+ flex-wrap: wrap;
290
+ }
291
+
292
+ .splitInputGroup {
293
+ display: flex;
294
+ flex-direction: column;
295
+ align-items: center;
296
+ text-align: center;
297
+ }
298
+
299
+ .splitInputLabel {
300
+ margin-bottom: var(--go-ui-spacing-xs);
301
+ font-weight: 500;
302
+ color: var(--go-ui-color-text-primary);
303
+ }
304
+
305
+ .splitInput {
306
+ width: 80px;
307
+ padding: var(--go-ui-spacing-sm);
308
+ border: 1px solid var(--go-ui-color-border);
309
+ border-radius: var(--go-ui-border-radius);
310
+ font-size: 1rem;
311
+ text-align: center;
312
+ }
313
+
314
+ .splitInput:focus {
315
+ outline: none;
316
+ border-color: var(--go-ui-color-red-50);
317
+ box-shadow: 0 0 0 2px var(--go-ui-color-red-20);
318
+ }
319
+
320
+ .splitInput:hover {
321
+ border-color: var(--go-ui-color-gray-400);
322
+ }
323
+
324
+ .splitTotal {
325
+ font-size: var(--go-ui-font-size-xs);
326
+ color: var(--go-ui-color-gray-500);
327
+ text-align: center;
328
+ margin-top: var(--go-ui-spacing-xs);
329
+ }
330
+
331
+ .splitTotalError {
332
+ color: var(--go-ui-color-negative);
333
+ margin-left: var(--go-ui-spacing-xs);
334
+ }
335
+
336
+ /* Responsive adjustments for split inputs */
337
+ @media (max-width: 768px) {
338
+ .splitInputsContainer {
339
+ gap: var(--go-ui-spacing-md);
340
+ }
341
+
342
+ .splitInput {
343
+ width: 3.5rem;
344
+ }
345
+ }
346
+
347
+ .checkboxesContainer {
348
+ text-align: center;
349
+ margin-bottom: var(--go-ui-spacing-lg);
350
+ }
frontend/src/pages/MapDetailsPage/MapDetailPage.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { PageContainer, Container, Button, Spinner, SegmentInput, TextInput, SelectInput, MultiSelectInput } from '@ifrc-go/ui';
2
  import { useParams, useNavigate } from 'react-router-dom';
3
  import { useState, useEffect, useMemo, useCallback } from 'react';
4
  import { ChevronLeftLineIcon, ChevronRightLineIcon, DeleteBinLineIcon } from '@ifrc-go/icons';
@@ -55,6 +55,13 @@ export default function MapDetailPage() {
55
 
56
  // Add delete confirmation state
57
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
 
 
 
 
 
 
 
58
 
59
  // Add flag to prevent auto-navigation during delete operations
60
  const [isDeleting, setIsDeleting] = useState(false);
@@ -474,6 +481,301 @@ export default function MapDetailPage() {
474
  }
475
  };
476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
 
478
  if (loading) {
479
  return (
@@ -528,6 +830,15 @@ export default function MapDetailPage() {
528
  keySelector={(o) => o.key}
529
  labelSelector={(o) => o.label}
530
  />
 
 
 
 
 
 
 
 
 
531
  </div>
532
 
533
  {/* Search and Filters */}
@@ -543,26 +854,24 @@ export default function MapDetailPage() {
543
  />
544
  </Container>
545
 
546
- {/* Reference Examples Filter - Admin Only */}
547
- {isAuthenticated && (
548
- <Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
549
- <Button
550
- name="reference-examples"
551
- variant={showReferenceExamples ? "primary" : "secondary"}
552
- onClick={() => setShowReferenceExamples(!showReferenceExamples)}
553
- className="whitespace-nowrap"
554
- >
555
- <span className="mr-2">
556
- {showReferenceExamples ? (
557
- <span className="text-yellow-400">★</span>
558
- ) : (
559
- <span className="text-yellow-400">☆</span>
560
- )}
561
- </span>
562
- Reference Examples
563
- </Button>
564
- </Container>
565
- )}
566
 
567
  <Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
568
  <Button
@@ -653,7 +962,7 @@ export default function MapDetailPage() {
653
  heading={
654
  <div className="flex items-center gap-2">
655
  <span>{filteredMap.title || "Map Image"}</span>
656
- {isAuthenticated && filteredMap.starred && (
657
  <span className="text-red-500 text-xl" title="Starred image">★</span>
658
  )}
659
  </div>
@@ -887,6 +1196,166 @@ export default function MapDetailPage() {
887
  </div>
888
  </div>
889
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
890
  </PageContainer>
891
  );
892
  }
 
1
+ import { PageContainer, Container, Button, Spinner, SegmentInput, TextInput, SelectInput, MultiSelectInput, Checkbox } from '@ifrc-go/ui';
2
  import { useParams, useNavigate } from 'react-router-dom';
3
  import { useState, useEffect, useMemo, useCallback } from 'react';
4
  import { ChevronLeftLineIcon, ChevronRightLineIcon, DeleteBinLineIcon } from '@ifrc-go/icons';
 
55
 
56
  // Add delete confirmation state
57
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
58
+ const [showExportModal, setShowExportModal] = useState(false);
59
+ const [exportMode, setExportMode] = useState<'standard' | 'fine-tuning'>('standard');
60
+ const [trainSplit, setTrainSplit] = useState(80);
61
+ const [testSplit, setTestSplit] = useState(10);
62
+ const [valSplit, setValSplit] = useState(10);
63
+ const [crisisMapsSelected, setCrisisMapsSelected] = useState(true);
64
+ const [droneImagesSelected, setDroneImagesSelected] = useState(true);
65
 
66
  // Add flag to prevent auto-navigation during delete operations
67
  const [isDeleting, setIsDeleting] = useState(false);
 
481
  }
482
  };
483
 
484
+ // Helper function to create image data
485
+ const createImageData = (map: any, fileName: string) => ({
486
+ image: `images/${fileName}`,
487
+ caption: map.edited || map.generated || '',
488
+ metadata: {
489
+ image_id: map.image_id,
490
+ title: map.title,
491
+ source: map.source,
492
+ event_type: map.event_type,
493
+ image_type: map.image_type,
494
+ countries: map.countries,
495
+ starred: map.starred
496
+ }
497
+ });
498
+
499
+ const exportDataset = async (mode: 'standard' | 'fine-tuning') => {
500
+ if (!map) {
501
+ alert('No map to export');
502
+ return;
503
+ }
504
+
505
+ try {
506
+ // Create a JSZip instance
507
+ const JSZip = (await import('jszip')).default;
508
+ const zip = new JSZip();
509
+
510
+ // Determine which dataset to create based on image type
511
+ if (map.image_type === 'crisis_map') {
512
+ const crisisFolder = zip.folder('crisis_maps_dataset');
513
+ const crisisImagesFolder = crisisFolder?.folder('images');
514
+
515
+ if (crisisImagesFolder) {
516
+ // Download the current crisis map image and add to zip
517
+ try {
518
+ const response = await fetch(map.image_url);
519
+ if (!response.ok) throw new Error(`Failed to fetch image ${map.image_id}`);
520
+
521
+ const blob = await response.blob();
522
+ const fileExtension = map.file_key.split('.').pop() || 'jpg';
523
+ const fileName = `0001.${fileExtension}`;
524
+
525
+ crisisImagesFolder.file(fileName, blob);
526
+
527
+ if (mode === 'fine-tuning') {
528
+ // Create train.jsonl, test.jsonl, and val.jsonl with stratified sampling
529
+ const trainData: any[] = [];
530
+ const testData: any[] = [];
531
+ const valData: any[] = [];
532
+
533
+ if (String(map?.image_type) === 'crisis_map') {
534
+ // For crisis maps, group by source
535
+ const source = map.source || 'unknown';
536
+ const totalImages = 1; // Only one image in MapDetailPage
537
+
538
+ // Since we only have one image, distribute it based on the split
539
+ const random = Math.random();
540
+ if (random < trainSplit / 100) {
541
+ trainData.push(createImageData(map, '0001'));
542
+ } else if (random < (trainSplit + testSplit) / 100) {
543
+ testData.push(createImageData(map, '0001'));
544
+ } else {
545
+ valData.push(createImageData(map, '0001'));
546
+ }
547
+ } else if (String(map?.image_type) === 'drone_image') {
548
+ // For drone images, group by event type
549
+ const eventType = map.event_type || 'unknown';
550
+ const totalImages = 1; // Only one image in MapDetailPage
551
+
552
+ // Since we only have one image, distribute it based on the split
553
+ const random = Math.random();
554
+ if (random < trainSplit / 100) {
555
+ trainData.push(createImageData(map, '0001'));
556
+ } else if (random < (trainSplit + testSplit) / 100) {
557
+ testData.push(createImageData(map, '0001'));
558
+ } else {
559
+ valData.push(createImageData(map, '0001'));
560
+ }
561
+ }
562
+
563
+ // Add JSONL files to dataset folder
564
+ if (crisisFolder) {
565
+ crisisFolder.file('train.jsonl', JSON.stringify(trainData, null, 2));
566
+ crisisFolder.file('test.jsonl', JSON.stringify(testData, null, 2));
567
+ crisisFolder.file('val.jsonl', JSON.stringify(valData, null, 2));
568
+ }
569
+ } else {
570
+ // Standard mode: create individual JSON file
571
+ const jsonData = {
572
+ image: `images/${fileName}`,
573
+ caption: map.edited || map.generated || '',
574
+ metadata: {
575
+ image_id: map.image_id,
576
+ title: map.title,
577
+ source: map.source,
578
+ event_type: map.event_type,
579
+ image_type: map.image_type,
580
+ countries: map.countries,
581
+ starred: map.starred
582
+ }
583
+ };
584
+
585
+ if (crisisFolder) {
586
+ crisisFolder.file('0001.json', JSON.stringify(jsonData, null, 2));
587
+ }
588
+ }
589
+ } catch (error) {
590
+ console.error(`Failed to process image ${map.image_id}:`, error);
591
+ throw error;
592
+ }
593
+ }
594
+ } else if (map.image_type === 'drone_image') {
595
+ const droneFolder = zip.folder('drone_images_dataset');
596
+ const droneImagesFolder = droneFolder?.folder('images');
597
+
598
+ if (droneImagesFolder) {
599
+ // Download the current drone image and add to zip
600
+ try {
601
+ const response = await fetch(map.image_url);
602
+ if (!response.ok) throw new Error(`Failed to fetch image ${map.image_id}`);
603
+
604
+ const blob = await response.blob();
605
+ const fileExtension = map.file_key.split('.').pop() || 'jpg';
606
+ const fileName = `0001.${fileExtension}`;
607
+
608
+ droneImagesFolder.file(fileName, blob);
609
+
610
+ if (mode === 'fine-tuning') {
611
+ // Create train.jsonl, test.jsonl, and val.jsonl with stratified sampling
612
+ const trainData: any[] = [];
613
+ const testData: any[] = [];
614
+ const valData: any[] = [];
615
+
616
+ if (String(map?.image_type) === 'crisis_map') {
617
+ // For crisis maps, group by source
618
+ const source = map.source || 'unknown';
619
+ const totalImages = 1; // Only one image in MapDetailPage
620
+
621
+ // Since we only have one image, distribute it based on the split
622
+ const random = Math.random();
623
+ if (random < trainSplit / 100) {
624
+ trainData.push(createImageData(map, '0001'));
625
+ } else if (random < (trainSplit + testSplit) / 100) {
626
+ testData.push(createImageData(map, '0001'));
627
+ } else {
628
+ valData.push(createImageData(map, '0001'));
629
+ }
630
+ } else if (String(map?.image_type) === 'drone_image') {
631
+ // For drone images, group by event type
632
+ const eventType = map.event_type || 'unknown';
633
+ const totalImages = 1; // Only one image in MapDetailPage
634
+
635
+ // Since we only have one image, distribute it based on the split
636
+ const random = Math.random();
637
+ if (random < trainSplit / 100) {
638
+ trainData.push(createImageData(map, '0001'));
639
+ } else if (random < (trainSplit + testSplit) / 100) {
640
+ testData.push(createImageData(map, '0001'));
641
+ } else {
642
+ valData.push(createImageData(map, '0001'));
643
+ }
644
+ }
645
+
646
+ // Add JSONL files to dataset folder
647
+ if (droneFolder) {
648
+ droneFolder.file('train.jsonl', JSON.stringify(trainData, null, 2));
649
+ droneFolder.file('test.jsonl', JSON.stringify(testData, null, 2));
650
+ droneFolder.file('val.jsonl', JSON.stringify(valData, null, 2));
651
+ }
652
+ } else {
653
+ // Standard mode: create individual JSON file
654
+ const jsonData = {
655
+ image: `images/${fileName}`,
656
+ caption: map.edited || map.generated || '',
657
+ metadata: {
658
+ image_id: map.image_id,
659
+ title: map.title,
660
+ source: map.source,
661
+ event_type: map.event_type,
662
+ image_type: map.image_type,
663
+ countries: map.countries,
664
+ starred: map.starred
665
+ }
666
+ };
667
+
668
+ if (droneFolder) {
669
+ droneFolder.file('0001.json', JSON.stringify(jsonData, null, 2));
670
+ }
671
+ }
672
+ } catch (error) {
673
+ console.error(`Failed to process image ${map.image_id}:`, error);
674
+ throw error;
675
+ }
676
+ }
677
+ } else {
678
+ // For other image types, create a generic dataset
679
+ const genericFolder = zip.folder('generic_dataset');
680
+ const genericImagesFolder = genericFolder?.folder('images');
681
+
682
+ if (genericImagesFolder) {
683
+ try {
684
+ const response = await fetch(map.image_url);
685
+ if (!response.ok) throw new Error(`Failed to fetch image ${map.image_id}`);
686
+
687
+ const blob = await response.blob();
688
+ const fileExtension = map.file_key.split('.').pop() || 'jpg';
689
+ const fileName = `0001.${fileExtension}`;
690
+
691
+ genericImagesFolder.file(fileName, blob);
692
+
693
+ if (mode === 'fine-tuning') {
694
+ // Create train.jsonl, test.jsonl, and val.jsonl with stratified sampling
695
+ const trainData: any[] = [];
696
+ const testData: any[] = [];
697
+ const valData: any[] = [];
698
+
699
+ if (String(map?.image_type) === 'crisis_map') {
700
+ // For crisis maps, group by source
701
+ const source = map.source || 'unknown';
702
+ const totalImages = 1; // Only one image in MapDetailPage
703
+
704
+ // Since we only have one image, distribute it based on the split
705
+ const random = Math.random();
706
+ if (random < trainSplit / 100) {
707
+ trainData.push(createImageData(map, '0001'));
708
+ } else if (random < (trainSplit + testSplit) / 100) {
709
+ testData.push(createImageData(map, '0001'));
710
+ } else {
711
+ valData.push(createImageData(map, '0001'));
712
+ }
713
+ } else if (String(map?.image_type) === 'drone_image') {
714
+ // For drone images, group by event type
715
+ const eventType = map.event_type || 'unknown';
716
+ const totalImages = 1; // Only one image in MapDetailPage
717
+
718
+ // Since we only have one image, distribute it based on the split
719
+ const random = Math.random();
720
+ if (random < trainSplit / 100) {
721
+ trainData.push(createImageData(map, '0001'));
722
+ } else if (random < (trainSplit + testSplit) / 100) {
723
+ testData.push(createImageData(map, '0001'));
724
+ } else {
725
+ valData.push(createImageData(map, '0001'));
726
+ }
727
+ }
728
+
729
+ // Add JSONL files to dataset folder
730
+ if (genericFolder) {
731
+ genericFolder.file('train.jsonl', JSON.stringify(trainData, null, 2));
732
+ genericFolder.file('test.jsonl', JSON.stringify(testData, null, 2));
733
+ genericFolder.file('val.jsonl', JSON.stringify(valData, null, 2));
734
+ }
735
+ } else {
736
+ // Standard mode: create individual JSON file
737
+ const jsonData = {
738
+ image: `images/${fileName}`,
739
+ caption: map.edited || map.generated || '',
740
+ metadata: {
741
+ image_id: map.image_id,
742
+ title: map.title,
743
+ source: map.source,
744
+ event_type: map.event_type,
745
+ image_type: map.image_type,
746
+ countries: map.countries,
747
+ starred: map.starred
748
+ }
749
+ };
750
+
751
+ if (genericFolder) {
752
+ genericFolder.file('0001.json', JSON.stringify(jsonData, null, 2));
753
+ }
754
+ }
755
+ } catch (error) {
756
+ console.error(`Failed to process image ${map.image_id}:`, error);
757
+ throw error;
758
+ }
759
+ }
760
+ }
761
+
762
+ // Generate and download zip
763
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
764
+ const url = URL.createObjectURL(zipBlob);
765
+ const link = document.createElement('a');
766
+ link.href = url;
767
+ link.download = `dataset_${map.image_type}_${map.image_id}_${mode}_${new Date().toISOString().split('T')[0]}.zip`;
768
+ document.body.appendChild(link);
769
+ link.click();
770
+ document.body.removeChild(link);
771
+ URL.revokeObjectURL(url);
772
+
773
+ console.log(`Exported ${map.image_type} dataset with 1 image in ${mode} mode`);
774
+ } catch (error) {
775
+ console.error('Export failed:', error);
776
+ alert('Failed to export dataset. Please try again.');
777
+ }
778
+ };
779
 
780
  if (loading) {
781
  return (
 
830
  keySelector={(o) => o.key}
831
  labelSelector={(o) => o.label}
832
  />
833
+
834
+ {/* Export Dataset Button */}
835
+ <Button
836
+ name="export-dataset"
837
+ variant="secondary"
838
+ onClick={() => setShowExportModal(true)}
839
+ >
840
+ Export Dataset
841
+ </Button>
842
  </div>
843
 
844
  {/* Search and Filters */}
 
854
  />
855
  </Container>
856
 
857
+ {/* Reference Examples Filter - Available to all users */}
858
+ <Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
859
+ <Button
860
+ name="reference-examples"
861
+ variant={showReferenceExamples ? "primary" : "secondary"}
862
+ onClick={() => setShowReferenceExamples(!showReferenceExamples)}
863
+ className="whitespace-nowrap"
864
+ >
865
+ <span className="mr-2">
866
+ {showReferenceExamples ? (
867
+ <span className="text-yellow-400">★</span>
868
+ ) : (
869
+ <span className="text-yellow-400">☆</span>
870
+ )}
871
+ </span>
872
+ Reference Examples
873
+ </Button>
874
+ </Container>
 
 
875
 
876
  <Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
877
  <Button
 
962
  heading={
963
  <div className="flex items-center gap-2">
964
  <span>{filteredMap.title || "Map Image"}</span>
965
+ {filteredMap.starred && (
966
  <span className="text-red-500 text-xl" title="Starred image">★</span>
967
  )}
968
  </div>
 
1196
  </div>
1197
  </div>
1198
  )}
1199
+
1200
+ {/* Export Selection Modal */}
1201
+ {showExportModal && (
1202
+ <div className={styles.fullSizeModalOverlay} onClick={() => setShowExportModal(false)}>
1203
+ <div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
1204
+ <div className={styles.ratingWarningContent}>
1205
+ <h3 className={styles.ratingWarningTitle}>Export Dataset</h3>
1206
+
1207
+ {/* Export Mode Switch */}
1208
+ <div className={styles.exportModeSection}>
1209
+ <SegmentInput
1210
+ name="export-mode"
1211
+ value={exportMode}
1212
+ onChange={(value) => {
1213
+ if (value === 'standard' || value === 'fine-tuning') {
1214
+ setExportMode(value);
1215
+ }
1216
+ }}
1217
+ options={[
1218
+ { key: 'standard' as const, label: 'Standard' },
1219
+ { key: 'fine-tuning' as const, label: 'Fine-tuning' }
1220
+ ]}
1221
+ keySelector={(o) => o.key}
1222
+ labelSelector={(o) => o.label}
1223
+ />
1224
+ </div>
1225
+
1226
+ {/* Train/Test/Val Split Configuration - Only show for Fine-tuning mode */}
1227
+ {exportMode === 'fine-tuning' && (
1228
+ <div className={styles.splitConfigSection}>
1229
+ <div className={styles.splitConfigTitle}>Dataset Split Configuration</div>
1230
+ <div className={styles.splitInputsContainer}>
1231
+ <div className={styles.splitInputGroup}>
1232
+ <label htmlFor="train-split" className={styles.splitInputLabel}>Train (%)</label>
1233
+ <input
1234
+ id="train-split"
1235
+ type="number"
1236
+ min="0"
1237
+ max="100"
1238
+ value={trainSplit}
1239
+ onChange={(e) => {
1240
+ const newTrain = parseInt(e.target.value) || 0;
1241
+ const remaining = 100 - newTrain;
1242
+ if (remaining >= 0) {
1243
+ setTrainSplit(newTrain);
1244
+ // Distribute remaining between test and val
1245
+ if (testSplit + valSplit > remaining) {
1246
+ setTestSplit(Math.floor(remaining / 2));
1247
+ setValSplit(remaining - Math.floor(remaining / 2));
1248
+ }
1249
+ }
1250
+ }}
1251
+ className={styles.splitInput}
1252
+ />
1253
+ </div>
1254
+
1255
+ <div className={styles.splitInputGroup}>
1256
+ <label htmlFor="test-split" className={styles.splitInputLabel}>Test (%)</label>
1257
+ <input
1258
+ id="test-split"
1259
+ type="number"
1260
+ min="0"
1261
+ max="100"
1262
+ value={testSplit}
1263
+ onChange={(e) => {
1264
+ const newTest = parseInt(e.target.value) || 0;
1265
+ const remaining = 100 - trainSplit - newTest;
1266
+ if (remaining >= 0) {
1267
+ setTestSplit(newTest);
1268
+ setValSplit(remaining);
1269
+ }
1270
+ }}
1271
+ className={styles.splitInput}
1272
+ />
1273
+ </div>
1274
+
1275
+ <div className={styles.splitInputGroup}>
1276
+ <label htmlFor="val-split" className={styles.splitInputLabel}>Val (%)</label>
1277
+ <input
1278
+ id="val-split"
1279
+ type="number"
1280
+ min="0"
1281
+ max="100"
1282
+ value={valSplit}
1283
+ onChange={(e) => {
1284
+ const newVal = parseInt(e.target.value) || 0;
1285
+ const remaining = 100 - trainSplit - newVal;
1286
+ if (remaining >= 0) {
1287
+ setValSplit(newVal);
1288
+ setTestSplit(remaining);
1289
+ }
1290
+ }}
1291
+ className={styles.splitInput}
1292
+ />
1293
+ </div>
1294
+ </div>
1295
+
1296
+ {trainSplit + testSplit + valSplit !== 100 && (
1297
+ <div className={styles.splitTotal}>
1298
+ <span className={styles.splitTotalError}>Must equal 100%</span>
1299
+ </div>
1300
+ )}
1301
+ </div>
1302
+ )}
1303
+
1304
+ <div className={styles.checkboxesContainer}>
1305
+ <div className="flex items-center gap-3">
1306
+ <Checkbox
1307
+ name="crisis-maps"
1308
+ label="Crisis Maps"
1309
+ value={crisisMapsSelected}
1310
+ onChange={(value, name) => setCrisisMapsSelected(value)}
1311
+ />
1312
+ </div>
1313
+
1314
+ <div className="flex items-center gap-3">
1315
+ <Checkbox
1316
+ name="drone-images"
1317
+ label="Drone Images"
1318
+ value={droneImagesSelected}
1319
+ onChange={(value, name) => setDroneImagesSelected(value)}
1320
+ />
1321
+ </div>
1322
+ </div>
1323
+
1324
+ <div className={styles.ratingWarningButtons}>
1325
+ <Button
1326
+ name="confirm-export"
1327
+ onClick={() => {
1328
+ if (!crisisMapsSelected && !droneImagesSelected) {
1329
+ alert('Please select at least one image type to export.');
1330
+ return;
1331
+ }
1332
+
1333
+ // For MapDetailPage, we only export the current image if its type is selected
1334
+ if ((map?.image_type === 'crisis_map' && crisisMapsSelected) ||
1335
+ (map?.image_type === 'drone_image' && droneImagesSelected)) {
1336
+ exportDataset(exportMode);
1337
+ } else {
1338
+ alert('The current image type is not selected for export.');
1339
+ return;
1340
+ }
1341
+
1342
+ setShowExportModal(false);
1343
+ }}
1344
+ >
1345
+ Export Selected
1346
+ </Button>
1347
+ <Button
1348
+ name="cancel-export"
1349
+ variant="tertiary"
1350
+ onClick={() => setShowExportModal(false)}
1351
+ >
1352
+ Cancel
1353
+ </Button>
1354
+ </div>
1355
+ </div>
1356
+ </div>
1357
+ </div>
1358
+ )}
1359
  </PageContainer>
1360
  );
1361
  }
frontend/src/pages/UploadPage/UploadPage.module.css CHANGED
@@ -579,7 +579,21 @@
579
  display: flex;
580
  gap: var(--go-ui-spacing-md);
581
  justify-content: center;
582
- flex-wrap: wrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583
  }
584
 
585
 
 
579
  display: flex;
580
  gap: var(--go-ui-spacing-md);
581
  justify-content: center;
582
+ margin-top: var(--go-ui-spacing-lg);
583
+ }
584
+
585
+ .preprocessingProgress {
586
+ margin-top: var(--go-ui-spacing-lg);
587
+ text-align: center;
588
+ padding: var(--go-ui-spacing-lg);
589
+ background-color: var(--go-ui-color-background-light);
590
+ border-radius: var(--go-ui-border-radius-md);
591
+ }
592
+
593
+ .preprocessingProgress p {
594
+ margin-bottom: var(--go-ui-spacing-md);
595
+ color: var(--go-ui-color-text);
596
+ font-weight: var(--go-ui-font-weight-medium);
597
  }
598
 
599
 
frontend/src/pages/UploadPage/UploadPage.tsx CHANGED
@@ -295,6 +295,12 @@ export default function UploadPage() {
295
  setDraft('');
296
  setShowFallbackNotification(false);
297
  setFallbackInfo(null);
 
 
 
 
 
 
298
  };
299
  const [scores, setScores] = useState({
300
  accuracy: 50,
@@ -315,15 +321,152 @@ export default function UploadPage() {
315
  reason: string;
316
  } | null>(null);
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
 
319
  const onDrop = (e: DragEvent<HTMLDivElement>) => {
320
  e.preventDefault();
321
  const dropped = e.dataTransfer.files?.[0];
322
- if (dropped) setFile(dropped);
 
 
 
323
  };
324
 
325
  const onFileChange = (file: File | undefined) => {
326
- if (file) setFile(file);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  };
328
 
329
  useEffect(() => {
@@ -394,6 +537,15 @@ export default function UploadPage() {
394
  if (!mapRes.ok) throw new Error((mapJson.error as string) || 'Upload failed');
395
  setImageUrl(mapJson.image_url as string);
396
 
 
 
 
 
 
 
 
 
 
397
  const mapIdVal = mapJson.image_id as string;
398
  if (!mapIdVal) throw new Error('Upload failed: image_id not found');
399
  setUploadedImageId(mapIdVal);
@@ -507,6 +659,15 @@ export default function UploadPage() {
507
  const json = await readJsonSafely(res);
508
  if (!res.ok) throw new Error((json.error as string) || 'Upload failed');
509
 
 
 
 
 
 
 
 
 
 
510
  const newId = json.image_id as string;
511
  setUploadedImageId(newId);
512
  setImageUrl(json.image_url as string);
@@ -1307,6 +1468,70 @@ export default function UploadPage() {
1307
  </div>
1308
  )}
1309
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1310
  </div>
1311
  </div>
1312
  )}
 
295
  setDraft('');
296
  setShowFallbackNotification(false);
297
  setFallbackInfo(null);
298
+ setShowPreprocessingNotification(false);
299
+ setPreprocessingInfo(null);
300
+ setShowPreprocessingModal(false);
301
+ setPreprocessingFile(null);
302
+ setIsPreprocessing(false);
303
+ setPreprocessingProgress('');
304
  };
305
  const [scores, setScores] = useState({
306
  accuracy: 50,
 
321
  reason: string;
322
  } | null>(null);
323
 
324
+ const [showPreprocessingNotification, setShowPreprocessingNotification] = useState(false);
325
+ const [preprocessingInfo, setPreprocessingInfo] = useState<{
326
+ original_filename: string;
327
+ processed_filename: string;
328
+ original_mime_type: string;
329
+ processed_mime_type: string;
330
+ was_preprocessed: boolean;
331
+ error?: string;
332
+ } | null>(null);
333
+
334
+ // Enhanced preprocessing flow state
335
+ const [showPreprocessingModal, setShowPreprocessingModal] = useState(false);
336
+ const [preprocessingFile, setPreprocessingFile] = useState<File | null>(null);
337
+ const [isPreprocessing, setIsPreprocessing] = useState(false);
338
+ const [preprocessingProgress, setPreprocessingProgress] = useState<string>('');
339
+
340
 
341
  const onDrop = (e: DragEvent<HTMLDivElement>) => {
342
  e.preventDefault();
343
  const dropped = e.dataTransfer.files?.[0];
344
+ if (dropped) {
345
+ // Use onFileChange to trigger preprocessing detection
346
+ onFileChange(dropped);
347
+ }
348
  };
349
 
350
  const onFileChange = (file: File | undefined) => {
351
+ if (file) {
352
+ console.log('File selected:', file.name, 'Type:', file.type, 'Size:', file.size);
353
+
354
+ // Check if file needs preprocessing
355
+ if (needsPreprocessing(file)) {
356
+ console.log('File needs preprocessing, showing modal');
357
+ setPreprocessingFile(file);
358
+ setShowPreprocessingModal(true);
359
+ } else {
360
+ console.log('File does not need preprocessing, setting directly');
361
+ setFile(file);
362
+ }
363
+ }
364
+ };
365
+
366
+ // Check if file needs preprocessing (non-JPEG/PNG)
367
+ const needsPreprocessing = (file: File): boolean => {
368
+ const supportedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
369
+ const supportedExtensions = ['.jpg', '.jpeg', '.png'];
370
+
371
+ // Check MIME type first
372
+ let needsPreprocess = !supportedTypes.includes(file.type);
373
+
374
+ // If MIME type check is inconclusive, check file extension
375
+ if (!needsPreprocess && file.name) {
376
+ const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
377
+ needsPreprocess = !supportedExtensions.includes(fileExtension);
378
+ }
379
+
380
+ console.log('Preprocessing check:', {
381
+ fileName: file.name,
382
+ fileType: file.type,
383
+ fileExtension: file.name ? file.name.toLowerCase().substring(file.name.lastIndexOf('.')) : 'none',
384
+ supportedTypes,
385
+ supportedExtensions,
386
+ needsPreprocess
387
+ });
388
+
389
+ return needsPreprocess;
390
+ };
391
+
392
+ // Handle preprocessing confirmation
393
+ const handlePreprocessingConfirm = async () => {
394
+ if (!preprocessingFile) return;
395
+
396
+ setIsPreprocessing(true);
397
+ setPreprocessingProgress('Starting file conversion...');
398
+
399
+ try {
400
+ // Create FormData for preprocessing
401
+ const formData = new FormData();
402
+ formData.append('file', preprocessingFile);
403
+ formData.append('preprocess_only', 'true'); // Flag to indicate preprocessing only
404
+
405
+ setPreprocessingProgress('Converting file format...');
406
+
407
+ // Call preprocessing endpoint
408
+ const response = await fetch('/api/images/preprocess', {
409
+ method: 'POST',
410
+ body: formData
411
+ });
412
+
413
+ if (!response.ok) {
414
+ throw new Error('Preprocessing failed');
415
+ }
416
+
417
+ const result = await response.json();
418
+
419
+ setPreprocessingProgress('Finalizing conversion...');
420
+
421
+ // Decode base64 content
422
+ const processedContent = atob(result.processed_content);
423
+ const processedBytes = new Uint8Array(processedContent.length);
424
+ for (let i = 0; i < processedContent.length; i++) {
425
+ processedBytes[i] = processedContent.charCodeAt(i);
426
+ }
427
+
428
+ // Create a new File object from the processed data
429
+ const processedFile = new File(
430
+ [processedBytes],
431
+ result.processed_filename,
432
+ { type: result.processed_mime_type }
433
+ );
434
+
435
+ // Create preview URL
436
+ const previewUrl = URL.createObjectURL(processedFile);
437
+
438
+ // Update the main file state
439
+ setFile(processedFile);
440
+ setPreview(previewUrl);
441
+
442
+ setPreprocessingProgress('Conversion complete!');
443
+
444
+ // Close modal after a brief delay
445
+ setTimeout(() => {
446
+ setShowPreprocessingModal(false);
447
+ setPreprocessingFile(null);
448
+ setIsPreprocessing(false);
449
+ setPreprocessingProgress('');
450
+ }, 1000);
451
+
452
+ } catch (error) {
453
+ console.error('Preprocessing error:', error);
454
+ setPreprocessingProgress('Conversion failed. Please try again.');
455
+ setTimeout(() => {
456
+ setShowPreprocessingModal(false);
457
+ setPreprocessingFile(null);
458
+ setIsPreprocessing(false);
459
+ setPreprocessingProgress('');
460
+ }, 2000);
461
+ }
462
+ };
463
+
464
+ // Handle preprocessing cancellation
465
+ const handlePreprocessingCancel = () => {
466
+ setShowPreprocessingModal(false);
467
+ setPreprocessingFile(null);
468
+ setIsPreprocessing(false);
469
+ setPreprocessingProgress('');
470
  };
471
 
472
  useEffect(() => {
 
537
  if (!mapRes.ok) throw new Error((mapJson.error as string) || 'Upload failed');
538
  setImageUrl(mapJson.image_url as string);
539
 
540
+ // Check for preprocessing info and show notification if needed
541
+ if (mapJson.preprocessing_info &&
542
+ typeof mapJson.preprocessing_info === 'object' &&
543
+ 'was_preprocessed' in mapJson.preprocessing_info &&
544
+ mapJson.preprocessing_info.was_preprocessed === true) {
545
+ setPreprocessingInfo(mapJson.preprocessing_info as any);
546
+ setShowPreprocessingNotification(true);
547
+ }
548
+
549
  const mapIdVal = mapJson.image_id as string;
550
  if (!mapIdVal) throw new Error('Upload failed: image_id not found');
551
  setUploadedImageId(mapIdVal);
 
659
  const json = await readJsonSafely(res);
660
  if (!res.ok) throw new Error((json.error as string) || 'Upload failed');
661
 
662
+ // Check for preprocessing info and show notification if needed
663
+ if (json.preprocessing_info &&
664
+ typeof json.preprocessing_info === 'object' &&
665
+ 'was_preprocessed' in json.preprocessing_info &&
666
+ json.preprocessing_info.was_preprocessed === true) {
667
+ setPreprocessingInfo(json.preprocessing_info as any);
668
+ setShowPreprocessingNotification(true);
669
+ }
670
+
671
  const newId = json.image_id as string;
672
  setUploadedImageId(newId);
673
  setImageUrl(json.image_url as string);
 
1468
  </div>
1469
  )}
1470
 
1471
+ {/* Image Preprocessing Notification Modal */}
1472
+ {showPreprocessingNotification && preprocessingInfo && (
1473
+ <div className={styles.fullSizeModalOverlay} onClick={() => setShowPreprocessingNotification(false)}>
1474
+ <div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
1475
+ <div className={styles.ratingWarningContent}>
1476
+ <h3 className={styles.ratingWarningTitle}>File Converted</h3>
1477
+ <p className={styles.ratingWarningText}>
1478
+ Your file <strong>{preprocessingInfo.original_filename}</strong> has been converted from
1479
+ <strong> {preprocessingInfo.original_mime_type}</strong> to
1480
+ <strong> {preprocessingInfo.processed_mime_type}</strong> for better compatibility.
1481
+ <br /><br />
1482
+ This process may take a bit longer, but ensures your file works properly in the system.
1483
+ </p>
1484
+ <div className={styles.ratingWarningButtons}>
1485
+ <Button
1486
+ name="close-preprocessing"
1487
+ variant="secondary"
1488
+ onClick={() => setShowPreprocessingNotification(false)}
1489
+ >
1490
+ Got it
1491
+ </Button>
1492
+ </div>
1493
+ </div>
1494
+ </div>
1495
+ </div>
1496
+ )}
1497
+
1498
+ {/* Preprocessing Modal */}
1499
+ {showPreprocessingModal && (
1500
+ <div className={styles.fullSizeModalOverlay} onClick={handlePreprocessingCancel}>
1501
+ <div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
1502
+ <div className={styles.ratingWarningContent}>
1503
+ <h3 className={styles.ratingWarningTitle}>File Conversion Required</h3>
1504
+ <p className={styles.ratingWarningText}>
1505
+ The file you selected is not in a supported format (JPEG or PNG).
1506
+ We will convert it to a compatible format for better compatibility.
1507
+ </p>
1508
+ <div className={styles.ratingWarningButtons}>
1509
+ <Button
1510
+ name="confirm-preprocessing"
1511
+ variant="secondary"
1512
+ onClick={handlePreprocessingConfirm}
1513
+ >
1514
+ Convert File
1515
+ </Button>
1516
+ <Button
1517
+ name="cancel-preprocessing"
1518
+ variant="tertiary"
1519
+ onClick={handlePreprocessingCancel}
1520
+ >
1521
+ Cancel
1522
+ </Button>
1523
+ </div>
1524
+ {isPreprocessing && (
1525
+ <div className={styles.preprocessingProgress}>
1526
+ <p>{preprocessingProgress}</p>
1527
+ <Spinner className="text-ifrcRed" />
1528
+ </div>
1529
+ )}
1530
+ </div>
1531
+ </div>
1532
+ </div>
1533
+ )}
1534
+
1535
  </div>
1536
  </div>
1537
  )}
go-web-app-develop/.changeset/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Changesets
2
+
3
+ Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4
+ with multi-package repos, or single-package repos to help you version and publish your code. You can
5
+ find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6
+
7
+ We have a quick list of common questions to get you started engaging with this project in
8
+ [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
go-web-app-develop/.changeset/config.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
3
+ "changelog": "@changesets/cli/changelog",
4
+ "commit": false,
5
+ "fixed": [],
6
+ "linked": [],
7
+ "access": "public",
8
+ "baseBranch": "develop",
9
+ "updateInternalDependencies": "patch",
10
+ "ignore": [],
11
+ "privatePackages": {
12
+ "version": true,
13
+ "tag": true
14
+ }
15
+ }
go-web-app-develop/.changeset/lovely-kids-boil.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ ---
2
+ "go-web-app": patch
3
+ ---
4
+
5
+ Fix use of operational timeframe date in imminent final report form
go-web-app-develop/.changeset/pre.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mode": "pre",
3
+ "tag": "beta",
4
+ "initialVersions": {
5
+ "go-web-app": "7.20.2",
6
+ "go-ui-storybook": "1.0.7",
7
+ "@ifrc-go/ui": "1.5.1"
8
+ },
9
+ "changesets": [
10
+ "lovely-kids-boil",
11
+ "solid-clubs-care",
12
+ "sweet-gifts-cheer",
13
+ "whole-lions-guess"
14
+ ]
15
+ }
go-web-app-develop/.changeset/solid-clubs-care.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ "go-web-app": minor
3
+ ---
4
+
5
+ Add Crisis categorization update date
6
+
7
+ - Add updated date for crisis categorization in emergency page.
8
+ - Add consent checkbox over situational overview in field report form.
go-web-app-develop/.changeset/sweet-gifts-cheer.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ "go-web-app": minor
3
+ ---
4
+
5
+ Add support for DREF imminent v2 in final report
6
+
7
+ - Add a separate route for the old dref final report form
8
+ - Update dref final report to accomodate imminent v2 changes
9
+
go-web-app-develop/.changeset/whole-lions-guess.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ ---
2
+ "go-web-app": patch
3
+ ---
4
+
5
+ - Fix calculation of Operation End date in Final report form
6
+ - Fix icon position issue in the implementation table of DREF PDF export
7
+ - Update the label for last update date in the crisis categorization pop-up
go-web-app-develop/.dockerignore ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Swap files
2
+ *.swp
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+ # C extensions
10
+ *.so
11
+
12
+ # Distribution / packaging
13
+ .Python
14
+ env
15
+ build
16
+ develop-eggs
17
+ dist
18
+ downloads
19
+ eggs
20
+ .eggs
21
+ lib
22
+ lib64
23
+ parts
24
+ sdist
25
+ var
26
+ *.egg-info
27
+ .installed.cfg
28
+ *.egg
29
+
30
+ # PyInstaller
31
+ # Usually these files are written by a python script from a template
32
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
33
+ *.manifest
34
+ *.spec
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov
42
+ .tox
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *,cover
49
+ .hypothesis
50
+
51
+ # Translations
52
+ *.mo
53
+ *.pot
54
+
55
+ # Django stuff:
56
+ *.log
57
+
58
+ # Sphinx documentation
59
+ docs/_build
60
+
61
+ # PyBuilder
62
+ target
63
+
64
+ #Ipython Notebook
65
+ .ipynb_checkpoints
66
+
67
+ # SASS cache
68
+ .sass-cache
69
+ media_test
70
+
71
+ # Rope project settings
72
+ .ropeproject
73
+
74
+ # Logs
75
+ logs
76
+ *.log
77
+ npm-debug.log*
78
+ yarn-debug.log*
79
+ yarn-error.log*
80
+
81
+ # Runtime data
82
+ pids
83
+ *.pid
84
+ *.seed
85
+ *.pid.lock
86
+
87
+ # Directory for instrumented libs generated by jscoverage/JSCover
88
+ lib-cov
89
+
90
+ # Coverage directory used by tools like istanbul
91
+ coverage
92
+
93
+ # nyc test coverage
94
+ .nyc_output
95
+
96
+ # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
97
+ .grunt
98
+
99
+ # Bower dependency directory (https://bower.io/)
100
+ bower_components
101
+
102
+ # node-waf configuration
103
+ .lock-wscript
104
+
105
+ # Compiled binary addons (http://nodejs.org/api/addons.html)
106
+ build/Release
107
+
108
+ # Dependency directories
109
+ node_modules
110
+ jspm_packages
111
+
112
+ # Typescript v1 declaration files
113
+ typings
114
+
115
+ # Optional npm cache directory
116
+ .npm
117
+
118
+ # Optional eslint cache
119
+ .eslintcache
120
+
121
+ # Optional REPL history
122
+ .node_repl_history
123
+
124
+ # Output of 'npm pack'
125
+ *.tgz
126
+
127
+ # Yarn Integrity file
128
+ .yarn-integrity
129
+
130
+ # dotenv environment variables file
131
+ .env
132
+ .env*
133
+
134
+ # Sensitive Deploy Files
135
+ deploy/eb/
136
+
137
+ # tox
138
+ ./.tox
139
+
140
+ # Helm
141
+ .helm-charts/
142
+
143
+ # Docker
144
+ Dockerfile
145
+ .dockerignore
146
+
147
+ # git
148
+ .gitignore
go-web-app-develop/.github/ISSUE_TEMPLATE/01_bug_report.yml ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Bug Report"
2
+ description: "Report a technical or visual issue."
3
+ labels: ["type: bug"]
4
+ type: "Bug"
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ **Bug Report**
10
+ Please fill out the form below with as much detail as possible.
11
+ If the issue is visual, screenshots or videos are greatly appreciated.
12
+ **Please review [our guide on reporting bugs](https://github.com/IFRCGo/go-web-app/blob/develop/CONTRIBUTING.md#reporting-bugs) before opening a new issue.**
13
+
14
+ - type: input
15
+ attributes:
16
+ label: "Page URL"
17
+ description: "The URL of the page where you encountered the issue."
18
+ placeholder: "https://go.ifrc.org/"
19
+ validations:
20
+ required: true
21
+
22
+ - type: dropdown
23
+ attributes:
24
+ label: "Environment"
25
+ description: "Please select the environment where the bug occurred."
26
+ options:
27
+ - "Alpha"
28
+ - "Staging"
29
+ - "Production"
30
+ validations:
31
+ required: true
32
+
33
+ - type: input
34
+ attributes:
35
+ label: "Browser"
36
+ description: "Which browser are you using? (e.g., Chrome, Firefox, Safari)"
37
+ placeholder: "Chrome"
38
+ validations:
39
+ required: true
40
+
41
+ - type: textarea
42
+ attributes:
43
+ label: "Steps to Reproduce the Issue"
44
+ description: |
45
+ Please describe the issue in detail, including:
46
+ 1. What actions led to the issue?
47
+ 2. If possible, attach screenshots or videos demonstrating the problem.
48
+ placeholder: |
49
+ 1. I clicked on...
50
+ 2. [Attach screenshots/videos if available]
51
+ validations:
52
+ required: true
53
+
54
+ - type: textarea
55
+ attributes:
56
+ label: "Expected Behavior"
57
+ description: "Describe what you expected to happen."
58
+ placeholder: "I expected the page to..."
59
+ validations:
60
+ required: true
61
+
62
+ - type: textarea
63
+ attributes:
64
+ label: "Actual Behavior"
65
+ description: "Describe what actually happened, including any error messages."
66
+ placeholder: "Instead, I saw..."
67
+ validations:
68
+ required: true
69
+
70
+ - type: dropdown
71
+ attributes:
72
+ label: "Priority"
73
+ description: "How urgent is this issue?"
74
+ options:
75
+ - "Low (Minor inconvenience)"
76
+ - "Medium (Affects functionality, but there is a workaround)"
77
+ - "High (Major functionality is broken)"
78
+ - "Critical (Site is unusable)"
79
+ validations:
80
+ required: false
81
+
82
+ - type: textarea
83
+ attributes:
84
+ label: "Additional Context (Optional)"
85
+ description: |
86
+ Provide any extra details, such as:
87
+ - Related links.
88
+ - Previous occurrences of this issue.
89
+ - Workarounds you have tried.
90
+ placeholder: "This issue also happened on [link]."
91
+ validations:
92
+ required: false
go-web-app-develop/.github/ISSUE_TEMPLATE/02_feature_request.yml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Feature Request"
2
+ description: "Suggest a new idea or enhancement."
3
+ labels: ["type: feature-request"]
4
+ type: "Feature"
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ **Feature Request**
10
+ Thank you for suggesting a new feature!
11
+ Please provide as much detail as possible to help us understand and evaluate your idea.
12
+ **Please review [our guide on suggesting enhancements](https://github.com/IFRCGo/go-web-app/blob/develop/CONTRIBUTING.md#suggesting-enhancements).**
13
+
14
+ - type: textarea
15
+ attributes:
16
+ label: "Feature Description"
17
+ description: |
18
+ Describe your feature request in detail, including:
19
+ - What the feature is.
20
+ - Why it is needed and how it will improve the project.
21
+ - How it will benefit users (e.g., As a user, I want to [do something] so that [desired outcome].).
22
+ placeholder: "As a user, I want to filter search results by date so that I can quickly find recent information."
23
+ validations:
24
+ required: true
25
+
26
+ - type: textarea
27
+ attributes:
28
+ label: "Additional Context"
29
+ description: |
30
+ Provide any extra details or supporting information, such as:
31
+ - Links to references or related resources.
32
+ - Examples from other projects or systems.
33
+ - Screenshots, mockups, or diagrams.
34
+ *Tip: You can attach files by clicking here and dragging them in.*
35
+ placeholder: |
36
+ Here's a link to a similar feature in another project: [link].
37
+ I've also attached a mockup of what this could look like.
38
+ validations:
39
+ required: false
go-web-app-develop/.github/ISSUE_TEMPLATE/03_epic_request.yml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Epic"
2
+ description: "Track a larger initiative with multiple related tasks and deliverables."
3
+ labels: ["type: epic"]
4
+ type: "Feature"
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ **Epic**
10
+ Use this to define a large, overarching initiative.
11
+ **Please review [our guide on suggesting enhancements](https://github.com/IFRCGo/go-web-app/blob/develop/CONTRIBUTING.md#suggesting-enhancements).**
12
+
13
+ - type: textarea
14
+ attributes:
15
+ label: "Epic Summary"
16
+ description: |
17
+ Provide a clear and concise summary of the epic.
18
+ - What is this epic about?
19
+ - What problem does it solve or what goal does it achieve?
20
+ - How does it align with the project’s objectives?
21
+ placeholder: |
22
+ Example:
23
+ This epic focuses on implementing a new feature.
24
+ validations:
25
+ required: true
26
+
27
+ - type: textarea
28
+ attributes:
29
+ label: "Additional Context or Resources"
30
+ description: "Provide any additional information, links, or resources that will help the team understand and execute this epic."
31
+ placeholder: |
32
+ Examples:
33
+ - Link to design mockups: [link]
34
+ - Technical specs document: [link]
35
+ - Reference to similar features: [link]
36
+ validations:
37
+ required: false
go-web-app-develop/.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ blank_issues_enabled: true
2
+ contact_links:
3
+ - name: Documentation
4
+ url: https://go-wiki.ifrc.org/en/home
5
+ about: Please consult the wiki to know more about IFRC GO.
go-web-app-develop/.github/dependabot.yml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: npm
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ groups:
8
+ eslint:
9
+ patterns:
10
+ - "*eslint*"
11
+ vite:
12
+ patterns:
13
+ - "*vite*"
14
+ postcss:
15
+ patterns:
16
+ - "*postcss*"
17
+ stylelint:
18
+ patterns:
19
+ - "*stylelint*"
20
+ all-other-dependencies:
21
+ patterns:
22
+ - "*"
23
+ exclude-patterns:
24
+ - "*eslint*"
25
+ - "*vite*"
26
+ - "*postcss*"
27
+ - "*stylelint*"
go-web-app-develop/.github/pull_request_template.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Summary
2
+
3
+ Provide a brief description of what this PR addresses and its purpose.
4
+
5
+ ## Addresses
6
+
7
+ * Issue(s): *List related issues or tickets.*
8
+
9
+ ## Depends On
10
+
11
+ * Other PRs or Dependencies: *List PRs or dependencies this PR relies on.*
12
+
13
+ ## Changes
14
+
15
+ * Detailed list or prose of changes
16
+ * Breaking changes
17
+ * Changes to configurations
18
+
19
+ ## This PR Ensures:
20
+
21
+ * \[ ] No typos or grammatical errors
22
+ * \[ ] No conflict markers left in the code
23
+ * \[ ] No unwanted comments, temporary files, or auto-generated files
24
+ * \[ ] No inclusion of secret keys or sensitive data
25
+ * \[ ] No `console.log` statements meant for debugging
26
+ * \[ ] All CI checks have passed
27
+
28
+ ## Additional Notes
29
+
30
+ *Optional: Add any other relevant context, screenshots, or details here.*
go-web-app-develop/.github/workflows/add-issue-to-backlog.yml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Add issues to Backlog
2
+
3
+ on:
4
+ issues:
5
+ types:
6
+ - opened
7
+
8
+ jobs:
9
+ add-to-project:
10
+ name: Add issue to project
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/[email protected]
14
+ with:
15
+ project-url: https://github.com/orgs/IFRCGo/projects/12
16
+ github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
go-web-app-develop/.github/workflows/chromatic.yml ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Chromatic'
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches:
7
+ - develop
8
+
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}-chromatic
11
+ cancel-in-progress: true
12
+
13
+ permissions:
14
+ actions: write
15
+ contents: read
16
+ pages: write
17
+ id-token: write
18
+
19
+ jobs:
20
+ changed-files:
21
+ name: Check for changed files
22
+ runs-on: ubuntu-latest
23
+ outputs:
24
+ all_changed_files: ${{ steps.changed-files.outputs.all_changed_files }}
25
+ any_changed: ${{ steps.changed-files.outputs.any_changed }}
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ with:
29
+ fetch-depth: 0
30
+ - name: Get changed files
31
+ id: changed-files
32
+ uses: tj-actions/changed-files@v44
33
+ with:
34
+ files: |
35
+ packages/ui/**
36
+ packages/go-ui-storybook/**
37
+ ui:
38
+ name: Build UI Library
39
+ environment: 'test'
40
+ runs-on: ubuntu-latest
41
+ needs: [changed-files]
42
+ if: ${{ needs.changed-files.outputs.any_changed == 'true' }}
43
+ defaults:
44
+ run:
45
+ working-directory: packages/ui
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ with:
49
+ fetch-depth: 0
50
+ - name: Install pnpm
51
+ uses: pnpm/action-setup@v4
52
+ - name: Install Node.js
53
+ uses: actions/setup-node@v4
54
+ with:
55
+ node-version: 20
56
+ cache: 'pnpm'
57
+ - name: Install dependencies
58
+ run: pnpm install
59
+ - name: Typecheck
60
+ run: pnpm typecheck
61
+ - name: Lint CSS
62
+ run: pnpm lint:css
63
+ - name: Lint JS
64
+ run: pnpm lint:js
65
+ - name: build UI library
66
+ run: pnpm build
67
+ - uses: actions/upload-artifact@v4
68
+ with:
69
+ name: ui-build
70
+ path: packages/ui/dist
71
+ chromatic:
72
+ name: Chromatic Deploy
73
+ runs-on: ubuntu-latest
74
+ needs: [ui]
75
+ steps:
76
+ - uses: actions/checkout@v4
77
+ with:
78
+ fetch-depth: 0
79
+ - name: Install pnpm
80
+ uses: pnpm/action-setup@v4
81
+ - name: Install Node.js
82
+ uses: actions/setup-node@v4
83
+ with:
84
+ node-version: 20
85
+ cache: 'pnpm'
86
+ - name: Install dependencies
87
+ run: pnpm install
88
+ - uses: actions/download-artifact@v4
89
+ with:
90
+ name: ui-build
91
+ path: packages/ui/dist
92
+ - name: Run Chromatic
93
+ uses: chromaui/action@v1
94
+ with:
95
+ exitZeroOnChanges: true
96
+ exitOnceUploaded: true
97
+ onlyChanged: true
98
+ skip: "@(renovate/**|dependabot/**)"
99
+ projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
100
+ token: ${{ secrets.GITHUB_TOKEN }}
101
+ autoAcceptChanges: "develop"
102
+ workingDir: packages/go-ui-storybook
103
+ github-pages:
104
+ name: Deploy to Github Pages
105
+ runs-on: ubuntu-latest
106
+ needs: [ui]
107
+ steps:
108
+ - uses: actions/checkout@v4
109
+ with:
110
+ fetch-depth: 0
111
+ - name: Install pnpm
112
+ uses: pnpm/action-setup@v4
113
+ - name: Install Node.js
114
+ uses: actions/setup-node@v4
115
+ with:
116
+ node-version: 20
117
+ cache: 'pnpm'
118
+ - uses: actions/download-artifact@v4
119
+ with:
120
+ name: ui-build
121
+ path: packages/ui/dist
122
+ - uses: bitovi/[email protected]
123
+ with:
124
+ install_command: pnpm install
125
+ build_command: pnpm build-storybook
126
+ path: packages/go-ui-storybook/storybook-static
127
+ checkout: false
go-web-app-develop/.github/workflows/ci.yml ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches:
7
+ - 'develop'
8
+
9
+ env:
10
+ APP_ADMIN_URL: ${{ vars.APP_ADMIN_URL }}
11
+ APP_API_ENDPOINT: ${{ vars.APP_API_ENDPOINT }}
12
+ APP_ENVIRONMENT: ${{ vars.APP_ENVIRONMENT }}
13
+ APP_MAPBOX_ACCESS_TOKEN: ${{ vars.APP_MAPBOX_ACCESS_TOKEN }}
14
+ APP_RISK_ADMIN_URL: ${{ vars.APP_RISK_ADMIN_URL }}
15
+ APP_RISK_API_ENDPOINT: ${{ vars.APP_RISK_API_ENDPOINT }}
16
+ APP_SENTRY_DSN: ${{ vars.APP_SENTRY_DSN }}
17
+ APP_SENTRY_NORMALIZE_DEPTH: ${{ vars.APP_SENTRY_NORMALIZE_DEPTH }}
18
+ APP_SENTRY_TRACES_SAMPLE_RATE: ${{ vars.APP_SENTRY_TRACES_SAMPLE_RATE }}
19
+ APP_SHOW_ENV_BANNER: ${{ vars.APP_SHOW_ENV_BANNER }}
20
+ APP_TINY_API_KEY: ${{ vars.APP_TINY_API_KEY }}
21
+ APP_TITLE: ${{ vars.APP_TITLE }}
22
+ GITHUB_WORKFLOW: true
23
+
24
+ concurrency:
25
+ group: ${{ github.workflow }}-${{ github.ref }}
26
+ cancel-in-progress: true
27
+
28
+ jobs:
29
+ ui:
30
+ name: Build UI Library
31
+ environment: 'test'
32
+ runs-on: ubuntu-latest
33
+ defaults:
34
+ run:
35
+ working-directory: packages/ui
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+ - name: Install pnpm
39
+ uses: pnpm/action-setup@v4
40
+ - name: Install Node.js
41
+ uses: actions/setup-node@v4
42
+ with:
43
+ node-version: 20
44
+ cache: 'pnpm'
45
+ - name: Install dependencies
46
+ run: pnpm install
47
+
48
+ - name: Typecheck
49
+ run: pnpm typecheck
50
+
51
+ - name: Lint CSS
52
+ run: pnpm lint:css
53
+
54
+ - name: Lint JS
55
+ run: pnpm lint:js
56
+
57
+ - name: Build
58
+ run: pnpm build
59
+
60
+ - uses: actions/upload-artifact@v4
61
+ with:
62
+ name: ui-build
63
+ path: packages/ui/dist
64
+
65
+ test:
66
+ name: Run tests
67
+ environment: 'test'
68
+ runs-on: ubuntu-latest
69
+ defaults:
70
+ run:
71
+ working-directory: app
72
+ needs: [ui]
73
+ steps:
74
+ - uses: actions/checkout@v4
75
+ - name: Install pnpm
76
+ uses: pnpm/action-setup@v4
77
+ - name: Install Node.js
78
+ uses: actions/setup-node@v4
79
+ with:
80
+ node-version: 20
81
+ cache: 'pnpm'
82
+ - name: Install dependencies
83
+ run: pnpm install
84
+
85
+ - uses: actions/download-artifact@v4
86
+ with:
87
+ name: ui-build
88
+ path: packages/ui/dist
89
+
90
+ - name: Run test
91
+ run: pnpm test
92
+
93
+ translation:
94
+ continue-on-error: true
95
+ name: Identify error with translation files
96
+ runs-on: ubuntu-latest
97
+ defaults:
98
+ run:
99
+ working-directory: app
100
+ needs: [ui]
101
+ steps:
102
+ - uses: actions/checkout@v4
103
+ - name: Install pnpm
104
+ uses: pnpm/action-setup@v4
105
+ - name: Install Node.js
106
+ uses: actions/setup-node@v4
107
+ with:
108
+ node-version: 20
109
+ cache: 'pnpm'
110
+ - name: Install dependencies
111
+ run: pnpm install
112
+
113
+ - uses: actions/download-artifact@v4
114
+ with:
115
+ name: ui-build
116
+ path: packages/ui/dist
117
+
118
+ - name: Identify error with translation files
119
+ run: pnpm lint:translation
120
+
121
+ translation-migrations:
122
+ if: |
123
+ (github.event_name == 'pull_request' && github.base_ref == 'develop') ||
124
+ (github.event_name == 'push' && github.ref == 'refs/heads/develop')
125
+ continue-on-error: true
126
+ name: Identify if translation migrations need to be generated
127
+ runs-on: ubuntu-latest
128
+ defaults:
129
+ run:
130
+ working-directory: app
131
+ needs: [ui]
132
+ steps:
133
+ - uses: actions/checkout@v4
134
+ - name: Install pnpm
135
+ uses: pnpm/action-setup@v4
136
+ - name: Install Node.js
137
+ uses: actions/setup-node@v4
138
+ with:
139
+ node-version: 20
140
+ cache: 'pnpm'
141
+ - name: Install dependencies
142
+ run: pnpm install
143
+
144
+ - uses: actions/download-artifact@v4
145
+ with:
146
+ name: ui-build
147
+ path: packages/ui/dist
148
+
149
+ - name: Identify if translation migrations need to be generated
150
+ run: |
151
+ if pnpm translatte:generate; then
152
+ # The step should fail if generation is possible
153
+ exit 1
154
+ fi
155
+
156
+ unused:
157
+ name: Identify unused files
158
+ runs-on: ubuntu-latest
159
+ needs: [ui]
160
+ steps:
161
+ - uses: actions/checkout@v4
162
+ - name: Install pnpm
163
+ uses: pnpm/action-setup@v4
164
+ - name: Install Node.js
165
+ uses: actions/setup-node@v4
166
+ with:
167
+ node-version: 20
168
+ cache: 'pnpm'
169
+ - name: Install dependencies
170
+ run: pnpm install
171
+
172
+ - name: Initialize types
173
+ run: pnpm initialize:type
174
+ working-directory: app
175
+
176
+ - name: Identify unused files
177
+ run: pnpm lint:unused
178
+
179
+ lint:
180
+ name: Lint JS
181
+ runs-on: ubuntu-latest
182
+ defaults:
183
+ run:
184
+ working-directory: app
185
+ needs: [ui]
186
+ steps:
187
+ - uses: actions/checkout@v4
188
+ - name: Install pnpm
189
+ uses: pnpm/action-setup@v4
190
+ - name: Install Node.js
191
+ uses: actions/setup-node@v4
192
+ with:
193
+ node-version: 20
194
+ cache: 'pnpm'
195
+ - name: Install dependencies
196
+ run: pnpm install
197
+
198
+ - uses: actions/download-artifact@v4
199
+ with:
200
+ name: ui-build
201
+ path: packages/ui/dist
202
+
203
+ - name: Lint JS
204
+ run: pnpm lint:js
205
+
206
+ lint-css:
207
+ name: Lint CSS
208
+ runs-on: ubuntu-latest
209
+ defaults:
210
+ run:
211
+ working-directory: app
212
+ needs: [ui]
213
+ steps:
214
+ - uses: actions/checkout@v4
215
+ - name: Install pnpm
216
+ uses: pnpm/action-setup@v4
217
+ - name: Install Node.js
218
+ uses: actions/setup-node@v4
219
+ with:
220
+ node-version: 20
221
+ cache: 'pnpm'
222
+ - name: Install dependencies
223
+ run: pnpm install
224
+
225
+ - uses: actions/download-artifact@v4
226
+ with:
227
+ name: ui-build
228
+ path: packages/ui/dist
229
+
230
+ - name: Lint CSS
231
+ run: pnpm lint:css
232
+
233
+ # FIXME: Identify a way to generate schema before we run typecheck
234
+ # typecheck:
235
+ # name: Typecheck
236
+ # runs-on: ubuntu-latest
237
+ # steps:
238
+ # - uses: actions/checkout@v4
239
+ # - name: Install pnpm
240
+ # uses: pnpm/action-setup@v4
241
+ # - name: Install Node.js
242
+ # uses: actions/setup-node@v4
243
+ # with:
244
+ # node-version: 20
245
+ # cache: 'pnpm'
246
+ # - name: Install dependencies
247
+ # run: pnpm install
248
+ #
249
+ # - name: Typecheck
250
+ # run: pnpm typecheck
251
+
252
+ typos:
253
+ name: Spell Check with Typos
254
+ runs-on: ubuntu-latest
255
+ steps:
256
+ - name: Checkout Actions Repository
257
+ uses: actions/checkout@v4
258
+
259
+ - name: Check spelling
260
+ uses: crate-ci/[email protected]
261
+
262
+ build:
263
+ name: Build GO Web App
264
+ environment: 'test'
265
+ runs-on: ubuntu-latest
266
+ defaults:
267
+ run:
268
+ working-directory: app
269
+ needs: [lint, lint-css, test, ui]
270
+ steps:
271
+ - uses: actions/checkout@v4
272
+ - name: Install pnpm
273
+ uses: pnpm/action-setup@v4
274
+ - name: Install Node.js
275
+ uses: actions/setup-node@v4
276
+ with:
277
+ node-version: 20
278
+ cache: 'pnpm'
279
+ - name: Install dependencies
280
+ run: pnpm install
281
+
282
+ - uses: actions/download-artifact@v4
283
+ with:
284
+ name: ui-build
285
+ path: packages/ui/dist
286
+
287
+ - name: Build
288
+ run: pnpm build
289
+
290
+ validate_helm:
291
+ name: Validate Helm
292
+ runs-on: ubuntu-latest
293
+
294
+ steps:
295
+ - uses: actions/checkout@main
296
+
297
+ - name: Install Helm
298
+ uses: azure/setup-helm@v4
299
+
300
+ - name: Helm lint
301
+ run: helm lint ./nginx-serve/helm --values ./nginx-serve/helm/values-test.yaml
302
+
303
+ - name: Helm template
304
+ run: helm template ./nginx-serve/helm --values ./nginx-serve/helm/values-test.yaml
go-web-app-develop/.github/workflows/publish-nginx-serve.yml ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish Helm
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ branches:
7
+ - develop
8
+ - project/*
9
+
10
+ permissions:
11
+ packages: write
12
+
13
+
14
+ jobs:
15
+ publish_image:
16
+ name: Publish Docker Image
17
+ runs-on: ubuntu-latest
18
+
19
+ outputs:
20
+ docker_image_name: ${{ steps.prep.outputs.tagged_image_name }}
21
+ docker_image_tag: ${{ steps.prep.outputs.tag }}
22
+ docker_image: ${{ steps.prep.outputs.tagged_image }}
23
+
24
+ steps:
25
+ - uses: actions/checkout@main
26
+
27
+ - name: Login to GitHub Container Registry
28
+ uses: docker/login-action@v3
29
+ with:
30
+ registry: ghcr.io
31
+ username: ${{ github.actor }}
32
+ password: ${{ secrets.GITHUB_TOKEN }}
33
+
34
+ - name: 🐳 Prepare Docker
35
+ id: prep
36
+ env:
37
+ IMAGE_NAME: ghcr.io/${{ github.repository }}
38
+ run: |
39
+ BRANCH_NAME=$(echo $GITHUB_REF_NAME | sed 's|[/:]|-|' | tr '[:upper:]' '[:lower:]' | sed 's/_/-/g' | cut -c1-100 | sed 's/-*$//')
40
+
41
+ # XXX: Check if there is a slash in the BRANCH_NAME eg: project/add-docker
42
+ if [[ "$BRANCH_NAME" == *"/"* ]]; then
43
+ # XXX: Change the docker image package to -alpha
44
+ IMAGE_NAME="$IMAGE_NAME-alpha"
45
+ TAG="$(echo "$BRANCH_NAME" | sed 's|/|-|g').$(echo $GITHUB_SHA | head -c7)"
46
+ else
47
+ TAG="$BRANCH_NAME.$(echo $GITHUB_SHA | head -c7)"
48
+ fi
49
+
50
+ IMAGE_NAME=$(echo $IMAGE_NAME | tr '[:upper:]' '[:lower:]')
51
+ echo "tagged_image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT
52
+ echo "tag=${TAG}" >> $GITHUB_OUTPUT
53
+ echo "tagged_image=${IMAGE_NAME}:${TAG}" >> $GITHUB_OUTPUT
54
+ echo "::notice::Tagged docker image: ${IMAGE_NAME}:${TAG}"
55
+
56
+ - name: 🐳 Set up Docker Buildx
57
+ id: buildx
58
+ uses: docker/setup-buildx-action@v3
59
+
60
+ - name: 🐳 Cache Docker layers
61
+ uses: actions/cache@v4
62
+ with:
63
+ path: /tmp/.buildx-cache
64
+ key: ${{ runner.os }}-buildx-${{ github.ref }}
65
+ restore-keys: |
66
+ ${{ runner.os }}-buildx-refs/develop
67
+ ${{ runner.os }}-buildx-
68
+
69
+ - name: 🐳 Docker build
70
+ uses: docker/build-push-action@v6
71
+ with:
72
+ context: .
73
+ builder: ${{ steps.buildx.outputs.name }}
74
+ file: nginx-serve/Dockerfile
75
+ target: nginx-serve
76
+ load: true
77
+ push: true
78
+ tags: ${{ steps.prep.outputs.tagged_image }}
79
+ cache-from: type=local,src=/tmp/.buildx-cache
80
+ cache-to: type=local,dest=/tmp/.buildx-cache-new
81
+ build-args: |
82
+ "APP_SENTRY_TRACES_SAMPLE_RATE=0.8"
83
+ "APP_SENTRY_REPLAYS_SESSION_SAMPLE_RATE=0.8"
84
+ "APP_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=0.8"
85
+
86
+ - name: 🐳 Move docker cache
87
+ run: |
88
+ rm -rf /tmp/.buildx-cache
89
+ mv /tmp/.buildx-cache-new /tmp/.buildx-cache
90
+
91
+ publish_helm:
92
+ name: Publish Helm
93
+ needs: publish_image
94
+ runs-on: ubuntu-latest
95
+
96
+ steps:
97
+ - name: Checkout code
98
+ uses: actions/checkout@v4
99
+
100
+ - name: Login to GitHub Container Registry
101
+ uses: docker/login-action@v3
102
+ with:
103
+ registry: ghcr.io
104
+ username: ${{ github.actor }}
105
+ password: ${{ secrets.GITHUB_TOKEN }}
106
+
107
+ - name: Install Helm
108
+ uses: azure/setup-helm@v3
109
+
110
+ - name: Tag docker image in Helm Chart values.yaml
111
+ env:
112
+ IMAGE_NAME: ${{ needs.publish_image.outputs.docker_image_name }}
113
+ IMAGE_TAG: ${{ needs.publish_image.outputs.docker_image_tag }}
114
+ run: |
115
+ # Update values.yaml with latest docker image
116
+ sed -i "s|SET-BY-CICD-IMAGE|$IMAGE_NAME|" nginx-serve/helm/values.yaml
117
+ sed -i "s/SET-BY-CICD-TAG/$IMAGE_TAG/" nginx-serve/helm/values.yaml
118
+
119
+ - name: Package Helm Chart
120
+ id: set-variables
121
+ run: |
122
+ # XXX: Check if there is a slash in the BRANCH_NAME eg: project/add-docker
123
+ if [[ "$GITHUB_REF_NAME" == *"/"* ]]; then
124
+ # XXX: Change the helm chart to <chart-name>-alpha
125
+ sed -i 's/^name: \(.*\)/name: \1-alpha/' nginx-serve/helm/Chart.yaml
126
+ fi
127
+
128
+ SHA_SHORT=$(git rev-parse --short HEAD)
129
+ sed -i "s/SET-BY-CICD/$SHA_SHORT/g" nginx-serve/helm/Chart.yaml
130
+ helm package ./nginx-serve/helm -d .helm-charts
131
+
132
+ - name: Push Helm Chart
133
+ env:
134
+ IMAGE: ${{ needs.publish_image.outputs.docker_image }}
135
+ OCI_REPO: oci://ghcr.io/${{ github.repository }}
136
+ run: |
137
+ OCI_REPO=$(echo $OCI_REPO | tr '[:upper:]' '[:lower:]')
138
+ PACKAGE_FILE=$(ls .helm-charts/*.tgz | head -n 1)
139
+ echo "# Helm Chart" >> $GITHUB_STEP_SUMMARY
140
+ echo "" >> $GITHUB_STEP_SUMMARY
141
+ echo "Tagged Image: **$IMAGE**" >> $GITHUB_STEP_SUMMARY
142
+ echo "" >> $GITHUB_STEP_SUMMARY
143
+ echo "Helm push output" >> $GITHUB_STEP_SUMMARY
144
+ echo "" >> $GITHUB_STEP_SUMMARY
145
+ echo '```bash' >> $GITHUB_STEP_SUMMARY
146
+ helm push "$PACKAGE_FILE" $OCI_REPO >> $GITHUB_STEP_SUMMARY
147
+ echo '```' >> $GITHUB_STEP_SUMMARY
go-web-app-develop/.github/workflows/publish-storybook-nginx-serve.yml ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish Storybook Helm
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ branches:
7
+ - develop
8
+
9
+ permissions:
10
+ packages: write
11
+
12
+
13
+ jobs:
14
+ publish_image:
15
+ name: 🐳 Publish Docker Image
16
+ runs-on: ubuntu-latest
17
+
18
+ outputs:
19
+ docker_image_name: ${{ steps.prep.outputs.tagged_image_name }}
20
+ docker_image_tag: ${{ steps.prep.outputs.tag }}
21
+ docker_image: ${{ steps.prep.outputs.tagged_image }}
22
+
23
+ steps:
24
+ - uses: actions/checkout@main
25
+
26
+ - name: Login to GitHub Container Registry
27
+ uses: docker/login-action@v3
28
+ with:
29
+ registry: ghcr.io
30
+ username: ${{ github.actor }}
31
+ password: ${{ secrets.GITHUB_TOKEN }}
32
+
33
+ - name: 🐳 Prepare Docker
34
+ id: prep
35
+ env:
36
+ IMAGE_NAME: ghcr.io/${{ github.repository }}/go-ui-storybook
37
+ run: |
38
+ BRANCH_NAME=$(echo $GITHUB_REF_NAME | sed 's|[/:]|-|' | tr '[:upper:]' '[:lower:]' | sed 's/_/-/g' | cut -c1-100 | sed 's/-*$//')
39
+ TAG="$BRANCH_NAME.$(echo $GITHUB_SHA | head -c7)"
40
+ IMAGE_NAME=$(echo $IMAGE_NAME | tr '[:upper:]' '[:lower:]')
41
+ echo "tagged_image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT
42
+ echo "tag=${TAG}" >> $GITHUB_OUTPUT
43
+ echo "tagged_image=${IMAGE_NAME}:${TAG}" >> $GITHUB_OUTPUT
44
+ echo "::notice::Tagged docker image: ${IMAGE_NAME}:${TAG}"
45
+
46
+ - name: 🐳 Set up Docker Buildx
47
+ id: buildx
48
+ uses: docker/setup-buildx-action@v3
49
+
50
+ - name: 🐳 Cache Docker layers
51
+ uses: actions/cache@v4
52
+ with:
53
+ path: /tmp/.buildx-cache
54
+ key: ${{ runner.os }}-buildx-${{ github.ref }}
55
+ restore-keys: |
56
+ ${{ runner.os }}-buildx-refs/develop
57
+ ${{ runner.os }}-buildx-
58
+
59
+ - name: 🐳 Docker build
60
+ uses: docker/build-push-action@v6
61
+ with:
62
+ context: .
63
+ builder: ${{ steps.buildx.outputs.name }}
64
+ file: packages/go-ui-storybook/nginx-serve/Dockerfile
65
+ target: nginx-serve
66
+ load: true
67
+ push: true
68
+ tags: ${{ steps.prep.outputs.tagged_image }}
69
+ cache-from: type=local,src=/tmp/.buildx-cache
70
+ cache-to: type=local,dest=/tmp/.buildx-cache-new
71
+
72
+ - name: 🐳 Move docker cache
73
+ run: |
74
+ rm -rf /tmp/.buildx-cache
75
+ mv /tmp/.buildx-cache-new /tmp/.buildx-cache
76
+
77
+ publish_helm:
78
+ name: ⎈ Publish Helm
79
+ needs: publish_image
80
+ runs-on: ubuntu-latest
81
+
82
+ steps:
83
+ - name: Checkout code
84
+ uses: actions/checkout@v4
85
+
86
+ - name: Login to GitHub Container Registry
87
+ uses: docker/login-action@v3
88
+ with:
89
+ registry: ghcr.io
90
+ username: ${{ github.actor }}
91
+ password: ${{ secrets.GITHUB_TOKEN }}
92
+
93
+ - name: ⎈ Install Helm
94
+ uses: azure/setup-helm@v3
95
+
96
+ - name: ⎈ Tag docker image in Helm Chart values.yaml
97
+ env:
98
+ IMAGE_NAME: ${{ needs.publish_image.outputs.docker_image_name }}
99
+ IMAGE_TAG: ${{ needs.publish_image.outputs.docker_image_tag }}
100
+ run: |
101
+ # Update values.yaml with latest docker image
102
+ sed -i "s|SET-BY-CICD-IMAGE|$IMAGE_NAME|" packages/go-ui-storybook/nginx-serve/helm/values.yaml
103
+ sed -i "s/SET-BY-CICD-TAG/$IMAGE_TAG/" packages/go-ui-storybook/nginx-serve/helm/values.yaml
104
+
105
+ - name: ⎈ Package Helm Chart
106
+ id: set-variables
107
+ run: |
108
+ SHA_SHORT=$(git rev-parse --short HEAD)
109
+ sed -i "s/SET-BY-CICD/$SHA_SHORT/g" packages/go-ui-storybook/nginx-serve/helm/Chart.yaml
110
+ helm package ./packages/go-ui-storybook/nginx-serve/helm -d .helm-charts
111
+
112
+ - name: ⎈ Push Helm Chart
113
+ env:
114
+ IMAGE: ${{ needs.publish_image.outputs.docker_image }}
115
+ OCI_REPO: oci://ghcr.io/${{ github.repository }}
116
+ run: |
117
+ OCI_REPO=$(echo $OCI_REPO | tr '[:upper:]' '[:lower:]')
118
+ PACKAGE_FILE=$(ls .helm-charts/*.tgz | head -n 1)
119
+ echo "## 🚀 IFRC GO UI Helm Chart 🚀" >> $GITHUB_STEP_SUMMARY
120
+ echo "" >> $GITHUB_STEP_SUMMARY
121
+ echo "🐳 Tagged Image: **$IMAGE**" >> $GITHUB_STEP_SUMMARY
122
+ echo "" >> $GITHUB_STEP_SUMMARY
123
+ echo "⎈ Helm push output" >> $GITHUB_STEP_SUMMARY
124
+ echo "" >> $GITHUB_STEP_SUMMARY
125
+ echo '```bash' >> $GITHUB_STEP_SUMMARY
126
+ helm push "$PACKAGE_FILE" $OCI_REPO >> $GITHUB_STEP_SUMMARY
127
+ echo '```' >> $GITHUB_STEP_SUMMARY
go-web-app-develop/.gitignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ build
14
+ build-ssr
15
+ *.local
16
+
17
+ # Editor directories and files
18
+ .vscode/*
19
+ !.vscode/extensions.json
20
+ .idea
21
+ .DS_Store
22
+ *.suo
23
+ *.ntvs*
24
+ *.njsproj
25
+ *.sln
26
+ *.sw?
27
+
28
+ .env*
29
+ !.env.example
30
+ .eslintcache
31
+ tsconfig.tsbuildinfo
32
+
33
+ # Custom ignores
34
+
35
+ stats.html
36
+ generated/
37
+ coverage/
38
+
39
+ # storybook build
40
+ storybook-static/
41
+
42
+ # Helm
43
+ .helm-charts/
go-web-app-develop/.npmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ enable-pre-post-scripts=true
go-web-app-develop/COLLABORATING.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # IFRC GO Collaboration Guide
2
+
3
+ This document offers guidelines for collaborators on codebase maintenance, testing, building and deployment, and issue management.
4
+
5
+ ## Repository
6
+
7
+ * [Issues and Pull Requests](./collaborating/issues-and-pull-requests.md)
8
+ * [Structure](./collaborating/repository-structure.md)
9
+ * [Linting](./collaborating/linting.md)
10
+ * [Technology Used](./collaborating/technology.md)
11
+
12
+ ## Development
13
+
14
+ * [Developing](./collaborating/developing.md)
15
+ * [Translation](./collaborating/translation.md)
16
+ * [Building](./collaborating/building.md)
17
+ * [Testing](./collaborating/testing.md)
18
+ * [Release](./collaborating/release.md)
go-web-app-develop/CONTRIBUTING.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # IFRC GO Web Application Contributing Guide
2
+
3
+ First off, thanks for taking the time to contribute! ❤️
4
+
5
+ All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution.
6
+
7
+ ## Table of Contents
8
+
9
+ * [I Have a Question](#i-have-a-question)
10
+ * [I Want To Contribute](#i-want-to-contribute)
11
+ * [What should I know before I get started?](#what-should-i-know-before-i-get-started)
12
+ * [Reporting Bugs](#reporting-bugs)
13
+ * [Suggesting Enhancements](#suggesting-enhancements)
14
+ * [Becoming a Collaborator](#becoming-a-collaborator)
15
+
16
+ ## I Have a Question
17
+
18
+ > If you want to ask a question, we assume that you have read the available [documentation](https://go-wiki.ifrc.org/en/home).
19
+
20
+ Before you ask a question, it is best to search for existing [issues](https://github.com/IFRCGo/go-web-app/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue.
21
+
22
+ If you then still feel the need to ask a question and need clarification, we recommend the following:
23
+
24
+ * Open a [discussion](https://github.com/IFRCGo/go-web-app/discussions).
25
+ * Open an [issue](https://github.com/IFRCGo/go-web-app/issues/new/choose).
26
+ * Provide as much context as you can about what you're running into.
27
+
28
+ ## I Want To Contribute
29
+
30
+ Any individual is welcome to contribute to IFRC GO. The repository currently has two kinds of contribution personas:
31
+
32
+ * A **Contributor** is any individual who creates an issue/PR, comments on an issue/PR, or contributes in some other way.
33
+ * A **Collaborator** is a contributor with write access to the repository.
34
+
35
+ ### What should I know before I get started?
36
+
37
+ ### IFRC GO and Packages
38
+
39
+ The project is hosted at <https://go.ifrc.org/>.
40
+
41
+ The project comprises several [repositories](https://github.com/orgs/IFRCGo/repositories), with notable ones including:
42
+
43
+ * [go-web-app](https://github.com/IFRCGo/go-web-app/) - The frontend repository for the IFRC GO project.
44
+ * [go-api](https://github.com/IFRCGo/go-api) - The backed repository for the IFRC GO project.
45
+
46
+ ### Reporting Bugs
47
+
48
+ #### Before Submitting a Bug Report
49
+
50
+ Ensure the issue is not a user error by reviewing the documentation. Check the [existing bug reports](https://github.com/IFRCGo/go-web-app/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug) to confirm if the issue has already been reported.
51
+
52
+ #### Submitting the Bug Report
53
+
54
+ 1. Open a new [Issue](https://github.com/IFRCGo/go-web-app/issues/new?q=is%3Aissue+state%3Aopen+type%3ABug\&template=01_bug_report.yml).
55
+ 2. Provide all relevant details.
56
+
57
+ #### After Submitting the Issue
58
+
59
+ * The team will categorize and attempt to reproduce the issue.
60
+ * If reproducible, the team will work on resolving the bug.
61
+
62
+ ### Suggesting Enhancements
63
+
64
+ #### Before Submitting an Enhancement
65
+
66
+ * Review the [documentation](https://go-wiki.ifrc.org/en/home) to ensure the functionality isn't already covered.
67
+ * Perform a [search](https://github.com/IFRCGo/go-web-app/issues) to check if the enhancement has been suggested. If so, comment on the existing issue.
68
+ * Confirm that your suggestion aligns with the project’s scope and objectives.
69
+
70
+ #### How to Submit an Enhancement Suggestion
71
+
72
+ Enhancements are tracked as [GitHub issues](https://github.com/IFRCGo/go-web-app/issues).
73
+
74
+ * Open a new [feature request](https://github.com/IFRCGo/go-web-app/issues/new?q=is%3Aissue+state%3Aopen+type%3ABug\&template=02_feature_request.yml) or [Epic ticket](https://github.com/IFRCGo/go-web-app/issues/new?q=is%3Aissue+state%3Aopen+type%3ABug\&template=03_epic_request.yml) depending on the scale of the enhancement.
75
+ * Provide a clear description and submit the ticket.
76
+
77
+ ## Becoming a Collaborator
78
+
79
+ Collaborators are key members of the IFRC GO Web Application Team, responsible for its development. Members should have expertise in modern web technologies and standards.
80
+
81
+ For detailed guidelines, refer to the [Collaboration Guide](./COLLABORATING.md).
go-web-app-develop/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2023 GO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
go-web-app-develop/README.md ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <p align="center">
2
+ <br />
3
+ <a href="https://go.ifrc.org/">
4
+ <picture>
5
+ <img src="https://github.com/IFRCGo/go-web-app/blob/develop/app/src/assets/icons/go-logo-2020.svg" width="200px" alt="IFRC GO Logo">
6
+ </picture>
7
+ </a>
8
+ </p>
9
+
10
+ # IFRC GO
11
+
12
+ [IFRC GO](https://go.ifrc.org/) is the platform of the International Federation of Red Cross and Red Crescent, aimed at connecting crucial information on emergency needs with the appropriate response. This repository houses the frontend source code for the application, developed using [React](https://react.dev/), [Vite](https://vitejs.dev/), and associated technologies.
13
+
14
+ ## Built With
15
+
16
+ [![React][react-shields]][react-url] [![Vite][vite-shields]][vite-url] [![TypeScript][typescript-shields]][typescript-url] [![pnpm][pnpm-shields]][pnpm-url]
17
+
18
+ ## Getting Started
19
+
20
+ Below are the steps to guide you through preparing your local environment for IFRC GO Web application development. The repository is set up as a [monorepo](https://monorepo.tools/). The [app](https://github.com/IFRCGo/go-web-app/tree/develop/app) directory houses the application code, while the [packages](https://github.com/IFRCGo/go-web-app/tree/develop/packages) directory contains related packages, including the [IFRC GO UI](https://www.npmjs.com/package/@ifrc-go/ui) components library.
21
+
22
+ ### Prerequisites
23
+
24
+ To begin, ensure you have network access. Then, you'll need the following:
25
+
26
+ 1. [Git](https://git-scm.com/)
27
+ 2. [Node.js](https://nodejs.org/en/) as specified under `engines` section in `package.json` file
28
+ 3. [pnpm](https://pnpm.io/) as specified under `engines` section in `package.json` file
29
+ 4. Alternatively, you can use [Docker](https://www.docker.com/) to build the application.
30
+
31
+ > \[!NOTE]\
32
+ > Make sure the correct versions of pnpm and Node.js are installed. They are specified under `engines` section in `package.json` file.
33
+
34
+ ### Local Development
35
+
36
+ 1. Clone the repository using HTTPS, SSH, or GitHub CLI:
37
+
38
+ ```bash
39
+ git clone https://github.com/IFRCGo/go-web-app.git # HTTPS
40
+ git clone [email protected]:IFRCGo/go-web-app.git # SSH
41
+ gh repo clone IFRCGo/go-web-app # GitHub CLI
42
+ ```
43
+
44
+ 2. Install the dependencies:
45
+
46
+ ```bash
47
+ pnpm install
48
+ ```
49
+
50
+ 3. Create a `.env` file in the `app` directory and add variables from [env.ts](https://github.com/IFRCGo/go-web-app/blob/develop/app/env.ts). Any variables marked with `.optional()` are not mandatory for setup and can be skipped.
51
+
52
+ ```bash
53
+ cd app
54
+ touch .env
55
+ ```
56
+
57
+ Example `.env` file
58
+ ```
59
+ APP_TITLE=IFRC GO
60
+ APP_ENVIRONMENT=testing
61
+ ...
62
+ ```
63
+
64
+ 4. Start the development server:
65
+
66
+ ```bash
67
+ pnpm start:app
68
+ ```
69
+
70
+ ## Contributing
71
+
72
+ * Check out existing [Issues](https://github.com/IFRCGo/go-web-app/issues) and [Pull Requests](https://github.com/IFRCGo/go-web-app/pulls) to contribute.
73
+ * To request a feature or report a bug, [create a GitHub Issue](https://github.com/IFRCGo/go-web-app/issues/new/choose).
74
+ * [Contribution Guide →](./CONTRIBUTING.md)
75
+ * [Collaboration Guide →](./COLLABORATING.md)
76
+
77
+ ## Additional Packages
78
+
79
+ The repository hosts multiple packages under the `packages` directory.
80
+
81
+ 1. [IFRC GO UI](https://github.com/IFRCGo/go-web-app/tree/develop/packages/ui) is a React UI components library tailored to meet the specific requirements of the IFRC GO community and its associated projects.
82
+ 2. [IFRC GO UI Storybook](https://github.com/IFRCGo/go-web-app/tree/develop/packages/go-ui-storybook) serves as the comprehensive showcase for the IFRC GO UI components library. It is hosted on [Chromatic](https://66557be6b68dacbf0a96db23-zctxglhsnk.chromatic.com/).
83
+
84
+ ## IFRC GO Backend
85
+
86
+ The backend that serves the frontend application is maintained in a separate [repository](https://github.com/IFRCGo/go-api/).
87
+
88
+ ## Previous Repository
89
+
90
+ [Go Frontend](https://github.com/IFRCGo/go-frontend) is the previous version of the project which contains the original codebase and project history.
91
+
92
+ ## Community & Support
93
+
94
+ * Visit the [IFRC GO Wiki](https://go-wiki.ifrc.org/) for documentation of the IFRC GO platform.
95
+ * Stay informed about the latest project updates on [Medium](https://ifrcgoproject.medium.com/).
96
+
97
+ ## License
98
+
99
+ [MIT](https://github.com/IFRCGo/go-web-app/blob/develop/LICENSE)
100
+
101
+ <!-- MARKDOWN LINKS & IMAGES -->
102
+
103
+ [react-shields]: https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB
104
+
105
+ [react-url]: https://reactjs.org/
106
+
107
+ [vite-shields]: https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white
108
+
109
+ [vite-url]: https://vitejs.dev/
110
+
111
+ [typescript-shields]: https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white
112
+
113
+ [typescript-url]: https://www.typescriptlang.org/
114
+
115
+ [pnpm-shields]: https://img.shields.io/badge/pnpm-F69220?style=for-the-badge&logo=pnpm&logoColor=fff
116
+
117
+ [pnpm-url]: https://pnpm.io/
go-web-app-develop/app/CHANGELOG.md ADDED
@@ -0,0 +1,729 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # go-web-app
2
+
3
+ ## 7.21.0-beta.2
4
+
5
+ ### Patch Changes
6
+
7
+ - b949fcd: Fix use of operational timeframe date in imminent final report form
8
+
9
+ ## 7.21.0-beta.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 84b4802: - Fix calculation of Operation End date in Final report form
14
+ - Fix icon position issue in the implementation table of DREF PDF export
15
+ - Update the label for last update date in the crisis categorization pop-up
16
+
17
+ ## 7.21.0-beta.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 039c488: Add Crisis categorization update date
22
+
23
+ - Add updated date for crisis categorization in emergency page.
24
+ - Add consent checkbox over situational overview in field report form.
25
+
26
+ - 3ee9979: Add support for DREF imminent v2 in final report
27
+
28
+ - Add a separate route for the old dref final report form
29
+ - Update dref final report to accomodate imminent v2 changes
30
+
31
+ ## 7.20.2
32
+
33
+ ### Patch Changes
34
+
35
+ - 8090b9a: Fix other action section visibility condition in DREF export
36
+
37
+ ## 7.20.1
38
+
39
+ ### Patch Changes
40
+
41
+ - 4418171: Fix DREF form to properly save major coordination mechanism [#1928](https://github.com/IFRCGo/go-web-app/issues/1928)
42
+
43
+ ## 7.20.1-beta.0
44
+
45
+ ### Patch Changes
46
+
47
+ - 4418171: Fix DREF form to properly save major coordination mechanism [#1928](https://github.com/IFRCGo/go-web-app/issues/1928)
48
+
49
+ ## 7.20.0
50
+
51
+ ### Minor Changes
52
+
53
+ - 5771a6b: Update DREF application form and export
54
+
55
+ - add new field hazard date and location
56
+ - update hazard date as forcasted day of event
57
+ - update the section in dref application export
58
+ - remove Current National Society Actions from the export
59
+
60
+ ## 7.20.0-beta.0
61
+
62
+ ### Minor Changes
63
+
64
+ - 5771a6b: Update DREF application form and export
65
+
66
+ - add new field hazard date and location
67
+ - update hazard date as forcasted day of event
68
+ - update the section in dref application export
69
+ - remove Current National Society Actions from the export
70
+
71
+ ## 7.19.0
72
+
73
+ ### Minor Changes
74
+
75
+ - 456a145: Fix versioning
76
+
77
+ ### Patch Changes
78
+
79
+ - 47786f8: Fix the undefined society name issue in surge page [#1899](https://github.com/IFRCGo/go-web-app/issues/1899)
80
+
81
+ ## 7.18.2
82
+
83
+ ### Patch Changes
84
+
85
+ - e51a80f: Update the action for the DREF Ops update form for imminent.
86
+ - Remove change to response modal in the ops update form for type imminent.
87
+ - Fix the order of the field in operational timeframe tab.
88
+ - Add description text under upload assessment report button in DREF operation update form
89
+ - Fix the error while viewing PER process [#1838](https://github.com/IFRCGo/go-web-app/issues/1838).
90
+
91
+ ## 7.18.1
92
+
93
+ ### Patch Changes
94
+
95
+ - 75bf525: Fix logic to disable ops update for old imminents
96
+
97
+ ## 7.18.0
98
+
99
+ ### Minor Changes
100
+
101
+ - bfcaecf: Address [Dref imminent Application](https://github.com/IFRCGo/go-web-app/issues/1455)
102
+ - Update logic for creation of dref final report for imminent
103
+ - Update allocatioon form for dref imminent
104
+ - Add Activity input in proposed action for dref type imminent
105
+ - Add proposed actions icons
106
+ - Show proposed actions for existing imminent dref applications
107
+ - Hide unused sections for dref imminent export and preserve proposed actions order
108
+ - Prevent selection of past dates for the `hazard_date` in dref imminent
109
+ - Add auto total population calculation in dref
110
+ - Add a confirmation popup before creating ops. update from imminent dref
111
+
112
+ ### Patch Changes
113
+
114
+ - ee1bd60: Add proper redirect for Non-sovereign country in the country ongoing emergencies page
115
+ - 771d085: Community Based Surveillance updates (Surge CoS Health)
116
+ - Changed page: https://go.ifrc.org/surge/catalogue/health/community-based-surveillance
117
+ - The changes affect team size and some standard components (e.g. kit content)
118
+ - Updated dependencies [bfcaecf]
119
+ - @ifrc-go/[email protected]
120
+
121
+ ## 7.17.4
122
+
123
+ ### Patch Changes
124
+
125
+ - 14a7f2c: Update People assisted field label in the export of Dref final report.
126
+
127
+ ## 7.17.3
128
+
129
+ ### Patch Changes
130
+
131
+ - fc8b427: Update field label in DrefFinalReport form and export
132
+
133
+ ## 7.17.2
134
+
135
+ ### Patch Changes
136
+
137
+ - 54df6ff: Update DREF final report form
138
+
139
+ - The DREF final report form and export now include a new "Assisted Population" field, replacing the "Targeted Population" field.
140
+
141
+ ## 7.17.1
142
+
143
+ ### Patch Changes
144
+
145
+ - 215030a: Update DREF forms
146
+
147
+ - Move Response strategy description from placeholder to below the input
148
+ - Add DREF allocation field in event details for the Loan type Ops. update form
149
+
150
+ ## 7.17.0
151
+
152
+ ### Minor Changes
153
+
154
+ - 0b351d1: Address [DREF Superticket 2 bugs](https://github.com/IFRCGo/go-web-app/issues/1784)
155
+
156
+ - Update no of images in for "Description of event" from 2 to 4
157
+ - Update descriptions of few fields
158
+ - Replace \* with bullet in description of planned interventions in DREF import
159
+ - Add some of the missing fields to exports
160
+ - Remove warnings for previously removed fields
161
+
162
+ ## 7.16.2
163
+
164
+ ### Patch Changes
165
+
166
+ - c086629: Update Learn > Resources > Montandon page
167
+ - Update styling of 'API Access' buttons
168
+ - Reword 'Access API' link to 'Access Montandon API'
169
+ - Reword 'Explore Radiant Earth API' to 'Explore data in STAC browser'
170
+ - 2ee6a1e: Remove a broken image from Catalogue of Surge Services > Health > ERU Hospital page
171
+
172
+ ## 7.16.1
173
+
174
+ ### Patch Changes
175
+
176
+ - d561dc4: - Update Montandon landing page - Fix typo in Justin's name and email - Update description
177
+ - Fix position and deploying organisation in ongoing RR deployment table
178
+
179
+ ## 7.16.0
180
+
181
+ ### Minor Changes
182
+
183
+ - 9dcdd38: Add Montandon landing page
184
+
185
+ - Add a basic landing page for Montandon with links and information
186
+ - Add link to Montandon landing page to Learn > Resources menu
187
+
188
+ ## 7.15.0
189
+
190
+ ### Minor Changes
191
+
192
+ - c26bda4: Implement [ERU Readiness](https://github.com/IFRCGo/go-web-app/issues/1710)
193
+
194
+ - Restucture surge page to acommodate ERU
195
+ - Move surge deployment related sections to a new dedicated tab **Active Surge Deployments**
196
+ - Update active deployments to improve scaling of points in the map
197
+ - Add **Active Surge Support per Emergency** section
198
+ - Revamp **Surge Overview** tab
199
+ - Add **Rapid Response Personnel** sub-tab
200
+ - Update existings charts and add new related tables/charts
201
+ - Add **Emergency Response Unit** sub-tab
202
+ - Add section to visualize ERU capacity and readiness
203
+ - Add section to view ongoing ERU deployments
204
+ - Add a form to update ERU Readiness
205
+ - Add option to export ERU Readiness data
206
+ - Update **Respond > Surge/Deployments** menu to include **Active Surge Deployments**
207
+
208
+ - 9ed8181: Address feedbacks in [DREF superticket feedbacks](https://github.com/IFRCGo/go-web-app/issues/1816)
209
+
210
+ - Make end date of operation readonly field in all DREF forms
211
+ - Fix font and spacing issues in the DREF exports (caused by link text overflow)
212
+ - Update styling of Risk and Security Considerations section to match that of Previous Operations
213
+ - Update visibility condition of National Society Actions in Final Report export
214
+
215
+ ### Patch Changes
216
+
217
+ - Updated dependencies [c26bda4]
218
+ - @ifrc-go/[email protected]
219
+
220
+ ## 7.14.0
221
+
222
+ ### Minor Changes
223
+
224
+ - 18ccc85:
225
+ - Update styling of vertical NavigationTab
226
+ - Hide register URL in the T&C page for logged in user
227
+ - Update styling of T&C page
228
+ - Make the page responsive
229
+ - Make sidebar sticky
230
+ - Update url for [monty docs](https://github.com/IFRCGo/go-web-app/issues/1418#issuecomment-2422371363)
231
+ - 8d3a7bd: Initiate shutdown for 3W
232
+ - Remove "Submit 3W Projects" from the menu Prepare > Global 3W projects
233
+ - Rename "Global 3W Projects" to "Programmatic Partnerships" in Prepare menu
234
+ - Update global 3W page
235
+ - Update title and description for Programmatic Partnerships
236
+ - Remove all the contents related to 3W
237
+ - Replace contents in various places with project shutdown message
238
+ - Regional 3W tab
239
+ - 3W Projects section in Accounts > My Form > 3W
240
+ - Projects tab in Country > Ongoing Activities
241
+ - All Projects page
242
+ - New, edit 3W project form
243
+ - View 3W project page
244
+ - Remove NS Activities section in Country > NS overview > NS Activities page
245
+ - Remove Projects section from search results page
246
+
247
+ ### Patch Changes
248
+
249
+ - Updated dependencies [18ccc85]
250
+ - @ifrc-go/[email protected]
251
+
252
+ ## 7.13.0
253
+
254
+ ### Minor Changes
255
+
256
+ - 69fd74f: - Update page title for Emergency to include the name
257
+ - Update page title of Flash update to include the name
258
+ - Fix the user registration link in the Terms & Condition page
259
+ - 680c673: Implement [DREF Superticket 2.0](https://github.com/IFRCGo/go-web-app/issues/1695)
260
+
261
+ ### Patch Changes
262
+
263
+ - fe4b727: - Upgrade pnpm to v10.6.1
264
+ - Cleanup Dockerfile
265
+ - Configure depandabot to track other dependencies updates
266
+ - Upgrade eslint
267
+ - Use workspace protocol to reference workspace packages
268
+ - 9f20016: Enable user to edit their position field in [#1647](https://github.com/IFRCGo/go-web-app/issues/1647)
269
+ - ef15af1: Add secondary ordering in tables for rows with same date
270
+ - Updated dependencies [fe4b727]
271
+ - @ifrc-go/[email protected]
272
+
273
+ ## 7.12.1
274
+
275
+ ### Patch Changes
276
+
277
+ - Fix nullable type of assessment for NS capacity
278
+
279
+ ## 7.12.0
280
+
281
+ ### Minor Changes
282
+
283
+ - f766bc7: Add link to IFRC Survey Designer in the tools section under learn menu
284
+
285
+ ### Patch Changes
286
+
287
+ - 7f51854: - Surge CoS: Health fix
288
+ - 3a1cac8: Hide focal point details based on user permissions
289
+ - 43d3bf1: - Add Surge CoS Administration section
290
+ - Add Surge CoS Faecal Sludge Management (FSM) section
291
+ - Update Surge CoS IT&T section
292
+ - Update Surge CoS Basecamp section (as OSH)
293
+
294
+ ## 7.11.1
295
+
296
+ ### Patch Changes
297
+
298
+ - ff426cd: Use current language for field report title generation
299
+
300
+ ## 7.11.0
301
+
302
+ ### Minor Changes
303
+
304
+ - Field report number generation: Change only when the country or event changes
305
+
306
+ ## 7.10.1
307
+
308
+ ### Patch Changes
309
+
310
+ - 14567f1: Improved tables by adding default and second-level ordering in [#1633](https://github.com/IFRCGo/go-web-app/issues/1633)
311
+
312
+ - Appeal Documents table, `emergencies/{xxx}/reports` page
313
+ - Recent Emergencies in Regions – All Appeals table
314
+ - All Deployed Personnel – Default sorting (filters to be added)
315
+ - Deployed ERUs – Changed filter title
316
+ - Key Documents tables in Countries
317
+ - Response documents
318
+ - Main page – Active Operations table
319
+ - The same `AppealsTable` is used in:
320
+ - Active Operations in Regions
321
+ - Previous Operations in Countries
322
+
323
+ - 78d25b2:
324
+
325
+ - Update on the ERU MHPSS Module in the Catalogue of Services in [#1648](https://github.com/IFRCGo/go-web-app/issues/1648)
326
+ - Update on a PER role profile in [#1648](https://github.com/IFRCGo/go-web-app/issues/1648)
327
+ - Update link to the IM Technical Competency Framework in [#1483](https://github.com/IFRCGo/go-web-app/issues/1483)
328
+
329
+ - 44623a7: Undo DREF Imminent changes
330
+ - b57c453: Show the number of people assisted in the DREF Final Report export in [#1665](https://github.com/IFRCGo/go-web-app/issues/1665)
331
+
332
+ ## 7.10.0
333
+
334
+ ### Minor Changes
335
+
336
+ - 4f89133: Fix DREF PGA export styling
337
+
338
+ ## 7.9.0
339
+
340
+ ### Minor Changes
341
+
342
+ - 7927522: Update Imminent DREF Application in [#1455](https://github.com/IFRCGo/go-web-app/issues/1455)
343
+
344
+ - Hide sections/fields
345
+ - Rename sections/fields
346
+ - Remove sections/fields
347
+ - Reflect changes in the PDF export
348
+
349
+ ### Patch Changes
350
+
351
+ - Updated dependencies [4032688]
352
+ - @ifrc-go/[email protected]
353
+
354
+ ## 7.8.1
355
+
356
+ ### Patch Changes
357
+
358
+ - 9c51dee: Remove `summary` field from field report form
359
+ - Update @ifrc-go/ui version
360
+
361
+ ## 7.8.0
362
+
363
+ ### Minor Changes
364
+
365
+ - 4843cb0: Added Operational Learning 2.0
366
+
367
+ - Key Figures Overview in Operational Learning
368
+ - Map View for Operational Learning
369
+ - Learning by Sector Bar Chart
370
+ - Learning by Region Bar Chart
371
+ - Sources Over Time Line Chart
372
+ - Methodology changes for the prioritization step
373
+ - Added an option to regenerate cached summaries
374
+ - Summary post-processing and cleanup
375
+ - Enabled MDR code search in admin
376
+
377
+ ### Patch Changes
378
+
379
+ - f96e177: Move field report/emergency title generation logic from client to server
380
+ - e85fc32: Integrate `crate-ci/typos` for code spell checking
381
+ - 4cdea2b: Add redirection logic for `preparedness#operational-learning`
382
+ - 9a50443: Add appeal doc type for appeal documents
383
+ - 817d56d: Display properly formatted appeal type in search results
384
+ - 1159fa4: Redirect obsolete URLs to recent ones
385
+ - redirect `/reports/` to `/field-reports/`
386
+ - redirect `/deployments/` -> `/surge/overview`
387
+ - Updated dependencies [4843cb0]
388
+ - @ifrc-go/[email protected]
389
+
390
+ ## 7.7.0
391
+
392
+ ### Minor Changes
393
+
394
+ - 3258b96: Add local unit validation workflow
395
+
396
+ ### Patch Changes
397
+
398
+ - Updated dependencies [c5a446f]
399
+ - @ifrc-go/[email protected]
400
+
401
+ ## 7.6.6
402
+
403
+ ### Patch Changes
404
+
405
+ - 8cdc946: Hide Local unit contact details on the list view for logged in users in [#1485](https://github.com/ifRCGo/go-web-app/issues/1485)
406
+ Update `tinymce-react` plugin to the latest version and enabled additional plugins, including support for lists in [#1481](https://github.com/ifRCGo/go-web-app/issues/1481)
407
+ - ecca810: Replace the from-communication-copied text of CoS Health header
408
+ - 7cf2514: Prioritize GDACS as the Primary Source for Imminent Risk Watch in [#1547](https://github.com/IFRCGo/go-web-app/issues/1547)
409
+ - 8485076: Add Organization type and Learning type filter in Operational learning in [#1469](https://github.com/IFRCGo/go-web-app/issues/1469)
410
+ - 766d98d: Auto append https:// for incomplete URLs in [#1505](https://github.com/IFRCGo/go-web-app/issues/1505)
411
+
412
+ ## 7.6.5
413
+
414
+ ### Patch Changes
415
+
416
+ - 478e73b: Update labels for severity control in Imminent Risk Map
417
+ Update navigation for the events in Imminent Risk Map
418
+ Fix issue displayed when opening a DREF import template
419
+ Fix submission issue when importing a DREF import file
420
+ - f82f846: Update Health Section in Catalogue of Surge Services
421
+ - ade84aa: Display ICRC Presence
422
+ - Display ICRC presence across partner countries
423
+ - Highlight key operational countries
424
+
425
+ ## 7.6.4
426
+
427
+ ### Patch Changes
428
+
429
+ - d85f64d: Update Imminent Events
430
+
431
+ - Hide WFP ADAM temporarily from list sources
432
+ - Show exposure control for cyclones from GDACS only
433
+
434
+ ## 7.6.3
435
+
436
+ ### Patch Changes
437
+
438
+ - 7bbf3d2: Update key insights disclaimer text in Ops. Learning
439
+ - 0e40681: Update FDRS data in Country / Context and Structure / NS indicators
440
+
441
+ - Add separate icon for each field for data year
442
+ - Use separate icon for disaggregation
443
+ - Update descriptions on dref import template (more details on _Missing / to be implemented_ section in https://github.com/IFRCGo/go-web-app/pull/1434#issuecomment-2459034932)
444
+
445
+ - Updated dependencies [801ec3c]
446
+ - @ifrc-go/[email protected]
447
+
448
+ ## 7.6.2
449
+
450
+ ### Patch Changes
451
+
452
+ - 4fa6a36: Updated PER terminology and add PER logo in PER PDF export
453
+ - 813e93f: Add link to GO UI storybook in resources page
454
+ - 20dfeb3: Update DREF import template
455
+ - Update guidance
456
+ - Improve template stylings
457
+ - Update message in error popup when import fails
458
+ - 8a18ad8: Add beta tag, URL redirect, and link to old dashboard on Ops Learning
459
+
460
+ ## 7.6.1
461
+
462
+ ### Patch Changes
463
+
464
+ - 7afaf34: Fix null event in appeal for operational learning
465
+
466
+ ## 7.6.0
467
+
468
+ ### Minor Changes
469
+
470
+ - Add new Operational Learning Page
471
+
472
+ - Add link to Operational Learning page under `Learn` navigation menu
473
+ - Integrate LLM summaries for Operational Learning
474
+
475
+ ## 7.5.3
476
+
477
+ ### Patch Changes
478
+
479
+ - d7f5f53: Revamp risk imminent events for cyclone
480
+ - Visualize storm position, forecast uncertainty, track line and exposed area differently
481
+ - Add option to toggle visibility of these different layers
482
+ - Add severity legend for exposure
483
+ - Update styling for items in event list
484
+ - Update styling for event details page
485
+ - 36a64fa: Integrate multi-select functionality in operational learning filters to allow selection of multiple filter items.
486
+ - 894d00c: Add a new 404 page
487
+ - 7757e54: Add an option to download excel import template for DREF (Response) which user can fill up and import.
488
+ - a8d021d: Update resources page
489
+ - Add a new video for LocalUnits
490
+ - Update ordering of videos
491
+ - aea512d: Prevent users from pasting images into rich text field
492
+ - fd54657: Add Terms and Conditions page
493
+ - bf55ccc: Add Cookie Policy page
494
+ - df80c4f: Fix contact details in Field Report being always required when filled once
495
+ - 81dc3bd: Added color mapping based on PER Area and Rating across all PER charts
496
+ - Updated dependencies [dd92691]
497
+ - Updated dependencies [d7f5f53]
498
+ - Updated dependencies [fe6a455]
499
+ - Updated dependencies [81dc3bd]
500
+ - @ifrc-go/[email protected]
501
+
502
+ ## 7.5.2
503
+
504
+ ### Patch Changes
505
+
506
+ - 37bba31: Add collaboration guide
507
+
508
+ ## 7.5.1
509
+
510
+ ### Patch Changes
511
+
512
+ - 2a5e4a1: Add Core Competency Framework link to Resources page in [#1331](https://github.com/IFRCGo/go-web-app/issues/1331)
513
+ - 31eaa97: Add Health Mapping Report to Resources page in [#1331](https://github.com/IFRCGo/go-web-app/issues/1331)
514
+ - 4192da1: - Local Units popup, view/edit mode improvements in [#1178](https://github.com/IFRCGo/go-web-app/issues/1178)
515
+ - Remove ellipsize heading option in local units map popup
516
+ - Local units title on popup are now clickable that opens up a modal to show details
517
+ - Added an Edit button to the View Mode for users with edit permissions
518
+ - Users will now see a **disabled grey button** when the content is already validated
519
+ - 5c7ab88: Display the public visibility field report to public users in [#1743](https://github.com/IFRCGo/go-web-app/issues/1343)
520
+
521
+ ## 7.5.0
522
+
523
+ ### Minor Changes
524
+
525
+ - 5845699: Clean up Resources page
526
+
527
+ ## 7.4.2
528
+
529
+ ### Patch Changes
530
+
531
+ - d734e04: - Fix duplication volunteer label in the Field Report details
532
+ - Fix rating visibility in the Country > NS Overview > Strategic priorities page
533
+
534
+ ## 7.4.1
535
+
536
+ ### Patch Changes
537
+
538
+ - a4f77ab: Fetch and use latest available WorldBank data in [#571](https://github.com/IFRCGo/go-api/issues/2224)
539
+ - ebf033a: Update Technical Competencies Link on the Cash page of the Catalogue of Surge Services in [#1290](https://github.com/IFRCGo/go-web-app/issues/1290)
540
+ - 18d0dc9: Use `molnix status` to filter surge alerts in [#2208](https://github.com/IFRCGo/go-api/issues/2208)
541
+ - b070c66: Check guest user permission for local units
542
+ - 72df1f2: Add new drone icon for UAV team in [#1280](https://github.com/IFRCGo/go-web-app/issues/1280)
543
+ - 2ff7940: Link version number to release notes on GitHub in [#1004](https://github.com/IFRCGo/go-web-app/issues/1004)
544
+ Updated @ifrc-go/icons to v2.0.1
545
+ - Updated dependencies [72df1f2]
546
+ - @ifrc-go/[email protected]
547
+
548
+ ## 7.4.0
549
+
550
+ ### Minor Changes
551
+
552
+ - b6bd6aa: Implement Guest User Permission in [#1237](https://github.com/IFRCGo/go-web-app/issues/1237)
553
+
554
+ ## 7.3.13
555
+
556
+ ### Patch Changes
557
+
558
+ - 453a397: - Update Local Unit map, table and form to match the updated design in [#1178](https://github.com/IFRCGo/go-web-app/issues/1178)
559
+ - Add delete button in Local units table and form
560
+ - Use filter prop in container and remove manual stylings
561
+ - Update size of WikiLink to match height of other action items
562
+ - Add error boundary to BaseMap component
563
+ - Updated dependencies [453a397]
564
+ - @ifrc-go/[email protected]
565
+
566
+ ## 7.3.12
567
+
568
+ ### Patch Changes
569
+
570
+ - ba6734e: Show admin labels in maps in different languages, potentially fixing [#1036](https://github.com/IFRCGo/go-web-app/issues/1036)
571
+
572
+ ## 7.3.11
573
+
574
+ ### Patch Changes
575
+
576
+ - d9491a2: Fix appeals statistics calculation
577
+
578
+ ## 7.3.10
579
+
580
+ ### Patch Changes
581
+
582
+ - 3508c83: Add missing validations in DREF forms
583
+ - 3508c83: Fix region filter in All Appeals table
584
+ - 073fa1e: Remove personal detail for focal point in local units table
585
+ - b508475: Add June 2024 Catalogue of Surge Services Updates
586
+ - 3508c83: Handle countries with no bounding box
587
+ - d9491a2: Fix appeals based statistics calculation
588
+ - Updated dependencies [073fa1e]
589
+ - @ifrc-go/[email protected]
590
+
591
+ ## 7.3.9
592
+
593
+ ### Patch Changes
594
+
595
+ - 49f5410: - Reorder CoS list
596
+ - Update texts in CoS strategic partnerships resource mobilisation
597
+
598
+ ## 7.3.8
599
+
600
+ ### Patch Changes
601
+
602
+ - 478ab69: Hide contact information from IFRC Presence
603
+ - 3fbe60f: Hide add/edit local units on production environment
604
+ - 90678ed: Show Organization Type properly in Account Details page
605
+
606
+ ## 7.3.7
607
+
608
+ ### Patch Changes
609
+
610
+ - 909a5e2: Fix Appeals table for Africa Region
611
+ - 5a1ae43: Add presentation mode in local units map
612
+ - 96120aa: Fix DREF exports margins and use consistent date format
613
+ - 8a4f26d: Avoid crash on country pages for countries without bbox
614
+
615
+ ## 7.3.6
616
+
617
+ ### Patch Changes
618
+
619
+ - 1b4b6df: Add local unit form
620
+ - 2631a9f: Add office type and location information for IFRC delegation office
621
+ - 2d7a6a5: - Enable ability to start PER in IFRC supported languages
622
+ - Make PER forms `readOnly` in case of language mismatch
623
+ - e4bf098: Fix incorrect statistics for past appeals of a country
624
+ - Updated dependencies [0ab207d]
625
+ - Updated dependencies [66151a7]
626
+ - @ifrc-go/[email protected]
627
+
628
+ ## 7.3.5
629
+
630
+ ### Patch Changes
631
+
632
+ - 894a762: Fix seasonal risk score in regional and global risk watch
633
+
634
+ ## 7.3.4
635
+
636
+ ### Patch Changes
637
+
638
+ - d368ada: Fix GNI per capita in country profile overview
639
+
640
+ ## 7.3.3
641
+
642
+ ### Patch Changes
643
+
644
+ - 73e1966: Update CoS pages as mentioned in #913
645
+ - 179a073: Show all head of delegation under IFRC Presence
646
+ - 98d6b62: Fix region operation map to apply filter for Africa
647
+
648
+ ## 7.3.2
649
+
650
+ ### Patch Changes
651
+
652
+ - f83c12b: Show Local name when available and use English name as fallback for local units data
653
+
654
+ ## 7.3.1
655
+
656
+ ### Patch Changes
657
+
658
+ - 7f0212b: Integrate mapbox street view for local units map
659
+ - Updated dependencies [7f0212b]
660
+ - @ifrc-go/[email protected]
661
+
662
+ ## 7.3.0
663
+
664
+ ### Minor Changes
665
+
666
+ - 0dffd52: Add table view in NS local units
667
+
668
+ ## 7.2.5
669
+
670
+ ### Patch Changes
671
+
672
+ - 556766e: - Refetch token list after new token is created
673
+ - Update link for terms and conditions for Montandon
674
+
675
+ ## 7.2.4
676
+
677
+ ### Patch Changes
678
+
679
+ - 30eac3c: Add option to generate API token for Montandon in the user profile
680
+
681
+ ## 7.2.3
682
+
683
+ ### Patch Changes
684
+
685
+ - Fix crash due to undefined ICRC presence in country page
686
+
687
+ ## 7.2.2
688
+
689
+ ### Patch Changes
690
+
691
+ - - Update country risk page sources
692
+ - Update CoS pages
693
+ - Updated dependencies [a1c0554]
694
+ - Updated dependencies [e9552b4]
695
+ - @ifrc-go/[email protected]
696
+
697
+ ## 7.2.1
698
+
699
+ ### Patch Changes
700
+
701
+ - Remove personal identifiable information for local units
702
+
703
+ ## 7.2.0
704
+
705
+ ### Minor Changes
706
+
707
+ - 9657d4b: Update country pages with appropriate source links
708
+ - 66fa7cf: Show FDRS data retrieval year in NS indicators
709
+ - b69e8e5: Update IFRC legal status link
710
+ - 300250a: Show latest strategic plan of National Society under Strategic Priorities
711
+ - 9657d4b: Add GO Wiki links for country page sections
712
+ - b38d9d9: Improve overall styling of country pages
713
+ - Make loading animation consistent across all pages
714
+ - Make empty message consistent
715
+ - Use ChartContainer and update usage of charting hooks
716
+ - Update BaseMap to extend defaultMapOptions (instead of replacing it)
717
+ - Add an option to provide popupClassName in MapPopup
718
+ - 80be711: Rename `Supporting Partners` to `Partners`.
719
+ - Update IFRC legal status link.
720
+ - Update the name of the strategic priorities link to indicate that they were created by the National Society.
721
+ - 176e01b: Simplify usage of PER question group in PER assessment form
722
+ - Add min widths in account table columns
723
+
724
+ ## 7.1.5
725
+
726
+ ### Patch Changes
727
+
728
+ - Updated dependencies
729
+ - @ifrc-go/[email protected]
go-web-app-develop/app/env.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, Schema } from '@julr/vite-plugin-validate-env';
2
+
3
+ export default defineConfig({
4
+ APP_TITLE: Schema.string(),
5
+ APP_ENVIRONMENT: (key, value) => {
6
+ // NOTE: APP_ENVIRONMENT_PLACEHOLDER is meant to be used with image builds
7
+ // The value will be later replaced with the actual value
8
+ const regex = /^production|staging|testing|alpha-\d+|development|APP_ENVIRONMENT_PLACEHOLDER$/;
9
+ const valid = !!value && (value.match(regex) !== null);
10
+ if (!valid) {
11
+ throw new Error(`Value for environment variable "${key}" must match regex "${regex}", instead received "${value}"`);
12
+ }
13
+ if (value === 'APP_ENVIRONMENT_PLACEHOLDER') {
14
+ console.warn(`Using ${value} for app environment. Make sure to not use this for builds without helm chart`)
15
+ }
16
+ return value as ('production' | 'staging' | 'testing' | `alpha-${number}` | 'development' | 'APP_ENVIRONMENT_PLACEHOLDER');
17
+ },
18
+ APP_API_ENDPOINT: Schema.string({ format: 'url', protocol: true, tld: false }),
19
+ APP_ADMIN_URL: Schema.string.optional({ format: 'url', protocol: true, tld: false }),
20
+ APP_MAPBOX_ACCESS_TOKEN: Schema.string(),
21
+ APP_TINY_API_KEY: Schema.string(),
22
+ APP_RISK_API_ENDPOINT: Schema.string({ format: 'url', protocol: true }),
23
+ APP_SDT_URL: Schema.string.optional({ format: 'url', protocol: true, tld: false }),
24
+ APP_SENTRY_DSN: Schema.string.optional(),
25
+ APP_SENTRY_TRACES_SAMPLE_RATE: Schema.number.optional(),
26
+ APP_SENTRY_REPLAYS_SESSION_SAMPLE_RATE: Schema.number.optional(),
27
+ APP_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE: Schema.number.optional(),
28
+ APP_GOOGLE_ANALYTICS_ID: Schema.string.optional(),
29
+ });
go-web-app-develop/app/eslint.config.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FlatCompat } from '@eslint/eslintrc';
2
+ import js from '@eslint/js';
3
+ import json from "@eslint/json";
4
+ import tseslint from "typescript-eslint";
5
+ import process from 'process';
6
+
7
+ const dirname = process.cwd();
8
+
9
+ const compat = new FlatCompat({
10
+ baseDirectory: dirname,
11
+ resolvePluginsRelativeTo: dirname,
12
+ });
13
+
14
+ const appConfigs = compat.config({
15
+ env: {
16
+ node: true,
17
+ browser: true,
18
+ es2020: true,
19
+ },
20
+ root: true,
21
+ extends: [
22
+ 'airbnb',
23
+ 'airbnb/hooks',
24
+ 'plugin:@typescript-eslint/recommended',
25
+ 'plugin:react-hooks/recommended',
26
+ ],
27
+ parser: '@typescript-eslint/parser',
28
+ parserOptions: {
29
+ ecmaVersion: 'latest',
30
+ sourceType: 'module',
31
+ },
32
+ plugins: [
33
+ '@typescript-eslint',
34
+ 'react-refresh',
35
+ 'simple-import-sort',
36
+ 'import-newlines'
37
+ ],
38
+ settings: {
39
+ 'import/parsers': {
40
+ '@typescript-eslint/parser': ['.ts', '.tsx']
41
+ },
42
+ 'import/resolver': {
43
+ typescript: {
44
+ project: [
45
+ './tsconfig.json',
46
+ ],
47
+ },
48
+ },
49
+ },
50
+ rules: {
51
+ 'react-refresh/only-export-components': 'warn',
52
+
53
+ 'no-unused-vars': 0,
54
+ '@typescript-eslint/no-unused-vars': 1,
55
+
56
+ 'no-use-before-define': 0,
57
+ '@typescript-eslint/no-use-before-define': 1,
58
+
59
+ 'no-shadow': 0,
60
+ '@typescript-eslint/no-shadow': ['error'],
61
+
62
+ '@typescript-eslint/consistent-type-imports': [
63
+ 'warn',
64
+ {
65
+ disallowTypeAnnotations: false,
66
+ fixStyle: 'inline-type-imports',
67
+ prefer: 'type-imports',
68
+ },
69
+ ],
70
+
71
+ 'import/no-extraneous-dependencies': [
72
+ 'error',
73
+ {
74
+ devDependencies: [
75
+ '**/*.test.{ts,tsx}',
76
+ 'eslint.config.js',
77
+ 'postcss.config.cjs',
78
+ 'stylelint.config.cjs',
79
+ 'vite.config.ts',
80
+ ],
81
+ optionalDependencies: false,
82
+ },
83
+ ],
84
+
85
+ indent: ['error', 4, { SwitchCase: 1 }],
86
+
87
+ 'import/no-cycle': ['error', { allowUnsafeDynamicCyclicDependency: true }],
88
+
89
+ 'react/react-in-jsx-scope': 'off',
90
+ 'camelcase': 'off',
91
+
92
+ 'react/jsx-indent': ['error', 4],
93
+ 'react/jsx-indent-props': ['error', 4],
94
+ 'react/jsx-filename-extension': ['error', { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
95
+
96
+ 'import/extensions': ['off', 'never'],
97
+
98
+ 'react-hooks/rules-of-hooks': 'error',
99
+ 'react-hooks/exhaustive-deps': 'warn',
100
+
101
+ 'react/require-default-props': ['warn', { ignoreFunctionalComponents: true }],
102
+ 'simple-import-sort/imports': 'warn',
103
+ 'simple-import-sort/exports': 'warn',
104
+ 'import-newlines/enforce': ['warn', 1]
105
+ },
106
+ overrides: [
107
+ {
108
+ files: ['*.js', '*.jsx', '*.ts', '*.tsx'],
109
+ rules: {
110
+ 'simple-import-sort/imports': [
111
+ 'error',
112
+ {
113
+ 'groups': [
114
+ // side effect imports
115
+ ['^\\u0000'],
116
+ // packages `react` related packages come first
117
+ ['^react', '^@?\\w'],
118
+ // internal packages
119
+ ['^#.+$'],
120
+ // parent imports. Put `..` last
121
+ // other relative imports. Put same-folder imports and `.` last
122
+ ['^\\.\\.(?!/?$)', '^\\.\\./?$', '^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
123
+ // style imports
124
+ ['^.+\\.json$', '^.+\\.module.css$'],
125
+ ]
126
+ }
127
+ ]
128
+ }
129
+ }
130
+ ]
131
+ }).map((conf) => ({
132
+ ...conf,
133
+ files: ['src/**/*.tsx', 'src/**/*.jsx', 'src/**/*.ts', 'src/**/*.js'],
134
+ ignores: [
135
+ "node_modules/",
136
+ "build/",
137
+ "coverage/",
138
+ 'src/generated/types.ts'
139
+ ],
140
+ }));
141
+
142
+ const otherConfig = {
143
+ files: ['*.js', '*.ts', '*.cjs'],
144
+ ...js.configs.recommended,
145
+ ...tseslint.configs.recommended,
146
+ };
147
+
148
+ const jsonConfig = {
149
+ files: ['**/*.json'],
150
+ language: 'json/json',
151
+ rules: {
152
+ 'json/no-duplicate-keys': 'error',
153
+ },
154
+ };
155
+
156
+ export default [
157
+ {
158
+ plugins: {
159
+ json,
160
+ },
161
+ },
162
+ ...appConfigs,
163
+ otherConfig,
164
+ jsonConfig,
165
+ ];
go-web-app-develop/app/index.html ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html
3
+ lang="en"
4
+ translate="no"
5
+ >
6
+ <head>
7
+ <meta charset="UTF-8" />
8
+ <link
9
+ rel="icon"
10
+ type="image/svg+xml"
11
+ href="/go-icon.svg"
12
+ />
13
+ <meta
14
+ name="viewport"
15
+ content="width=device-width, initial-scale=1.0"
16
+ />
17
+ <meta
18
+ name="description"
19
+ content=""
20
+ />
21
+ <title>
22
+ %APP_TITLE%
23
+ </title>
24
+ <link rel="preconnect" href="https://fonts.googleapis.com">
25
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
26
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
27
+ <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap" rel="stylesheet">
28
+ <link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
29
+
30
+ <style>
31
+ html, body {
32
+ margin: 0;
33
+ padding: 0;
34
+ }
35
+
36
+ body {
37
+ font-family: Poppins, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
38
+ }
39
+
40
+ @media screen {
41
+ body {
42
+ background-color: #f7f7f7;
43
+ }
44
+ }
45
+
46
+ #webapp-preload {
47
+ width: 100vw;
48
+ height: 100vh;
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ }
53
+ </style>
54
+ </head>
55
+ <body>
56
+ <noscript>
57
+ %APP_TITLE% needs JS.
58
+ </noscript>
59
+ <div id="webapp-root">
60
+ <div id="webapp-preload">
61
+ %APP_TITLE% loading...
62
+ </div>
63
+ </div>
64
+ <script
65
+ type="module"
66
+ src="/src/index.tsx"
67
+ ></script>
68
+ </body>
69
+ </html>
go-web-app-develop/app/package.json ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "go-web-app",
3
+ "version": "7.21.0-beta.2",
4
+ "type": "module",
5
+ "private": true,
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/IFRCGo/go-web-app.git",
10
+ "directory": "app"
11
+ },
12
+ "scripts": {
13
+ "translatte": "tsx scripts/translatte/main.ts",
14
+ "translatte:generate": "pnpm translatte generate-migration ../translationMigrations ./src/**/i18n.json ../packages/ui/src/**/i18n.json",
15
+ "translatte:lint": "pnpm translatte lint ./src/**/i18n.json ../packages/ui/src/**/i18n.json",
16
+ "initialize:type": "mkdir -p generated/ && pnpm initialize:type:go-api && pnpm initialize:type:risk-api",
17
+ "initialize:type:go-api": "test -f ./generated/types.ts && true || cp types.stub.ts ./generated/types.ts",
18
+ "initialize:type:risk-api": "test -f ./generated/riskTypes.ts && true || cp types.stub.ts ./generated/riskTypes.ts",
19
+ "generate:type": "pnpm generate:type:go-api && pnpm generate:type:risk-api",
20
+ "generate:type:go-api": "dotenv -- cross-var openapi-typescript \"%APP_API_ENDPOINT%api-docs/\" -o ./generated/types.ts --alphabetize",
21
+ "generate:type:risk-api": "dotenv -- cross-var openapi-typescript \"%APP_RISK_API_ENDPOINT%api-docs/\" -o ./generated/riskTypes.ts --alphabetize",
22
+ "prestart": "pnpm initialize:type",
23
+ "start": "pnpm -F @ifrc-go/ui build && vite",
24
+ "prebuild": "pnpm initialize:type",
25
+ "build": "pnpm -F @ifrc-go/ui build && vite build",
26
+ "preview": "vite preview",
27
+ "pretypecheck": "pnpm initialize:type",
28
+ "typecheck": "tsc",
29
+ "prelint:js": "pnpm initialize:type",
30
+ "lint:js": "eslint src",
31
+ "lint:css": "stylelint \"./src/**/*.css\"",
32
+ "lint:translation": "pnpm translatte:lint",
33
+ "lint": "pnpm lint:js && pnpm lint:css && pnpm lint:translation",
34
+ "lint:fix": "pnpm lint:js --fix && pnpm lint:css --fix",
35
+ "test": "vitest",
36
+ "test:coverage": "vitest run --coverage",
37
+ "surge:deploy": "branch=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD); branch=$(echo $branch | tr ./ -); cp ../build/index.html ../build/200.html; surge -p ../build/ -d https://ifrc-go-$branch.surge.sh",
38
+ "surge:teardown": "branch=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD); branch=$(echo $branch | tr ./ -); surge teardown https://ifrc-go-$branch.surge.sh"
39
+ },
40
+ "dependencies": {
41
+ "@ifrc-go/icons": "^2.0.1",
42
+ "@ifrc-go/ui": "workspace:^",
43
+ "@sentry/react": "^7.81.1",
44
+ "@tinymce/tinymce-react": "^5.1.1",
45
+ "@togglecorp/fujs": "^2.1.1",
46
+ "@togglecorp/re-map": "^0.3.0",
47
+ "@togglecorp/toggle-form": "^2.0.4",
48
+ "@togglecorp/toggle-request": "^1.0.0-beta.3",
49
+ "@turf/bbox": "^6.5.0",
50
+ "@turf/buffer": "^6.5.0",
51
+ "exceljs": "^4.3.0",
52
+ "file-saver": "^2.0.5",
53
+ "html-to-image": "^1.11.11",
54
+ "mapbox-gl": "^1.13.0",
55
+ "papaparse": "^5.4.1",
56
+ "react": "^18.2.0",
57
+ "react-dom": "^18.2.0",
58
+ "react-router-dom": "^6.18.0",
59
+ "sanitize-html": "^2.10.0"
60
+ },
61
+ "devDependencies": {
62
+ "@eslint/eslintrc": "^3.1.0",
63
+ "@eslint/js": "^9.20.0",
64
+ "@eslint/json": "^0.5.0",
65
+ "@julr/vite-plugin-validate-env": "^1.0.1",
66
+ "@types/file-saver": "^2.0.5",
67
+ "@types/mapbox-gl": "^1.13.0",
68
+ "@types/node": "^20.11.6",
69
+ "@types/papaparse": "^5.3.8",
70
+ "@types/react": "^18.0.28",
71
+ "@types/react-dom": "^18.0.11",
72
+ "@types/sanitize-html": "^2.9.0",
73
+ "@types/yargs": "^17.0.32",
74
+ "@typescript-eslint/eslint-plugin": "^8.11.0",
75
+ "@typescript-eslint/parser": "^8.11.0",
76
+ "@vitejs/plugin-react-swc": "^3.5.0",
77
+ "@vitest/coverage-v8": "^1.2.2",
78
+ "autoprefixer": "^10.4.14",
79
+ "cross-var": "^1.1.0",
80
+ "dotenv-cli": "^7.4.2",
81
+ "eslint": "^9.20.1",
82
+ "eslint-config-airbnb": "^19.0.4",
83
+ "eslint-import-resolver-typescript": "^3.6.3",
84
+ "eslint-plugin-import": "^2.31.0",
85
+ "eslint-plugin-import-exports-imports-resolver": "^1.0.1",
86
+ "eslint-plugin-import-newlines": "^1.3.4",
87
+ "eslint-plugin-jsx-a11y": "^6.10.1",
88
+ "eslint-plugin-react": "^7.37.4",
89
+ "eslint-plugin-react-hooks": "^5.0.0",
90
+ "eslint-plugin-react-refresh": "^0.4.13",
91
+ "eslint-plugin-simple-import-sort": "^12.1.1",
92
+ "fast-glob": "^3.3.2",
93
+ "happy-dom": "^9.18.3",
94
+ "openapi-typescript": "6.5.5",
95
+ "postcss": "^8.5.3",
96
+ "postcss-nested": "^7.0.2",
97
+ "postcss-normalize": "^13.0.1",
98
+ "postcss-preset-env": "^10.1.5",
99
+ "rollup-plugin-visualizer": "^5.9.0",
100
+ "stylelint": "^16.17.0",
101
+ "stylelint-config-concentric": "^2.0.2",
102
+ "stylelint-config-recommended": "^15.0.0",
103
+ "stylelint-value-no-unknown-custom-properties": "^6.0.1",
104
+ "surge": "^0.23.1",
105
+ "ts-md5": "^1.3.1",
106
+ "tsx": "^4.7.2",
107
+ "typescript": "^5.5.2",
108
+ "typescript-eslint": "^8.26.0",
109
+ "vite": "^5.0.10",
110
+ "vite-plugin-checker": "^0.7.0",
111
+ "vite-plugin-compression2": "^0.11.0",
112
+ "vite-plugin-radar": "^0.9.2",
113
+ "vite-plugin-svgr": "^4.2.0",
114
+ "vite-plugin-webfont-dl": "^3.9.4",
115
+ "vite-tsconfig-paths": "^4.2.2",
116
+ "vitest": "^1.2.2",
117
+ "yargs": "^17.7.2"
118
+ }
119
+ }
go-web-app-develop/app/postcss.config.cjs ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ plugins: [
3
+ require('postcss-preset-env'),
4
+ require('postcss-nested'),
5
+ require('postcss-normalize'),
6
+ require('autoprefixer'),
7
+ ],
8
+ };
go-web-app-develop/app/public/go-icon.svg ADDED
go-web-app-develop/app/scripts/translatte/README.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # translatte
2
+
3
+ A simple script to synchronize translations in source code to translations in
4
+ server
5
+
6
+ ## Usecase
7
+
8
+ ### Generating migrations
9
+
10
+ When adding a new feature or updating existing feature or removing an
11
+ existing feature on the codebase, we may need to update the strings used
12
+ in the application.
13
+
14
+ Developers can change the translations using their preferred choice of editor.
15
+
16
+ Once all of the changes have been made, we can generate a migration file for the translations using:
17
+
18
+ ```bash
19
+ pnpm translatte generate-migration ./src/translationMigrations ./src/**/i18n.json
20
+ ```
21
+
22
+ Once the migration file has been created, the migration file can be committed to the VCS.
23
+
24
+ ### Applying migrations
25
+
26
+ When we are deploying the changes to the server, we will need to update
27
+ the strings in the server.
28
+
29
+ We can generate the new set of strings for the server using:
30
+
31
+ ```bash
32
+ pnpm translatte apply-migrations ./src/translationMigrations --last-migration "name_of_last_migration" --source "strings_json_from_server.json" --destination "new_strings_json_for_server.json"
33
+ ```
34
+
35
+ ### Merge migrations
36
+
37
+ Once the migrations are applied to the strings in the server, we can merge the migrations into a single file.
38
+
39
+ To merge migrations, we can run the following command:
40
+
41
+ ```bash
42
+ pnpm translatte merge-migrations ./src/translationMigrations --from 'initial_migration.json' --to 'final_migration.json'
43
+ ```
44
+
45
+ ### Checking migrations
46
+
47
+ We can use the following command to check for valid migrations:
48
+
49
+ ```bash
50
+ pnpm translatte lint ./src/**/i18n.json
51
+ ```
52
+
53
+ ### Listing migrations
54
+
55
+ We can use the following command to list all migrations:
56
+
57
+ ```bash
58
+ pnpm translatte list-migrations ./src/translationMigrations
59
+ ```
go-web-app-develop/app/scripts/translatte/commands/applyMigrations.test.ts ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect } from 'vitest';
2
+ import { mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ import { testWithTmpDir } from '../testHelpers';
6
+ import {
7
+ writeFilePromisify,
8
+ readJsonFilesContents,
9
+ } from '../utils';
10
+ import {
11
+ migrationContent1,
12
+ migrationContent2,
13
+ migrationContent3,
14
+ migrationContent4,
15
+ migrationContent5,
16
+ migrationContent6,
17
+
18
+ strings1,
19
+ strings2,
20
+ } from '../mockData';
21
+ import applyMigrations from './applyMigrations';
22
+ import { SourceFileContent } from '../types';
23
+
24
+ testWithTmpDir('test applyMigrations with no data in server', async ({ tmpdir }) => {
25
+ mkdirSync(join(tmpdir, 'migrations'));
26
+ const migrations = [
27
+ { name: '000001-1000000000000.json', content: migrationContent1 },
28
+ { name: '000002-1000000000000.json', content: migrationContent2 },
29
+ { name: '000003-1000000000000.json', content: migrationContent3 },
30
+ { name: '000004-1000000000000.json', content: migrationContent4 },
31
+ { name: '000005-1000000000000.json', content: migrationContent5 },
32
+ ].map(({ name, content }) => writeFilePromisify(
33
+ join(tmpdir, 'migrations', name),
34
+ JSON.stringify(content, null, 4),
35
+ 'utf8',
36
+ ));
37
+ await Promise.all(migrations);
38
+
39
+ mkdirSync(join(tmpdir, 'strings'));
40
+
41
+ const emptySourceFile: SourceFileContent = {
42
+ last_migration: undefined,
43
+ strings: [],
44
+ };
45
+ await writeFilePromisify(
46
+ join(tmpdir, 'strings', 'before.json'),
47
+ JSON.stringify(emptySourceFile),
48
+ 'utf8',
49
+ );
50
+
51
+ await applyMigrations(
52
+ tmpdir,
53
+ join(tmpdir, 'strings', 'before.json'),
54
+ join(tmpdir, 'strings', 'after.json'),
55
+ 'migrations',
56
+ ['np'],
57
+ undefined,
58
+ false,
59
+ );
60
+
61
+ const newSourceFiles = await readJsonFilesContents([
62
+ join(tmpdir, 'strings', 'after.json'),
63
+ ]);
64
+ const newSourceFileContent = newSourceFiles[0].content;
65
+
66
+ expect(newSourceFileContent).toEqual(strings1)
67
+ });
68
+
69
+ testWithTmpDir('test applyMigrations with data in server', async ({ tmpdir }) => {
70
+ mkdirSync(join(tmpdir, 'migrations'));
71
+ const migrations = [
72
+ { name: '000006-1000000000000.json', content: migrationContent6 },
73
+ ].map(({ name, content }) => writeFilePromisify(
74
+ join(tmpdir, 'migrations', name),
75
+ JSON.stringify(content, null, 4),
76
+ 'utf8',
77
+ ));
78
+ await Promise.all(migrations);
79
+
80
+ mkdirSync(join(tmpdir, 'strings'));
81
+
82
+ await writeFilePromisify(
83
+ join(tmpdir, 'strings', 'before.json'),
84
+ JSON.stringify(strings1),
85
+ 'utf8',
86
+ );
87
+
88
+ await applyMigrations(
89
+ tmpdir,
90
+ join(tmpdir, 'strings', 'before.json'),
91
+ join(tmpdir, 'strings', 'after.json'),
92
+ 'migrations',
93
+ ['np'],
94
+ undefined,
95
+ false,
96
+ );
97
+
98
+ const newSourceFiles = await readJsonFilesContents([
99
+ join(tmpdir, 'strings', 'after.json'),
100
+ ]);
101
+ const newSourceFileContent = newSourceFiles[0].content;
102
+
103
+ expect(newSourceFileContent).toEqual(strings2)
104
+ });
go-web-app-develop/app/scripts/translatte/commands/applyMigrations.ts ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Md5 } from 'ts-md5';
2
+ import { listToMap, isDefined, unique } from '@togglecorp/fujs';
3
+ import { isAbsolute, join, basename } from 'path';
4
+ import {
5
+ readSource,
6
+ getMigrationFilesAttrs,
7
+ readMigrations,
8
+ writeFilePromisify,
9
+ } from '../utils';
10
+ import { merge } from './mergeMigrations';
11
+ import {
12
+ SourceFileContent,
13
+ MigrationFileContent,
14
+ SourceStringItem,
15
+ } from '../types';
16
+
17
+ function apply(
18
+ strings: SourceStringItem[],
19
+ migrationActions: MigrationFileContent['actions'],
20
+ languages: string[],
21
+ ): SourceStringItem[] {
22
+ const stringsMapping = listToMap(
23
+ strings,
24
+ (item) => `${item.page_name}:${item.key}:${item.language}` as string,
25
+ (item) => item,
26
+ );
27
+
28
+ const newMapping: {
29
+ [key: string]: SourceStringItem | null;
30
+ } = { };
31
+
32
+ unique(['en', ...languages]).forEach((language) => {
33
+ migrationActions.forEach((action) => {
34
+ const isSourceLanguage = language === 'en';
35
+ const key = `${action.namespace}:${action.key}:${language}`;
36
+ if (action.action === 'add') {
37
+ const hash = Md5.hashStr(action.value);
38
+
39
+ const prevValue = stringsMapping[key];
40
+ // NOTE: we are comparing hash instead of value so that this works for source language as well as other languages
41
+ if (prevValue && prevValue.hash !== hash) {
42
+ throw `Add: We already have string with different value for namespace '${action.namespace}' and key '${action.key}'`;
43
+ }
44
+
45
+ if (newMapping[key]) {
46
+ throw `Add: We already have string for namespace '${action.namespace}' and key '${action.key}' in migration`;
47
+ }
48
+
49
+ newMapping[key] = {
50
+ hash,
51
+ key: action.key,
52
+ page_name: action.namespace,
53
+ language,
54
+ value: isSourceLanguage
55
+ ? action.value
56
+ : '',
57
+ };
58
+ } else if (action.action === 'remove') {
59
+ // NOTE: We can add or move string so we might have value in newMapping
60
+ if (!newMapping[key]) {
61
+ newMapping[key] = null;
62
+ }
63
+ } else {
64
+ const prevValue = stringsMapping[key];
65
+ if (!prevValue) {
66
+ throw `Update: We do not have string with namespace '${action.namespace}' and key '${action.key}'`;
67
+ }
68
+
69
+ const newKey = action.newKey ?? prevValue.key;
70
+ const newNamespace = action.newNamespace ?? prevValue.page_name;
71
+ const newValue = isSourceLanguage
72
+ ? action.newValue ?? prevValue.value
73
+ : prevValue.value;
74
+ const newHash = isSourceLanguage
75
+ ? Md5.hashStr(newValue)
76
+ : prevValue.hash;
77
+
78
+ const newCanonicalKey = `${newNamespace}:${newKey}:${language}`;
79
+
80
+
81
+ // NOTE: remove the old key and add new key
82
+ if (!newMapping[key]) {
83
+ newMapping[key] = null;
84
+ }
85
+
86
+ const newItem = {
87
+ hash: newHash,
88
+ key: newKey,
89
+ page_name: newNamespace,
90
+ language,
91
+ value: newValue,
92
+ }
93
+
94
+ if (newMapping[newCanonicalKey]) {
95
+ throw `Update: We already have string for namespace '${action.namespace}' and key '${action.key}' in migration`;
96
+ }
97
+ newMapping[newCanonicalKey] = newItem;
98
+ }
99
+ });
100
+ });
101
+
102
+ const finalMapping: typeof newMapping = {
103
+ ...stringsMapping,
104
+ ...newMapping,
105
+ };
106
+
107
+ return Object.values(finalMapping)
108
+ .filter(isDefined)
109
+ .sort((foo, bar) => (
110
+ foo.page_name.localeCompare(bar.page_name)
111
+ || foo.key.localeCompare(bar.key)
112
+ || foo.language.localeCompare(bar.language)
113
+ ))
114
+ }
115
+
116
+ async function applyMigrations(
117
+ projectPath: string,
118
+ sourceFileName: string,
119
+ destinationFileName: string,
120
+ migrationFilePath: string,
121
+ languages: string[],
122
+ from: string | undefined,
123
+ dryRun: boolean | undefined,
124
+ ) {
125
+ const sourcePath = isAbsolute(sourceFileName)
126
+ ? sourceFileName
127
+ : join(projectPath, sourceFileName)
128
+ const sourceFile = await readSource(sourcePath)
129
+
130
+ const migrationFilesAttrs = await getMigrationFilesAttrs(projectPath, migrationFilePath);
131
+ const selectedMigrationFilesAttrs = from
132
+ ? migrationFilesAttrs.filter((item) => (item.migrationName > from))
133
+ : migrationFilesAttrs;
134
+
135
+ console.info(`Found ${selectedMigrationFilesAttrs.length} migration files`);
136
+
137
+ if (selectedMigrationFilesAttrs.length < 1) {
138
+ throw 'There should be at least 1 migration file';
139
+ }
140
+
141
+ const selectedMigrations = await readMigrations(
142
+ selectedMigrationFilesAttrs.map((migration) => migration.fileName),
143
+ );
144
+
145
+ const lastMigration = selectedMigrations[selectedMigrations.length - 1];
146
+
147
+ const mergedMigrationActions = merge(
148
+ selectedMigrations.map((migration) => migration.content),
149
+ );
150
+
151
+ const outputSourceFileContent: SourceFileContent = {
152
+ ...sourceFile.content,
153
+ last_migration: basename(lastMigration.file),
154
+ strings: apply(
155
+ sourceFile.content.strings,
156
+ mergedMigrationActions,
157
+ languages,
158
+ ),
159
+ };
160
+
161
+ const destinationPath = isAbsolute(destinationFileName)
162
+ ? destinationFileName
163
+ : join(projectPath, destinationFileName)
164
+
165
+ if (dryRun) {
166
+ console.info(`Creating file '${destinationPath}'`);
167
+ console.info(outputSourceFileContent);
168
+ } else {
169
+ await writeFilePromisify(
170
+ destinationPath,
171
+ JSON.stringify(outputSourceFileContent, null, 4),
172
+ 'utf8',
173
+ );
174
+ }
175
+ }
176
+
177
+ export default applyMigrations;
go-web-app-develop/app/scripts/translatte/commands/exportMigration.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import xlsx from 'exceljs';
2
+
3
+ import { readMigrations } from '../utils';
4
+ import { isNotDefined } from '@togglecorp/fujs';
5
+
6
+ async function exportMigration(
7
+ migrationFilePath: string,
8
+ exportFileName: string,
9
+ ) {
10
+ const migrations = await readMigrations(
11
+ [migrationFilePath]
12
+ );
13
+
14
+ const actions = migrations[0].content.actions;
15
+ const workbook = new xlsx.Workbook();
16
+ const now = new Date();
17
+ workbook.created = now;
18
+
19
+ const yyyy = now.getFullYear();
20
+ const mm = (now.getMonth() + 1).toString().padStart(2, '0');
21
+ const dd = now.getDate().toString().padStart(2, '0');
22
+ const worksheet = workbook.addWorksheet(
23
+ `${yyyy}-${mm}-${dd}`
24
+ );
25
+
26
+ worksheet.columns = [
27
+ { header: 'Namespace', key: 'namespace' },
28
+ { header: 'Key', key: 'key' },
29
+ { header: 'EN', key: 'en' },
30
+ { header: 'FR', key: 'fr' },
31
+ { header: 'ES', key: 'es' },
32
+ { header: 'AR', key: 'ar' },
33
+ ]
34
+
35
+ actions.forEach((actionItem) => {
36
+ if (actionItem.action === 'remove') {
37
+ return;
38
+ }
39
+
40
+ if (actionItem.action === 'update' && isNotDefined(actionItem.newValue)) {
41
+ return;
42
+ }
43
+
44
+ const value = actionItem.action === 'update'
45
+ ? actionItem.newValue
46
+ : actionItem.value;
47
+
48
+ worksheet.addRow({
49
+ namespace: actionItem.namespace,
50
+ key: actionItem.key,
51
+ en: value,
52
+ });
53
+ });
54
+
55
+ const fileName = isNotDefined(exportFileName)
56
+ ? `go-strings-${yyyy}-${mm}-${dd}`
57
+ : exportFileName;
58
+
59
+ await workbook.xlsx.writeFile(`${fileName}.xlsx`);
60
+ }
61
+
62
+ export default exportMigration;
go-web-app-develop/app/scripts/translatte/commands/generateMigration.test.ts ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect } from 'vitest';
2
+ import { mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ import generateMigration from './generateMigration';
6
+ import { testWithTmpDir } from '../testHelpers';
7
+ import { writeFilePromisify, readMigrations } from '../utils';
8
+ import {
9
+ migrationContent1,
10
+ migrationContent2,
11
+ migrationContent3,
12
+ migrationContent4,
13
+ migrationContent5,
14
+ loginContent,
15
+ registerContent,
16
+ updatedLoginContent,
17
+ updatedRegisterContent,
18
+ migrationContent6,
19
+ } from '../mockData';
20
+
21
+
22
+ testWithTmpDir('test generateMigration with no change', async ({ tmpdir }) => {
23
+ mkdirSync(join(tmpdir, 'migrations'));
24
+ const migrations = [
25
+ { name: '000001-1000000000000.json', content: migrationContent1 },
26
+ { name: '000002-1000000000000.json', content: migrationContent2 },
27
+ { name: '000003-1000000000000.json', content: migrationContent3 },
28
+ { name: '000004-1000000000000.json', content: migrationContent4 },
29
+ { name: '000005-1000000000000.json', content: migrationContent5 },
30
+ ].map(({ name, content }) => writeFilePromisify(
31
+ join(tmpdir, 'migrations', name),
32
+ JSON.stringify(content, null, 4),
33
+ 'utf8',
34
+ ));
35
+ await Promise.all(migrations);
36
+
37
+ mkdirSync(join(tmpdir, 'src'));
38
+ const translations = [
39
+ { name: 'home.i18n.json', content: loginContent },
40
+ { name: 'register.i18n.json', content: registerContent },
41
+ ].map(({ name, content }) => writeFilePromisify(
42
+ join(tmpdir, 'src', name),
43
+ JSON.stringify(content, null, 4),
44
+ 'utf8',
45
+ ));
46
+ await Promise.all(translations);
47
+
48
+ await expect(
49
+ () => generateMigration(
50
+ tmpdir,
51
+ 'migrations',
52
+ 'src/**/*.i18n.json',
53
+ new Date().getTime(),
54
+ false,
55
+ ),
56
+ ).rejects.toThrow('Nothing to do');
57
+ });
58
+
59
+ testWithTmpDir('test generateMigration with change', async ({ tmpdir }) => {
60
+ mkdirSync(join(tmpdir, 'migrations'));
61
+ const migrations = [
62
+ { name: '000001-1000000000000.json', content: migrationContent1 },
63
+ { name: '000002-1000000000000.json', content: migrationContent2 },
64
+ { name: '000003-1000000000000.json', content: migrationContent3 },
65
+ { name: '000004-1000000000000.json', content: migrationContent4 },
66
+ { name: '000005-1000000000000.json', content: migrationContent5 },
67
+ ].map(({ name, content }) => writeFilePromisify(
68
+ join(tmpdir, 'migrations', name),
69
+ JSON.stringify(content, null, 4),
70
+ 'utf8',
71
+ ));
72
+ await Promise.all(migrations);
73
+
74
+ mkdirSync(join(tmpdir, 'src'));
75
+
76
+ const translations = [
77
+ { name: 'home.i18n.json', content: updatedLoginContent },
78
+ { name: 'register.i18n.json', content: updatedRegisterContent },
79
+ ].map(({ name, content }) => writeFilePromisify(
80
+ join(tmpdir, 'src', name),
81
+ JSON.stringify(content, null, 4),
82
+ 'utf8',
83
+ ));
84
+ await Promise.all(translations);
85
+
86
+ const timestamp = new Date().getTime();
87
+
88
+ await generateMigration(
89
+ tmpdir,
90
+ 'migrations',
91
+ 'src/**/*.i18n.json',
92
+ timestamp,
93
+ false,
94
+ );
95
+
96
+ const generatedMigrations = await readMigrations([
97
+ join(tmpdir, 'migrations', `000006-${timestamp}.json`)
98
+ ]);
99
+ const generatedMigrationContent = generatedMigrations[0].content;
100
+
101
+ expect(generatedMigrationContent).toEqual(migrationContent6)
102
+ });
go-web-app-develop/app/scripts/translatte/commands/generateMigration.ts ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Md5 } from 'ts-md5';
2
+ import { join, isAbsolute } from 'path';
3
+
4
+ import {
5
+ writeFilePromisify,
6
+ oneOneMapping,
7
+ readTranslations,
8
+ getTranslationFileNames,
9
+ getMigrationFilesAttrs,
10
+ readMigrations,
11
+ oneOneMappingNonUnique,
12
+ } from '../utils';
13
+ import { MigrationActionItem, MigrationFileContent } from '../types';
14
+ import { merge } from './mergeMigrations';
15
+
16
+ function getCombinedKey(key: string, namespace: string) {
17
+ return `${namespace}:${key}`;
18
+ }
19
+
20
+ type StateItem = {
21
+ filename?: string;
22
+ namespace: string;
23
+ key: string;
24
+ value: string;
25
+ }
26
+
27
+ // FIXME: The output should be stable
28
+ function generateMigration(
29
+ prevState: StateItem[],
30
+ currentState: StateItem[],
31
+ ): MigrationActionItem[] {
32
+ /*
33
+ console.info('prevState length', prevState.length);
34
+ console.info('currentState length', currentState.length);
35
+ console.info('Total change', Math.abs(prevState.length - currentState.length));
36
+ */
37
+
38
+ const {
39
+ // Same, key, namespace and same value
40
+ validCommonItems: identicalStateItems,
41
+
42
+ // Same, key, namespace but different value
43
+ invalidCommonItems: valueUpdatedStateItems,
44
+
45
+ // items with different key or namespace or both
46
+ prevStateRemainder: potentiallyRemovedStateItems,
47
+
48
+ // items with different key or namespace or both
49
+ currentStateRemainder: potentiallyAddedStateItems,
50
+ } = oneOneMapping(
51
+ prevState,
52
+ currentState,
53
+ ({ key, namespace }) => getCombinedKey(key, namespace),
54
+ (prev, current) => prev.value === current.value,
55
+ );
56
+
57
+ console.info(`Unchanged strings: ${identicalStateItems.length}`)
58
+ console.info(`Value updated strings: ${valueUpdatedStateItems.length}`)
59
+
60
+ console.info(`Potentially removed: ${potentiallyRemovedStateItems.length}`)
61
+ console.info(`Potentially added: ${potentiallyAddedStateItems.length}`)
62
+
63
+ const {
64
+ commonItems: namespaceUpdatedStateItems,
65
+ prevStateRemainder: potentiallyRemovedStateItemsAfterNamespaceChange,
66
+ currentStateRemainder: potentiallyAddedStateItemsAfterNamespaceChange,
67
+ } = oneOneMappingNonUnique(
68
+ potentiallyRemovedStateItems,
69
+ potentiallyAddedStateItems,
70
+ (item) => getCombinedKey(item.key, Md5.hashStr(item.value)),
71
+ );
72
+
73
+ const {
74
+ commonItems: keyUpdatedStateItems,
75
+ prevStateRemainder: removedStateItems,
76
+ currentStateRemainder: addedStateItems,
77
+ } = oneOneMappingNonUnique(
78
+ potentiallyRemovedStateItemsAfterNamespaceChange,
79
+ potentiallyAddedStateItemsAfterNamespaceChange,
80
+ (item) => getCombinedKey(item.namespace, Md5.hashStr(item.value)),
81
+ );
82
+
83
+ console.info(`Namespace updated strings: ${namespaceUpdatedStateItems.length}`)
84
+ console.info(`Added strings: ${addedStateItems.length}`)
85
+ console.info(`Removed strings: ${removedStateItems.length}`)
86
+
87
+ return [
88
+ ...valueUpdatedStateItems.map(({ prevStateItem, currentStateItem }) => ({
89
+ action: 'update' as const,
90
+ key: prevStateItem.key,
91
+ namespace: prevStateItem.namespace,
92
+ newValue: currentStateItem.value,
93
+ })),
94
+ ...namespaceUpdatedStateItems.map(({ prevStateItem, currentStateItem }) => ({
95
+ action: 'update' as const,
96
+ key: prevStateItem.key,
97
+ namespace: prevStateItem.namespace,
98
+ newNamespace: currentStateItem.namespace,
99
+ })),
100
+ ...keyUpdatedStateItems.map(({ prevStateItem, currentStateItem }) => ({
101
+ action: 'update' as const,
102
+ key: prevStateItem.key,
103
+ newKey: currentStateItem.key,
104
+ namespace: prevStateItem.namespace,
105
+ })),
106
+ ...addedStateItems.map((item) => ({
107
+ action: 'add' as const,
108
+ key: item.key,
109
+ namespace: item.namespace,
110
+ value: item.value,
111
+ })),
112
+ ...removedStateItems.map((item) => ({
113
+ action: 'remove' as const,
114
+ key: item.key,
115
+ namespace: item.namespace,
116
+ })),
117
+ ].sort((foo, bar) => (
118
+ foo.namespace.localeCompare(bar.namespace)
119
+ || foo.action.localeCompare(bar.action)
120
+ || foo.key.localeCompare(bar.key)
121
+ ));
122
+ }
123
+
124
+ async function generate(
125
+ projectPath: string,
126
+ migrationFilePath: string,
127
+ translationFileName: string | string[],
128
+ timestamp: number,
129
+ dryRun: boolean | undefined,
130
+ ) {
131
+ const migrationFilesAttrs = await getMigrationFilesAttrs(projectPath, migrationFilePath);
132
+ const selectedMigrationFilesAttrs = migrationFilesAttrs;
133
+ console.info(`Found ${selectedMigrationFilesAttrs.length} migration files`);
134
+ const selectedMigrations = await readMigrations(
135
+ selectedMigrationFilesAttrs.map((migration) => migration.fileName),
136
+ );
137
+ const mergedMigrationActions = merge(
138
+ selectedMigrations.map((migration) => migration.content),
139
+ );
140
+
141
+ const serverState: StateItem[] = mergedMigrationActions.map((item) => {
142
+ if (item.action !== 'add') {
143
+ throw `The action should be "add" but found "${item.action}"`;
144
+ }
145
+ return {
146
+ filename: undefined,
147
+ namespace: item.namespace,
148
+ key: item.key,
149
+ value: item.value,
150
+ }
151
+ });
152
+ const translationFiles = await getTranslationFileNames(
153
+ projectPath,
154
+ Array.isArray(translationFileName) ? translationFileName : [translationFileName],
155
+ );
156
+ const { translations } = await readTranslations(translationFiles);
157
+ const fileState = translations.map((item) => ({
158
+ ...item,
159
+ }));
160
+
161
+ const migrationActionItems = generateMigration(
162
+ serverState,
163
+ fileState,
164
+ );
165
+
166
+ if (migrationActionItems.length <= 0) {
167
+ throw 'Nothing to do';
168
+ }
169
+
170
+ const lastMigration = migrationFilesAttrs[migrationFilesAttrs.length - 1];
171
+
172
+ const migrationContent: MigrationFileContent = {
173
+ parent: lastMigration?.migrationName,
174
+ actions: migrationActionItems,
175
+ }
176
+
177
+ const num = String(Number(lastMigration?.num ?? '000000') + 1).padStart(6, '0');
178
+
179
+ const outputMigrationFile = isAbsolute(migrationFilePath)
180
+ ? join(migrationFilePath, `${num}-${timestamp}.json`)
181
+ : join(projectPath, migrationFilePath, `${num}-${timestamp}.json`)
182
+
183
+ if (dryRun) {
184
+ console.info(`Creating migration file '${outputMigrationFile}'`);
185
+ console.info(migrationContent);
186
+ } else {
187
+ await writeFilePromisify(
188
+ outputMigrationFile,
189
+ JSON.stringify(migrationContent, null, 4),
190
+ 'utf8',
191
+ );
192
+ }
193
+ }
194
+
195
+ export default generate;