SCGR commited on
Commit
1686de5
·
1 Parent(s): d7291ef

UI refine & clean up

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. docker-compose.yml +2 -2
  2. frontend/src/App.css +0 -1
  3. frontend/src/App.tsx +2 -4
  4. frontend/src/components/Card.tsx +0 -14
  5. frontend/src/components/HeaderNav.tsx +50 -32
  6. frontend/src/index.css +0 -1
  7. frontend/src/layouts/RootLayout.tsx +0 -1
  8. frontend/src/pages/AnalyticsPage/AnalyticsPage.module.css +110 -0
  9. frontend/src/pages/{AnalyticsPage.tsx → AnalyticsPage/AnalyticsPage.tsx} +40 -75
  10. frontend/src/pages/AnalyticsPage/index.ts +1 -0
  11. frontend/src/pages/DemoPage.tsx +25 -151
  12. frontend/src/pages/DevPage.tsx +5 -20
  13. frontend/src/pages/ExplorePage.tsx +0 -365
  14. frontend/src/pages/ExplorePage/ExplorePage.module.css +125 -0
  15. frontend/src/pages/ExplorePage/ExplorePage.tsx +357 -0
  16. frontend/src/pages/ExplorePage/index.ts +1 -0
  17. frontend/src/pages/MapDetailsPage/MapDetailPage.module.css +184 -0
  18. frontend/src/pages/{MapDetailPage.tsx → MapDetailsPage/MapDetailPage.tsx} +113 -85
  19. frontend/src/pages/MapDetailsPage/index.ts +1 -0
  20. frontend/src/pages/UploadPage/UploadPage.module.css +495 -0
  21. frontend/src/pages/{UploadPage.tsx → UploadPage/UploadPage.tsx} +401 -237
  22. frontend/src/pages/UploadPage/index.ts +1 -0
  23. frontend/src/types.ts +3 -3
  24. frontend/tailwind.config.js +0 -1
  25. frontend/tsconfig.app.json +0 -4
  26. frontend/tsconfig.node.json +0 -4
  27. frontend/vite.config.ts +0 -3
  28. go-web-app-develop/.changeset/README.md +0 -8
  29. go-web-app-develop/.changeset/config.json +0 -15
  30. go-web-app-develop/.changeset/lovely-kids-boil.md +0 -5
  31. go-web-app-develop/.changeset/pre.json +0 -15
  32. go-web-app-develop/.changeset/solid-clubs-care.md +0 -8
  33. go-web-app-develop/.changeset/sweet-gifts-cheer.md +0 -9
  34. go-web-app-develop/.changeset/whole-lions-guess.md +0 -7
  35. go-web-app-develop/.dockerignore +0 -148
  36. go-web-app-develop/.github/ISSUE_TEMPLATE/01_bug_report.yml +0 -92
  37. go-web-app-develop/.github/ISSUE_TEMPLATE/02_feature_request.yml +0 -39
  38. go-web-app-develop/.github/ISSUE_TEMPLATE/03_epic_request.yml +0 -37
  39. go-web-app-develop/.github/ISSUE_TEMPLATE/config.yml +0 -5
  40. go-web-app-develop/.github/dependabot.yml +0 -27
  41. go-web-app-develop/.github/pull_request_template.md +0 -30
  42. go-web-app-develop/.github/workflows/add-issue-to-backlog.yml +0 -16
  43. go-web-app-develop/.github/workflows/chromatic.yml +0 -127
  44. go-web-app-develop/.github/workflows/ci.yml +0 -304
  45. go-web-app-develop/.github/workflows/publish-nginx-serve.yml +0 -147
  46. go-web-app-develop/.github/workflows/publish-storybook-nginx-serve.yml +0 -127
  47. go-web-app-develop/.gitignore +0 -43
  48. go-web-app-develop/.npmrc +0 -1
  49. go-web-app-develop/COLLABORATING.md +0 -18
  50. go-web-app-develop/CONTRIBUTING.md +0 -81
docker-compose.yml CHANGED
@@ -32,8 +32,8 @@ services:
32
  MINIO_ROOT_USER: promptaid
33
  MINIO_ROOT_PASSWORD: promptaid
34
  ports:
35
- - "9000:9000" # S3 API
36
- - "9001:9001" # web console
37
  volumes:
38
  - minio_data:/data
39
  depends_on:
 
32
  MINIO_ROOT_USER: promptaid
33
  MINIO_ROOT_PASSWORD: promptaid
34
  ports:
35
+ - "9000:9000"
36
+ - "9001:9001"
37
  volumes:
38
  - minio_data:/data
39
  depends_on:
frontend/src/App.css CHANGED
@@ -17,7 +17,6 @@ html, body {
17
  transform-origin: top center;
18
  }
19
 
20
- /* Responsive adjustments for different screen sizes */
21
  @media (min-width: 640px) {
22
  #root {
23
  padding: 1.5rem;
 
17
  transform-origin: top center;
18
  }
19
 
 
20
  @media (min-width: 640px) {
21
  #root {
22
  padding: 1.5rem;
frontend/src/App.tsx CHANGED
@@ -7,13 +7,13 @@ import UploadPage from './pages/UploadPage';
7
  import AnalyticsPage from './pages/AnalyticsPage';
8
  import ExplorePage from './pages/ExplorePage';
9
  import HelpPage from './pages/HelpPage';
10
- import MapDetailPage from './pages/MapDetailPage';
11
  import DemoPage from './pages/DemoPage';
12
  import DevPage from './pages/DevPage';
13
 
14
  const router = createBrowserRouter([
15
  {
16
- element: <RootLayout />, // header sticks here
17
  children: [
18
  { path: '/', element: <UploadPage /> },
19
  { path: '/upload', element: <UploadPage /> },
@@ -28,7 +28,6 @@ const router = createBrowserRouter([
28
  ]);
29
 
30
  function Application() {
31
- // ALERTS
32
  const [alerts, setAlerts] = useState<AlertParams[]>([]);
33
 
34
  const addAlert = useCallback((alert: AlertParams) => {
@@ -79,7 +78,6 @@ function Application() {
79
  [alerts, addAlert, removeAlert, updateAlert],
80
  );
81
 
82
- // LANGUAGE
83
  const languageContextValue = useMemo<LanguageContextProps>(
84
  () => ({
85
  languageNamespaceStatus: {},
 
7
  import AnalyticsPage from './pages/AnalyticsPage';
8
  import ExplorePage from './pages/ExplorePage';
9
  import HelpPage from './pages/HelpPage';
10
+ import MapDetailPage from './pages/MapDetailsPage';
11
  import DemoPage from './pages/DemoPage';
12
  import DevPage from './pages/DevPage';
13
 
14
  const router = createBrowserRouter([
15
  {
16
+ element: <RootLayout />,
17
  children: [
18
  { path: '/', element: <UploadPage /> },
19
  { path: '/upload', element: <UploadPage /> },
 
28
  ]);
29
 
30
  function Application() {
 
31
  const [alerts, setAlerts] = useState<AlertParams[]>([]);
32
 
33
  const addAlert = useCallback((alert: AlertParams) => {
 
78
  [alerts, addAlert, removeAlert, updateAlert],
79
  );
80
 
 
81
  const languageContextValue = useMemo<LanguageContextProps>(
82
  () => ({
83
  languageNamespaceStatus: {},
frontend/src/components/Card.tsx CHANGED
@@ -1,24 +1,10 @@
1
- // src/components/Card.tsx
2
  import React from 'react'
3
 
4
  export interface CardProps {
5
- /** extra Tailwind classes to apply to the wrapper */
6
  className?: string
7
- /** contents of the card */
8
  children: React.ReactNode
9
  }
10
 
11
- /**
12
- * A simple white card with rounded corners, padding and soft shadow.
13
- *
14
- * Usage:
15
- * import Card from '../components/Card'
16
- *
17
- * <Card className="max-w-md mx-auto">
18
- * <h3>Title</h3>
19
- * <p>Body content</p>
20
- * </Card>
21
- */
22
  export default function Card({ children, className = '' }: CardProps) {
23
  return (
24
  <div
 
 
1
  import React from 'react'
2
 
3
  export interface CardProps {
 
4
  className?: string
 
5
  children: React.ReactNode
6
  }
7
 
 
 
 
 
 
 
 
 
 
 
 
8
  export default function Card({ children, className = '' }: CardProps) {
9
  return (
10
  <div
frontend/src/components/HeaderNav.tsx CHANGED
@@ -9,7 +9,6 @@ import {
9
  SettingsIcon,
10
  } from "@ifrc-go/icons";
11
 
12
- /* Put page info in one list so it's easy to extend */
13
  const navItems = [
14
  { to: "/upload", label: "Upload", Icon: UploadCloudLineIcon },
15
  { to: "/explore", label: "Explore", Icon: SearchLineIcon },
@@ -22,51 +21,70 @@ export default function HeaderNav() {
22
  const navigate = useNavigate();
23
 
24
  return (
25
- <nav className="border-b border-gray-200 bg-white">
26
  <PageContainer
27
- className="border-b border-ifrcRed"
28
- contentClassName="flex items-center justify-between py-4"
29
  >
30
- {/* ── Logo + title ─────────────────────────── */}
31
- <div className="flex items-center gap-3 min-w-0 cursor-pointer" onClick={() => navigate('/')}>
32
- <GoMainIcon className="h-8 w-8 flex-shrink-0 text-ifrcRed" />
33
- <span className="font-semibold text-lg truncate text-gray-900">PromptAid Vision</span>
 
 
 
 
 
 
 
34
  </div>
35
 
36
- {/* ── Centre nav links ─────────────────────── */}
37
- <nav className="flex items-center">
38
- {navItems.map(({ to, label, Icon }, index) => (
39
- <div key={to} className={index < navItems.length - 1 ? "mr-8" : ""}>
40
- <Button
41
- name={label.toLowerCase()}
42
- variant={location.pathname === to ? "primary" : "tertiary"}
43
- size={1}
44
- onClick={() => {
45
- if (location.pathname === "/upload") {
46
- const uploadPage = document.querySelector('[data-step="2"]');
47
- if (uploadPage && !confirm("Changes will not be saved")) {
48
- return;
 
 
 
 
 
 
 
49
  }
50
- }
51
- navigate(to);
52
- }}
53
- >
54
- <Icon className="w-4 h-4" />
55
- <span className="inline ml-2 font-medium">{label}</span>
56
- </Button>
57
- </div>
58
- ))}
 
 
 
 
 
59
  </nav>
60
 
61
- {/* ── Right-side utility buttons ───────────── */}
62
  <Button
63
  name="help"
64
  variant="tertiary"
65
  size={1}
 
66
  onClick={() => navigate('/help')}
67
  >
68
  <QuestionLineIcon className="w-4 h-4" />
69
- <span className="inline ml-2 font-medium">Help & Support</span>
70
  </Button>
71
  </PageContainer>
72
  </nav>
 
9
  SettingsIcon,
10
  } from "@ifrc-go/icons";
11
 
 
12
  const navItems = [
13
  { to: "/upload", label: "Upload", Icon: UploadCloudLineIcon },
14
  { to: "/explore", label: "Explore", Icon: SearchLineIcon },
 
21
  const navigate = useNavigate();
22
 
23
  return (
24
+ <nav className="border-b border-gray-200 bg-white shadow-sm sticky top-0 z-50 backdrop-blur-sm bg-white/95">
25
  <PageContainer
26
+ className="border-b-2 border-ifrcRed"
27
+ contentClassName="flex items-center justify-between py-6"
28
  >
29
+ <div
30
+ className="flex items-center gap-4 min-w-0 cursor-pointer group transition-all duration-200 hover:scale-105"
31
+ onClick={() => navigate('/')}
32
+ >
33
+ <div className="p-2 rounded-lg bg-gradient-to-br from-ifrcRed/10 to-ifrcRed/20 group-hover:from-ifrcRed/20 group-hover:to-ifrcRed/30 transition-all duration-200">
34
+ <GoMainIcon className="h-8 w-8 flex-shrink-0 text-ifrcRed" />
35
+ </div>
36
+ <div className="flex flex-col">
37
+ <span className="font-bold text-xl text-gray-900 leading-tight">PromptAid Vision</span>
38
+ <span className="text-sm text-gray-500 font-medium">AI-Powered Image Analysis</span>
39
+ </div>
40
  </div>
41
 
42
+ <nav className="flex items-center space-x-2 bg-gray-50/80 rounded-xl p-2 backdrop-blur-sm">
43
+ {navItems.map(({ to, label, Icon }) => {
44
+ const isActive = location.pathname === to;
45
+ return (
46
+ <div key={to} className="relative">
47
+ <Button
48
+ name={label.toLowerCase()}
49
+ variant={isActive ? "primary" : "tertiary"}
50
+ size={1}
51
+ className={`transition-all duration-200 ${
52
+ isActive
53
+ ? 'shadow-lg shadow-ifrcRed/20 transform scale-105'
54
+ : 'hover:bg-white hover:shadow-md hover:scale-105'
55
+ }`}
56
+ onClick={() => {
57
+ if (location.pathname === "/upload") {
58
+ const uploadPage = document.querySelector('[data-step="2"]');
59
+ if (uploadPage && !confirm("Changes will not be saved")) {
60
+ return;
61
+ }
62
  }
63
+ navigate(to);
64
+ }}
65
+ >
66
+ <Icon className={`w-4 h-4 transition-transform duration-200 ${
67
+ isActive ? 'scale-110' : 'group-hover:scale-110'
68
+ }`} />
69
+ <span className="inline ml-2 font-semibold">{label}</span>
70
+ </Button>
71
+ {isActive && (
72
+ <div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-8 h-1 bg-ifrcRed rounded-full animate-pulse"></div>
73
+ )}
74
+ </div>
75
+ );
76
+ })}
77
  </nav>
78
 
 
79
  <Button
80
  name="help"
81
  variant="tertiary"
82
  size={1}
83
+ className="transition-all duration-200 hover:bg-blue-50 hover:text-blue-600 hover:shadow-md hover:scale-105"
84
  onClick={() => navigate('/help')}
85
  >
86
  <QuestionLineIcon className="w-4 h-4" />
87
+ <span className="inline ml-2 font-semibold">Help & Support</span>
88
  </Button>
89
  </PageContainer>
90
  </nav>
frontend/src/index.css CHANGED
@@ -1,4 +1,3 @@
1
- /* src/index.css */
2
  @tailwind base;
3
  @tailwind components;
4
  @tailwind utilities;
 
 
1
  @tailwind base;
2
  @tailwind components;
3
  @tailwind utilities;
frontend/src/layouts/RootLayout.tsx CHANGED
@@ -5,7 +5,6 @@ export default function RootLayout() {
5
  return (
6
  <>
7
  <HeaderNav />
8
- {/* All routed pages render here */}
9
  <Outlet />
10
  </>
11
  );
 
5
  return (
6
  <>
7
  <HeaderNav />
 
8
  <Outlet />
9
  </>
10
  );
frontend/src/pages/AnalyticsPage/AnalyticsPage.module.css ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .tabSelector {
2
+ display: flex;
3
+ justify-content: center;
4
+ margin: var(--go-ui-spacing-xl) 0;
5
+ }
6
+
7
+ .summaryStats {
8
+ display: grid;
9
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
10
+ gap: var(--go-ui-spacing-lg);
11
+ margin-bottom: var(--go-ui-spacing-lg);
12
+ }
13
+
14
+ .progressSection {
15
+ margin-top: var(--go-ui-spacing-lg);
16
+ padding-top: var(--go-ui-spacing-lg);
17
+ border-top: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
18
+ }
19
+
20
+ .progressLabel {
21
+ display: flex;
22
+ justify-content: space-between;
23
+ margin-bottom: var(--go-ui-spacing-sm);
24
+ color: var(--go-ui-color-text);
25
+ font-weight: var(--go-ui-font-weight-medium);
26
+ }
27
+
28
+ .chartGrid {
29
+ display: grid;
30
+ grid-template-columns: 1fr;
31
+ gap: var(--go-ui-spacing-xl);
32
+ }
33
+
34
+ .chartSection {
35
+ display: grid;
36
+ grid-template-columns: 1fr;
37
+ gap: var(--go-ui-spacing-lg);
38
+ }
39
+
40
+ .chartContainer {
41
+ display: flex;
42
+ justify-content: center;
43
+ align-items: center;
44
+ min-height: 300px;
45
+ background-color: var(--go-ui-color-gray-10);
46
+ border-radius: var(--go-ui-border-radius-lg);
47
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
48
+ padding: var(--go-ui-spacing-lg);
49
+ }
50
+
51
+ .tableContainer {
52
+ background-color: var(--go-ui-color-white);
53
+ border-radius: var(--go-ui-border-radius-lg);
54
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
55
+ overflow: hidden;
56
+ box-shadow: var(--go-ui-box-shadow-sm);
57
+ }
58
+
59
+ .modelPerformance {
60
+ background-color: var(--go-ui-color-white);
61
+ border-radius: var(--go-ui-border-radius-lg);
62
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
63
+ overflow: hidden;
64
+ box-shadow: var(--go-ui-box-shadow-sm);
65
+ }
66
+
67
+ .loadingContainer {
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ min-height: 400px;
72
+ color: var(--go-ui-color-gray-60);
73
+ font-size: var(--go-ui-font-size-lg);
74
+ font-weight: var(--go-ui-font-weight-medium);
75
+ }
76
+
77
+ .errorContainer {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ min-height: 400px;
82
+ color: var(--go-ui-color-negative);
83
+ font-size: var(--go-ui-font-size-lg);
84
+ font-weight: var(--go-ui-font-weight-medium);
85
+ }
86
+
87
+
88
+
89
+ /* Responsive adjustments */
90
+ @media (min-width: 1024px) {
91
+ .chartSection {
92
+ grid-template-columns: 1fr 1fr;
93
+ }
94
+ }
95
+
96
+ @media (max-width: 768px) {
97
+ .summaryStats {
98
+ grid-template-columns: 1fr;
99
+ gap: var(--go-ui-spacing-md);
100
+ }
101
+
102
+ .chartContainer {
103
+ min-height: 250px;
104
+ padding: var(--go-ui-spacing-md);
105
+ }
106
+
107
+ .tabSelector {
108
+ margin: var(--go-ui-spacing-lg) 0;
109
+ }
110
+ }
frontend/src/pages/{AnalyticsPage.tsx → AnalyticsPage/AnalyticsPage.tsx} RENAMED
@@ -1,5 +1,3 @@
1
- // src/pages/AnalyticsPage.tsx
2
-
3
  import {
4
  PageContainer,
5
  PieChart,
@@ -16,7 +14,7 @@ import {
16
  numericIdSelector
17
  } from '@ifrc-go/ui/utils';
18
  import { useState, useEffect, useMemo } from 'react';
19
- // icons not used on this page
20
 
21
  interface AnalyticsData {
22
  totalCaptions: number;
@@ -80,7 +78,6 @@ export default function AnalyticsPage() {
80
  const [typesLookup, setTypesLookup] = useState<LookupData[]>([]);
81
  const [regionsLookup, setRegionsLookup] = useState<LookupData[]>([]);
82
 
83
- // SegmentInput options for analytics view
84
  const viewOptions = [
85
  { key: 'general' as const, label: 'General Analytics' },
86
  { key: 'vlm' as const, label: 'VLM Analytics' }
@@ -107,23 +104,22 @@ export default function AnalyticsPage() {
107
 
108
  maps.forEach((map: any) => {
109
  if (map.source) analytics.sources[map.source] = (analytics.sources[map.source] || 0) + 1;
110
- if (map.type) analytics.types[map.type] = (analytics.types[map.type] || 0) + 1;
111
  if (map.countries) {
112
  map.countries.forEach((c: any) => {
113
  if (c.r_code) analytics.regions[c.r_code] = (analytics.regions[c.r_code] || 0) + 1;
114
  });
115
  }
116
- if (map.caption?.model) {
117
- const m = map.caption.model;
118
  const ctr = analytics.models[m] ||= { count: 0, avgAccuracy: 0, avgContext: 0, avgUsability: 0, totalScore: 0 };
119
  ctr.count++;
120
- if (map.caption.accuracy != null) ctr.avgAccuracy += map.caption.accuracy;
121
- if (map.caption.context != null) ctr.avgContext += map.caption.context;
122
- if (map.caption.usability != null) ctr.avgUsability += map.caption.usability;
123
  }
124
  });
125
 
126
- // Add all sources and types with 0 values for missing data
127
  sourcesLookup.forEach(source => {
128
  if (source.s_code && !analytics.sources[source.s_code]) {
129
  analytics.sources[source.s_code] = 0;
@@ -136,14 +132,12 @@ export default function AnalyticsPage() {
136
  }
137
  });
138
 
139
- // Add all regions with 0 values for missing data
140
  regionsLookup.forEach(region => {
141
  if (region.r_code && !analytics.regions[region.r_code]) {
142
  analytics.regions[region.r_code] = 0;
143
  }
144
  });
145
 
146
- // Add all models with 0 values for missing data
147
  const allModels = ['GPT-4', 'Claude', 'Gemini', 'Llama', 'Other'];
148
  allModels.forEach(model => {
149
  if (!analytics.models[model]) {
@@ -153,16 +147,16 @@ export default function AnalyticsPage() {
153
 
154
  Object.values(analytics.models).forEach(m => {
155
  if (m.count > 0) {
156
- m.avgAccuracy = Math.round(m.avgAccuracy / m.count);
157
- m.avgContext = Math.round(m.avgContext / m.count);
158
  m.avgUsability = Math.round(m.avgUsability / m.count);
159
- m.totalScore = Math.round((m.avgAccuracy + m.avgContext + m.avgUsability) / 3);
160
  }
161
  });
162
 
163
  setData(analytics);
164
  } catch (e) {
165
- console.error(e);
166
  setData(null);
167
  } finally {
168
  setLoading(false);
@@ -183,7 +177,7 @@ export default function AnalyticsPage() {
183
  setTypesLookup(types);
184
  setRegionsLookup(regions);
185
  } catch (e) {
186
- console.error('Failed to fetch lookup data:', e);
187
  }
188
  }
189
 
@@ -197,16 +191,9 @@ export default function AnalyticsPage() {
197
  return type ? type.label : code;
198
  };
199
 
200
- // const getRegionLabel = (code: string) => {
201
- // const region = regionsLookup.find(r => r.r_code === code);
202
- // return region ? region.label : code;
203
- // };
204
-
205
- // Transform regions data for IFRC Table - show all regions including 0 data
206
  const regionsTableData = useMemo(() => {
207
  if (!data || !regionsLookup.length) return [];
208
 
209
- // Create a map of all regions with their counts (0 if no data)
210
  const allRegions = regionsLookup.reduce((acc, region) => {
211
  if (region.r_code) {
212
  acc[region.r_code] = {
@@ -217,7 +204,6 @@ export default function AnalyticsPage() {
217
  return acc;
218
  }, {} as Record<string, { name: string; count: number }>);
219
 
220
- // Convert to array and sort by count descending
221
  return Object.entries(allRegions)
222
  .sort(([,a], [,b]) => b.count - a.count)
223
  .map(([_, { name, count }], index) => ({
@@ -228,7 +214,6 @@ export default function AnalyticsPage() {
228
  }));
229
  }, [data, regionsLookup]);
230
 
231
- // Transform types data for IFRC Table
232
  const typesTableData = useMemo(() => {
233
  if (!data) return [];
234
 
@@ -242,7 +227,6 @@ export default function AnalyticsPage() {
242
  }));
243
  }, [data, typesLookup]);
244
 
245
- // Transform sources data for IFRC Table
246
  const sourcesTableData = useMemo(() => {
247
  if (!data) return [];
248
 
@@ -256,7 +240,6 @@ export default function AnalyticsPage() {
256
  }));
257
  }, [data, sourcesLookup]);
258
 
259
- // Transform models data for IFRC Table
260
  const modelsTableData = useMemo(() => {
261
  if (!data) return [];
262
 
@@ -273,7 +256,6 @@ export default function AnalyticsPage() {
273
  }));
274
  }, [data]);
275
 
276
- // Create columns for regions table
277
  const regionsColumns = useMemo(() => [
278
  createStringColumn<RegionData, number>(
279
  'name',
@@ -296,7 +278,6 @@ export default function AnalyticsPage() {
296
  ),
297
  ], []);
298
 
299
- // Create columns for types table
300
  const typesColumns = useMemo(() => [
301
  createStringColumn<TypeData, number>(
302
  'name',
@@ -319,7 +300,6 @@ export default function AnalyticsPage() {
319
  ),
320
  ], []);
321
 
322
- // Create columns for sources table
323
  const sourcesColumns = useMemo(() => [
324
  createStringColumn<SourceData, number>(
325
  'name',
@@ -342,7 +322,6 @@ export default function AnalyticsPage() {
342
  ),
343
  ], []);
344
 
345
- // Create columns for models table
346
  const modelsColumns = useMemo(() => [
347
  createStringColumn<ModelData, number>(
348
  'name',
@@ -395,7 +374,7 @@ export default function AnalyticsPage() {
395
  if (loading) {
396
  return (
397
  <PageContainer>
398
- <div className="flex items-center justify-center min-h-[400px]">
399
  <Spinner />
400
  </div>
401
  </PageContainer>
@@ -405,7 +384,7 @@ export default function AnalyticsPage() {
405
  if (!data) {
406
  return (
407
  <PageContainer>
408
- <div className="flex items-center justify-center min-h-[400px]">
409
  <div className="text-red-500">Failed to load analytics data. Please try again.</div>
410
  </div>
411
  </PageContainer>
@@ -413,22 +392,13 @@ export default function AnalyticsPage() {
413
  }
414
 
415
  const sourcesChartData = Object.entries(data.sources).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value }));
416
- const typesChartData = Object.entries(data.types).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value }));
417
  const regionsChartData = Object.entries(data.regions).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value }));
418
 
419
- // Official IFRC color palette for all pie charts - same order for all charts
420
  const ifrcColors = [
421
- '#F5333F', // IFRC Primary Red (--go-ui-color-red-90)
422
- '#F64752', // IFRC Red 80
423
- '#F75C65', // IFRC Red 70
424
- '#F87079', // IFRC Red 60
425
- '#F9858C', // IFRC Red 50
426
- '#FA999F', // IFRC Red 40
427
- '#FBADB2', // IFRC Red 30
428
- '#FCC2C5' // IFRC Red 20
429
  ];
430
 
431
-
432
  return (
433
  <PageContainer>
434
  <Container
@@ -438,8 +408,7 @@ export default function AnalyticsPage() {
438
  withInternalPadding
439
  className="max-w-7xl mx-auto"
440
  >
441
- {/* Tab selector */}
442
- <div className="flex justify-center my-6">
443
  <SegmentInput
444
  name="analytics-view"
445
  value={view}
@@ -455,10 +424,9 @@ export default function AnalyticsPage() {
455
  </div>
456
 
457
  {view === 'general' ? (
458
- <div className="space-y-8">
459
- {/* Summary Statistics */}
460
  <Container heading="Summary Statistics" headingLevel={3} withHeaderBorder withInternalPadding>
461
- <div className="grid grid-cols-2 gap-4">
462
  <KeyFigure
463
  value={data.totalCaptions}
464
  label="Total Captions"
@@ -470,8 +438,8 @@ export default function AnalyticsPage() {
470
  compactValue
471
  />
472
  </div>
473
- <div className="mt-6">
474
- <div className="flex justify-between mb-2">
475
  <span>Progress towards target</span>
476
  <span>{Math.round((data.totalCaptions / 2000) * 100)}%</span>
477
  </div>
@@ -479,11 +447,9 @@ export default function AnalyticsPage() {
479
  </div>
480
  </Container>
481
 
482
-
483
- {/* Regions Chart & Data */}
484
  <Container heading="Regions Distribution" headingLevel={3} withHeaderBorder withInternalPadding>
485
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
486
- <div className="flex justify-center items-center min-h-[300px]">
487
  <PieChart
488
  data={regionsChartData}
489
  valueSelector={d => d.value}
@@ -493,7 +459,7 @@ export default function AnalyticsPage() {
493
  showPercentageInLegend
494
  />
495
  </div>
496
- <div className="w-full">
497
  <Table
498
  data={regionsTableData}
499
  columns={regionsColumns}
@@ -505,10 +471,9 @@ export default function AnalyticsPage() {
505
  </div>
506
  </Container>
507
 
508
- {/* Sources Chart & Data */}
509
  <Container heading="Sources Distribution" headingLevel={3} withHeaderBorder withInternalPadding>
510
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
511
- <div className="flex justify-center items-center min-h-[300px]">
512
  <PieChart
513
  data={sourcesChartData}
514
  valueSelector={d => d.value}
@@ -518,7 +483,7 @@ export default function AnalyticsPage() {
518
  showPercentageInLegend
519
  />
520
  </div>
521
- <div className="w-full">
522
  <Table
523
  data={sourcesTableData}
524
  columns={sourcesColumns}
@@ -530,10 +495,9 @@ export default function AnalyticsPage() {
530
  </div>
531
  </Container>
532
 
533
- {/* Types Chart & Data */}
534
  <Container heading="Types Distribution" headingLevel={3} withHeaderBorder withInternalPadding>
535
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
536
- <div className="flex justify-center items-center min-h-[300px]">
537
  <PieChart
538
  data={typesChartData}
539
  valueSelector={d => d.value}
@@ -543,7 +507,7 @@ export default function AnalyticsPage() {
543
  showPercentageInLegend
544
  />
545
  </div>
546
- <div className="w-full">
547
  <Table
548
  data={typesTableData}
549
  columns={typesColumns}
@@ -556,16 +520,17 @@ export default function AnalyticsPage() {
556
  </Container>
557
  </div>
558
  ) : (
559
- <div className="space-y-8">
560
- {/* Model Performance */}
561
  <Container heading="Model Performance" headingLevel={3} withHeaderBorder withInternalPadding>
562
- <Table
563
- data={modelsTableData}
564
- columns={modelsColumns}
565
- keySelector={numericIdSelector}
566
- filtered={false}
567
- pending={false}
568
- />
 
 
569
  </Container>
570
  </div>
571
  )}
 
 
 
1
  import {
2
  PageContainer,
3
  PieChart,
 
14
  numericIdSelector
15
  } from '@ifrc-go/ui/utils';
16
  import { useState, useEffect, useMemo } from 'react';
17
+ import styles from './AnalyticsPage.module.css';
18
 
19
  interface AnalyticsData {
20
  totalCaptions: number;
 
78
  const [typesLookup, setTypesLookup] = useState<LookupData[]>([]);
79
  const [regionsLookup, setRegionsLookup] = useState<LookupData[]>([]);
80
 
 
81
  const viewOptions = [
82
  { key: 'general' as const, label: 'General Analytics' },
83
  { key: 'vlm' as const, label: 'VLM Analytics' }
 
104
 
105
  maps.forEach((map: any) => {
106
  if (map.source) analytics.sources[map.source] = (analytics.sources[map.source] || 0) + 1;
107
+ if (map.event_type) analytics.types[map.event_type] = (analytics.types[map.event_type] || 0) + 1;
108
  if (map.countries) {
109
  map.countries.forEach((c: any) => {
110
  if (c.r_code) analytics.regions[c.r_code] = (analytics.regions[c.r_code] || 0) + 1;
111
  });
112
  }
113
+ if (map.captions && map.captions.length > 0 && map.captions[0].model) {
114
+ const m = map.captions[0].model;
115
  const ctr = analytics.models[m] ||= { count: 0, avgAccuracy: 0, avgContext: 0, avgUsability: 0, totalScore: 0 };
116
  ctr.count++;
117
+ if (map.captions[0].accuracy != null) ctr.avgAccuracy += map.captions[0].accuracy;
118
+ if (map.captions[0].context != null) ctr.avgContext += map.captions[0].context;
119
+ if (map.captions[0].usability != null) ctr.avgUsability += map.captions[0].usability;
120
  }
121
  });
122
 
 
123
  sourcesLookup.forEach(source => {
124
  if (source.s_code && !analytics.sources[source.s_code]) {
125
  analytics.sources[source.s_code] = 0;
 
132
  }
133
  });
134
 
 
135
  regionsLookup.forEach(region => {
136
  if (region.r_code && !analytics.regions[region.r_code]) {
137
  analytics.regions[region.r_code] = 0;
138
  }
139
  });
140
 
 
141
  const allModels = ['GPT-4', 'Claude', 'Gemini', 'Llama', 'Other'];
142
  allModels.forEach(model => {
143
  if (!analytics.models[model]) {
 
147
 
148
  Object.values(analytics.models).forEach(m => {
149
  if (m.count > 0) {
150
+ m.avgAccuracy = Math.round(m.avgAccuracy / m.count);
151
+ m.avgContext = Math.round(m.avgContext / m.count);
152
  m.avgUsability = Math.round(m.avgUsability / m.count);
153
+ m.totalScore = Math.round((m.avgAccuracy + m.avgContext + m.avgUsability) / 3);
154
  }
155
  });
156
 
157
  setData(analytics);
158
  } catch (e) {
159
+
160
  setData(null);
161
  } finally {
162
  setLoading(false);
 
177
  setTypesLookup(types);
178
  setRegionsLookup(regions);
179
  } catch (e) {
180
+
181
  }
182
  }
183
 
 
191
  return type ? type.label : code;
192
  };
193
 
 
 
 
 
 
 
194
  const regionsTableData = useMemo(() => {
195
  if (!data || !regionsLookup.length) return [];
196
 
 
197
  const allRegions = regionsLookup.reduce((acc, region) => {
198
  if (region.r_code) {
199
  acc[region.r_code] = {
 
204
  return acc;
205
  }, {} as Record<string, { name: string; count: number }>);
206
 
 
207
  return Object.entries(allRegions)
208
  .sort(([,a], [,b]) => b.count - a.count)
209
  .map(([_, { name, count }], index) => ({
 
214
  }));
215
  }, [data, regionsLookup]);
216
 
 
217
  const typesTableData = useMemo(() => {
218
  if (!data) return [];
219
 
 
227
  }));
228
  }, [data, typesLookup]);
229
 
 
230
  const sourcesTableData = useMemo(() => {
231
  if (!data) return [];
232
 
 
240
  }));
241
  }, [data, sourcesLookup]);
242
 
 
243
  const modelsTableData = useMemo(() => {
244
  if (!data) return [];
245
 
 
256
  }));
257
  }, [data]);
258
 
 
259
  const regionsColumns = useMemo(() => [
260
  createStringColumn<RegionData, number>(
261
  'name',
 
278
  ),
279
  ], []);
280
 
 
281
  const typesColumns = useMemo(() => [
282
  createStringColumn<TypeData, number>(
283
  'name',
 
300
  ),
301
  ], []);
302
 
 
303
  const sourcesColumns = useMemo(() => [
304
  createStringColumn<SourceData, number>(
305
  'name',
 
322
  ),
323
  ], []);
324
 
 
325
  const modelsColumns = useMemo(() => [
326
  createStringColumn<ModelData, number>(
327
  'name',
 
374
  if (loading) {
375
  return (
376
  <PageContainer>
377
+ <div className={styles.loadingContainer}>
378
  <Spinner />
379
  </div>
380
  </PageContainer>
 
384
  if (!data) {
385
  return (
386
  <PageContainer>
387
+ <div className={styles.errorContainer}>
388
  <div className="text-red-500">Failed to load analytics data. Please try again.</div>
389
  </div>
390
  </PageContainer>
 
392
  }
393
 
394
  const sourcesChartData = Object.entries(data.sources).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value }));
395
+ const typesChartData = Object.entries(data.types).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value }));
396
  const regionsChartData = Object.entries(data.regions).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value }));
397
 
 
398
  const ifrcColors = [
399
+ '#F5333F', '#F64752', '#F75C65', '#F87079', '#F9858C', '#FA999F', '#FBADB2', '#FCC2C5'
 
 
 
 
 
 
 
400
  ];
401
 
 
402
  return (
403
  <PageContainer>
404
  <Container
 
408
  withInternalPadding
409
  className="max-w-7xl mx-auto"
410
  >
411
+ <div className={styles.tabSelector}>
 
412
  <SegmentInput
413
  name="analytics-view"
414
  value={view}
 
424
  </div>
425
 
426
  {view === 'general' ? (
427
+ <div className={styles.chartGrid}>
 
428
  <Container heading="Summary Statistics" headingLevel={3} withHeaderBorder withInternalPadding>
429
+ <div className={styles.summaryStats}>
430
  <KeyFigure
431
  value={data.totalCaptions}
432
  label="Total Captions"
 
438
  compactValue
439
  />
440
  </div>
441
+ <div className={styles.progressSection}>
442
+ <div className={styles.progressLabel}>
443
  <span>Progress towards target</span>
444
  <span>{Math.round((data.totalCaptions / 2000) * 100)}%</span>
445
  </div>
 
447
  </div>
448
  </Container>
449
 
 
 
450
  <Container heading="Regions Distribution" headingLevel={3} withHeaderBorder withInternalPadding>
451
+ <div className={styles.chartSection}>
452
+ <div className={styles.chartContainer}>
453
  <PieChart
454
  data={regionsChartData}
455
  valueSelector={d => d.value}
 
459
  showPercentageInLegend
460
  />
461
  </div>
462
+ <div className={styles.tableContainer}>
463
  <Table
464
  data={regionsTableData}
465
  columns={regionsColumns}
 
471
  </div>
472
  </Container>
473
 
 
474
  <Container heading="Sources Distribution" headingLevel={3} withHeaderBorder withInternalPadding>
475
+ <div className={styles.chartSection}>
476
+ <div className={styles.chartContainer}>
477
  <PieChart
478
  data={sourcesChartData}
479
  valueSelector={d => d.value}
 
483
  showPercentageInLegend
484
  />
485
  </div>
486
+ <div className={styles.tableContainer}>
487
  <Table
488
  data={sourcesTableData}
489
  columns={sourcesColumns}
 
495
  </div>
496
  </Container>
497
 
 
498
  <Container heading="Types Distribution" headingLevel={3} withHeaderBorder withInternalPadding>
499
+ <div className={styles.chartSection}>
500
+ <div className={styles.chartContainer}>
501
  <PieChart
502
  data={typesChartData}
503
  valueSelector={d => d.value}
 
507
  showPercentageInLegend
508
  />
509
  </div>
510
+ <div className={styles.tableContainer}>
511
  <Table
512
  data={typesTableData}
513
  columns={typesColumns}
 
520
  </Container>
521
  </div>
522
  ) : (
523
+ <div className={styles.chartGrid}>
 
524
  <Container heading="Model Performance" headingLevel={3} withHeaderBorder withInternalPadding>
525
+ <div className={styles.modelPerformance}>
526
+ <Table
527
+ data={modelsTableData}
528
+ columns={modelsColumns}
529
+ keySelector={numericIdSelector}
530
+ filtered={false}
531
+ pending={false}
532
+ />
533
+ </div>
534
  </Container>
535
  </div>
536
  )}
frontend/src/pages/AnalyticsPage/index.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export { default } from './AnalyticsPage';
frontend/src/pages/DemoPage.tsx CHANGED
@@ -10,132 +10,51 @@ import {
10
  SearchMultiSelectInput,
11
  TextArea,
12
  Checkbox,
13
- Radio,
14
  Switch,
15
  DateInput,
16
  NumberInput,
17
  PasswordInput,
18
  RawFileInput,
19
  Container,
20
- Alert,
21
- Message,
22
  Spinner,
23
  ProgressBar,
24
  StackedProgressBar,
25
  KeyFigure,
26
  PieChart,
27
  BarChart,
28
- TimeSeriesChart,
29
- Table,
30
- HeaderCell,
31
- TableRow,
32
- TableData,
33
- Tabs,
34
- Tab,
35
- TabList,
36
- TabPanel,
37
- Chip,
38
- Tooltip,
39
- Modal,
40
- Popup,
41
- DropdownMenu,
42
  IconButton,
43
  ConfirmButton,
44
- Breadcrumbs,
45
- List,
46
- Grid,
47
- ExpandableContainer,
48
- BlockLoading,
49
- InputContainer,
50
  InputLabel,
51
  InputHint,
52
- InputError,
53
  InputSection,
54
  BooleanInput,
55
  BooleanOutput,
56
  DateOutput,
57
- DateRangeOutput,
58
  NumberOutput,
59
  TextOutput,
60
- HtmlOutput,
61
- DismissableTextOutput,
62
- DismissableListOutput,
63
- DismissableMultiListOutput,
64
- Legend,
65
- LegendItem,
66
- ChartContainer,
67
- ChartAxes,
68
- InfoPopup,
69
  Footer,
70
  NavigationTabList,
71
- Pager,
72
- RawButton,
73
- RawInput,
74
- RawTextArea,
75
- RawList,
76
  SegmentInput,
77
- SelectInputContainer,
78
- ReducedListDisplay,
79
- Image,
80
- TopBanner,
81
  } from '@ifrc-go/ui';
82
  import {
83
  UploadCloudLineIcon,
84
- ArrowRightLineIcon,
85
  SearchLineIcon,
86
- QuestionLineIcon,
87
- GoMainIcon,
88
- StarLineIcon,
89
- DashboardIcon,
90
- AnalysisIcon,
91
- FilterLineIcon,
92
- DropLineIcon,
93
- CartIcon,
94
  ChevronDownLineIcon,
95
- ChevronUpLineIcon,
96
  CloseLineIcon,
97
  EditLineIcon,
98
  DeleteBinLineIcon,
99
  DownloadLineIcon,
100
  ShareLineIcon,
101
- SettingsLineIcon,
102
- RulerLineIcon,
103
- MagicLineIcon,
104
- PantoneLineIcon,
105
- MarkupLineIcon,
106
- CalendarLineIcon,
107
- LockLineIcon,
108
  LocationIcon,
109
- HeartLineIcon,
110
- ThumbUpLineIcon,
111
- ThumbDownLineIcon,
112
- EyeLineIcon,
113
- EyeOffLineIcon,
114
  CheckLineIcon,
115
- CropLineIcon,
116
  AlertLineIcon,
117
  InfoIcon,
118
- AlarmWarningLineIcon,
119
- SliceLineIcon,
120
- ArrowLeftLineIcon,
121
- ArrowDownLineIcon,
122
- ArrowUpLineIcon,
123
- MenuLineIcon,
124
- MoreLineIcon,
125
- RefreshLineIcon,
126
- PaintLineIcon,
127
- NotificationIcon,
128
- HammerLineIcon,
129
- ShapeLineIcon,
130
- LinkLineIcon,
131
- ExternalLinkLineIcon,
132
- CopyLineIcon,
133
  } from '@ifrc-go/icons';
134
 
135
  export default function DemoPage() {
136
  const [showModal, setShowModal] = useState(false);
137
  const [showPopup, setShowPopup] = useState(false);
138
- const [activeTab, setActiveTab] = useState('components');
139
  const [loading, setLoading] = useState(false);
140
  const [textValue, setTextValue] = useState('');
141
  const [selectValue, setSelectValue] = useState('');
@@ -149,7 +68,7 @@ export default function DemoPage() {
149
  const [booleanValue, setBooleanValue] = useState(false);
150
  const [segmentValue, setSegmentValue] = useState('option1');
151
 
152
- // Dummy data
153
  const dummyOptions = [
154
  { key: 'option1', label: 'Option 1' },
155
  { key: 'option2', label: 'Option 2' },
@@ -168,12 +87,7 @@ export default function DemoPage() {
168
  { c_code: 'FR', label: 'France', r_code: 'EUR' },
169
  ];
170
 
171
- const dummyTableData = [
172
- { id: 1, name: 'John Doe', age: 30, country: 'United States', status: 'Active' },
173
- { id: 2, name: 'Jane Smith', age: 25, country: 'Canada', status: 'Inactive' },
174
- { id: 3, name: 'Bob Johnson', age: 35, country: 'Mexico', status: 'Active' },
175
- { id: 4, name: 'Alice Brown', age: 28, country: 'Brazil', status: 'Active' },
176
- ];
177
 
178
  const dummyChartData = [
179
  { name: 'Red Cross', value: 45 },
@@ -182,14 +96,7 @@ export default function DemoPage() {
182
  { name: 'WFP', value: 10 },
183
  ];
184
 
185
- const dummyTimeSeriesData = [
186
- { date: '2024-01', value: 100 },
187
- { date: '2024-02', value: 120 },
188
- { date: '2024-03', value: 110 },
189
- { date: '2024-04', value: 140 },
190
- { date: '2024-05', value: 130 },
191
- { date: '2024-06', value: 160 },
192
- ];
193
 
194
  const dummyBarData = [
195
  { name: 'Q1', value: 100 },
@@ -203,47 +110,47 @@ export default function DemoPage() {
203
  setTimeout(() => setLoading(false), 2000);
204
  };
205
 
206
- const handleTextChange = (value: string | undefined, name: string) => {
207
  setTextValue(value || '');
208
  };
209
 
210
- const handlePasswordChange = (value: string | undefined, name: string) => {
211
  setPasswordValue(value || '');
212
  };
213
 
214
- const handleNumberChange = (value: number | undefined, name: string) => {
215
  setNumberValue(value);
216
  };
217
 
218
- const handleDateChange = (value: string | undefined, name: string) => {
219
  setDateValue(value || '');
220
  };
221
 
222
- const handleSelectChange = (value: string | undefined, name: string) => {
223
  setSelectValue(value || '');
224
  };
225
 
226
- const handleMultiSelectChange = (value: string[], name: string) => {
227
  setMultiSelectValue(value);
228
  };
229
 
230
- const handleCheckboxChange = (value: boolean, name: string) => {
231
  setCheckboxValue(value);
232
  };
233
 
234
- const handleRadioChange = (value: string, name: string) => {
235
  setRadioValue(value);
236
  };
237
 
238
- const handleSwitchChange = (value: boolean, name: string) => {
239
  setSwitchValue(value);
240
  };
241
 
242
- const handleBooleanChange = (value: boolean, name: string) => {
243
  setBooleanValue(value);
244
  };
245
 
246
- const handleSegmentChange = (value: string, name: string) => {
247
  setSegmentValue(value);
248
  };
249
 
@@ -255,7 +162,7 @@ export default function DemoPage() {
255
  <div className="space-y-6">
256
  {/* Navigation Tabs */}
257
  <div>
258
- <h3 className="text-lg font-semibold mb-4">Navigation Tab List</h3>
259
  <NavigationTabList variant="primary">
260
  <Button name="upload" variant="primary">Upload</Button>
261
  <Button name="analytics" variant="secondary">Analytics</Button>
@@ -266,7 +173,7 @@ export default function DemoPage() {
266
 
267
  {/* Top Banner */}
268
  <div>
269
- <h3 className="text-lg font-semibold mb-4">Top Banner</h3>
270
  <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
271
  <div className="flex justify-between items-start">
272
  <div>
@@ -282,7 +189,7 @@ export default function DemoPage() {
282
 
283
  {/* Breadcrumbs */}
284
  <div>
285
- <h3 className="text-lg font-semibold mb-4">Breadcrumbs</h3>
286
  <nav className="flex" aria-label="Breadcrumb">
287
  <ol className="flex items-center space-x-2">
288
  <li>
@@ -311,7 +218,7 @@ export default function DemoPage() {
311
  <div className="space-y-6">
312
  {/* Buttons */}
313
  <div>
314
- <h3 className="text-lg font-semibold mb-4">Buttons</h3>
315
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
316
  <Button name="primary" variant="primary">Primary Button</Button>
317
  <Button name="secondary" variant="secondary">Secondary Button</Button>
@@ -332,7 +239,7 @@ export default function DemoPage() {
332
 
333
  {/* Icon Buttons */}
334
  <div>
335
- <h3 className="text-lg font-semibold mb-4">Icon Buttons</h3>
336
  <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
337
  <IconButton name="upload" variant="primary" title="Upload" ariaLabel="Upload">
338
  <UploadCloudLineIcon />
@@ -547,7 +454,7 @@ export default function DemoPage() {
547
  name="radio"
548
  value="option1"
549
  checked={radioValue === 'option1'}
550
- onChange={(e) => handleRadioChange(e.target.value, 'radio')}
551
  className="mr-2"
552
  />
553
  <span className="text-sm">Option 1</span>
@@ -558,7 +465,7 @@ export default function DemoPage() {
558
  name="radio"
559
  value="option2"
560
  checked={radioValue === 'option2'}
561
- onChange={(e) => handleRadioChange(e.target.value, 'radio')}
562
  className="mr-2"
563
  />
564
  <span className="text-sm">Option 2</span>
@@ -569,7 +476,7 @@ export default function DemoPage() {
569
  name="radio"
570
  value="option3"
571
  checked={radioValue === 'option3'}
572
- onChange={(e) => handleRadioChange(e.target.value, 'radio')}
573
  className="mr-2"
574
  />
575
  <span className="text-sm">Option 3</span>
@@ -642,7 +549,7 @@ export default function DemoPage() {
642
  valueSelector={(d) => d.value}
643
  labelSelector={(d) => d.name}
644
  keySelector={(d) => d.name}
645
- colorSelector={(d) => '#dc2626'}
646
  showPercentageInLegend
647
  />
648
  </div>
@@ -670,40 +577,7 @@ export default function DemoPage() {
670
  </div>
671
  </div>
672
 
673
- {/* Tables */}
674
- <div>
675
- <h3 className="text-lg font-semibold mb-4">Tables</h3>
676
- <div className="overflow-x-auto">
677
- <table className="min-w-full divide-y divide-gray-200">
678
- <thead className="bg-gray-50">
679
- <tr>
680
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
681
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Age</th>
682
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
683
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
684
- </tr>
685
- </thead>
686
- <tbody className="bg-white divide-y divide-gray-200">
687
- {dummyTableData.map((row) => (
688
- <tr key={row.id}>
689
- <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{row.name}</td>
690
- <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{row.age}</td>
691
- <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{row.country}</td>
692
- <td className="px-6 py-4 whitespace-nowrap">
693
- <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
694
- row.status === 'Active'
695
- ? 'bg-green-100 text-green-800'
696
- : 'bg-gray-100 text-gray-800'
697
- }`}>
698
- {row.status}
699
- </span>
700
- </td>
701
- </tr>
702
- ))}
703
- </tbody>
704
- </table>
705
- </div>
706
- </div>
707
 
708
  {/* Lists */}
709
  <div>
 
10
  SearchMultiSelectInput,
11
  TextArea,
12
  Checkbox,
 
13
  Switch,
14
  DateInput,
15
  NumberInput,
16
  PasswordInput,
17
  RawFileInput,
18
  Container,
 
 
19
  Spinner,
20
  ProgressBar,
21
  StackedProgressBar,
22
  KeyFigure,
23
  PieChart,
24
  BarChart,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  IconButton,
26
  ConfirmButton,
 
 
 
 
 
 
27
  InputLabel,
28
  InputHint,
 
29
  InputSection,
30
  BooleanInput,
31
  BooleanOutput,
32
  DateOutput,
 
33
  NumberOutput,
34
  TextOutput,
 
 
 
 
 
 
 
 
 
35
  Footer,
36
  NavigationTabList,
 
 
 
 
 
37
  SegmentInput,
38
+ BlockLoading,
 
 
 
39
  } from '@ifrc-go/ui';
40
  import {
41
  UploadCloudLineIcon,
 
42
  SearchLineIcon,
 
 
 
 
 
 
 
 
43
  ChevronDownLineIcon,
 
44
  CloseLineIcon,
45
  EditLineIcon,
46
  DeleteBinLineIcon,
47
  DownloadLineIcon,
48
  ShareLineIcon,
 
 
 
 
 
 
 
49
  LocationIcon,
 
 
 
 
 
50
  CheckLineIcon,
 
51
  AlertLineIcon,
52
  InfoIcon,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  } from '@ifrc-go/icons';
54
 
55
  export default function DemoPage() {
56
  const [showModal, setShowModal] = useState(false);
57
  const [showPopup, setShowPopup] = useState(false);
 
58
  const [loading, setLoading] = useState(false);
59
  const [textValue, setTextValue] = useState('');
60
  const [selectValue, setSelectValue] = useState('');
 
68
  const [booleanValue, setBooleanValue] = useState(false);
69
  const [segmentValue, setSegmentValue] = useState('option1');
70
 
71
+
72
  const dummyOptions = [
73
  { key: 'option1', label: 'Option 1' },
74
  { key: 'option2', label: 'Option 2' },
 
87
  { c_code: 'FR', label: 'France', r_code: 'EUR' },
88
  ];
89
 
90
+
 
 
 
 
 
91
 
92
  const dummyChartData = [
93
  { name: 'Red Cross', value: 45 },
 
96
  { name: 'WFP', value: 10 },
97
  ];
98
 
99
+
 
 
 
 
 
 
 
100
 
101
  const dummyBarData = [
102
  { name: 'Q1', value: 100 },
 
110
  setTimeout(() => setLoading(false), 2000);
111
  };
112
 
113
+ const handleTextChange = (value: string | undefined) => {
114
  setTextValue(value || '');
115
  };
116
 
117
+ const handlePasswordChange = (value: string | undefined) => {
118
  setPasswordValue(value || '');
119
  };
120
 
121
+ const handleNumberChange = (value: number | undefined) => {
122
  setNumberValue(value);
123
  };
124
 
125
+ const handleDateChange = (value: string | undefined) => {
126
  setDateValue(value || '');
127
  };
128
 
129
+ const handleSelectChange = (value: string | undefined) => {
130
  setSelectValue(value || '');
131
  };
132
 
133
+ const handleMultiSelectChange = (value: string[]) => {
134
  setMultiSelectValue(value);
135
  };
136
 
137
+ const handleCheckboxChange = (value: boolean) => {
138
  setCheckboxValue(value);
139
  };
140
 
141
+ const handleRadioChange = (value: string) => {
142
  setRadioValue(value);
143
  };
144
 
145
+ const handleSwitchChange = (value: boolean) => {
146
  setSwitchValue(value);
147
  };
148
 
149
+ const handleBooleanChange = (value: boolean) => {
150
  setBooleanValue(value);
151
  };
152
 
153
+ const handleSegmentChange = (value: string) => {
154
  setSegmentValue(value);
155
  };
156
 
 
162
  <div className="space-y-6">
163
  {/* Navigation Tabs */}
164
  <div>
165
+ <Heading level={3} className="mb-4">Navigation Tab List</Heading>
166
  <NavigationTabList variant="primary">
167
  <Button name="upload" variant="primary">Upload</Button>
168
  <Button name="analytics" variant="secondary">Analytics</Button>
 
173
 
174
  {/* Top Banner */}
175
  <div>
176
+ <Heading level={3} className="mb-4">Top Banner</Heading>
177
  <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
178
  <div className="flex justify-between items-start">
179
  <div>
 
189
 
190
  {/* Breadcrumbs */}
191
  <div>
192
+ <Heading level={3} className="mb-4">Breadcrumbs</Heading>
193
  <nav className="flex" aria-label="Breadcrumb">
194
  <ol className="flex items-center space-x-2">
195
  <li>
 
218
  <div className="space-y-6">
219
  {/* Buttons */}
220
  <div>
221
+ <Heading level={3} className="mb-4">Buttons</Heading>
222
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
223
  <Button name="primary" variant="primary">Primary Button</Button>
224
  <Button name="secondary" variant="secondary">Secondary Button</Button>
 
239
 
240
  {/* Icon Buttons */}
241
  <div>
242
+ <Heading level={3} className="mb-4">Icon Buttons</Heading>
243
  <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
244
  <IconButton name="upload" variant="primary" title="Upload" ariaLabel="Upload">
245
  <UploadCloudLineIcon />
 
454
  name="radio"
455
  value="option1"
456
  checked={radioValue === 'option1'}
457
+ onChange={(e) => handleRadioChange(e.target.value)}
458
  className="mr-2"
459
  />
460
  <span className="text-sm">Option 1</span>
 
465
  name="radio"
466
  value="option2"
467
  checked={radioValue === 'option2'}
468
+ onChange={(e) => handleRadioChange(e.target.value)}
469
  className="mr-2"
470
  />
471
  <span className="text-sm">Option 2</span>
 
476
  name="radio"
477
  value="option3"
478
  checked={radioValue === 'option3'}
479
+ onChange={(e) => handleRadioChange(e.target.value)}
480
  className="mr-2"
481
  />
482
  <span className="text-sm">Option 3</span>
 
549
  valueSelector={(d) => d.value}
550
  labelSelector={(d) => d.name}
551
  keySelector={(d) => d.name}
552
+ colorSelector={() => '#dc2626'}
553
  showPercentageInLegend
554
  />
555
  </div>
 
577
  </div>
578
  </div>
579
 
580
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
 
582
  {/* Lists */}
583
  <div>
frontend/src/pages/DevPage.tsx CHANGED
@@ -3,11 +3,9 @@ import {
3
  PageContainer, Heading, Button, Container,
4
  } from '@ifrc-go/ui';
5
 
6
- // Local storage key for selected model
7
  const SELECTED_MODEL_KEY = 'selectedVlmModel';
8
 
9
  export default function DevPage() {
10
- // Model selection state
11
  const [availableModels, setAvailableModels] = useState<Array<{
12
  m_code: string;
13
  label: string;
@@ -16,7 +14,6 @@ export default function DevPage() {
16
  }>>([]);
17
  const [selectedModel, setSelectedModel] = useState<string>('');
18
 
19
- // Fetch models on component mount
20
  useEffect(() => {
21
  fetchModels();
22
  }, []);
@@ -25,11 +22,8 @@ export default function DevPage() {
25
  fetch('/api/models')
26
  .then(r => r.json())
27
  .then(modelsData => {
28
- console.log('Models data:', modelsData);
29
- console.log('Available models count:', modelsData.models?.length || 0);
30
  setAvailableModels(modelsData.models || []);
31
 
32
- // Load persisted model or set default model (first available model)
33
  const persistedModel = localStorage.getItem(SELECTED_MODEL_KEY);
34
  if (modelsData.models && modelsData.models.length > 0) {
35
  if (persistedModel && modelsData.models.find((m: any) => m.m_code === persistedModel && m.is_available)) {
@@ -42,7 +36,7 @@ export default function DevPage() {
42
  }
43
  })
44
  .catch(err => {
45
- console.error('Failed to fetch models:', err);
46
  });
47
  };
48
 
@@ -59,7 +53,6 @@ export default function DevPage() {
59
  });
60
 
61
  if (response.ok) {
62
- // Update local state
63
  setAvailableModels(prev =>
64
  prev.map(model =>
65
  model.m_code === modelCode
@@ -67,19 +60,15 @@ export default function DevPage() {
67
  : model
68
  )
69
  );
70
- console.log(`Model ${modelCode} availability toggled to ${!currentStatus}`);
71
  } else {
72
  const errorData = await response.json();
73
- console.error('Failed to toggle model availability:', errorData);
74
  alert(`Failed to toggle model availability: ${errorData.error || 'Unknown error'}`);
75
  }
76
  } catch (error) {
77
- console.error('Error toggling model availability:', error);
78
  alert('Error toggling model availability');
79
  }
80
  };
81
 
82
- // Handle model selection change
83
  const handleModelChange = (modelCode: string) => {
84
  setSelectedModel(modelCode);
85
  localStorage.setItem(SELECTED_MODEL_KEY, modelCode);
@@ -195,12 +184,10 @@ export default function DevPage() {
195
  fetch('/api/models')
196
  .then(r => r.json())
197
  .then(data => {
198
- console.log('Models API response:', data);
199
- alert('Check console for models API response');
200
  })
201
  .catch(err => {
202
- console.error('Models API error:', err);
203
- alert('Models API error - check console');
204
  });
205
  }}
206
  >
@@ -216,12 +203,10 @@ export default function DevPage() {
216
  fetch(`/api/models/${selectedModel}/test`)
217
  .then(r => r.json())
218
  .then(data => {
219
- console.log('Model test response:', data);
220
- alert('Check console for model test response');
221
  })
222
  .catch(err => {
223
- console.error('Model test error:', err);
224
- alert('Model test error - check console');
225
  });
226
  }}
227
  >
 
3
  PageContainer, Heading, Button, Container,
4
  } from '@ifrc-go/ui';
5
 
 
6
  const SELECTED_MODEL_KEY = 'selectedVlmModel';
7
 
8
  export default function DevPage() {
 
9
  const [availableModels, setAvailableModels] = useState<Array<{
10
  m_code: string;
11
  label: string;
 
14
  }>>([]);
15
  const [selectedModel, setSelectedModel] = useState<string>('');
16
 
 
17
  useEffect(() => {
18
  fetchModels();
19
  }, []);
 
22
  fetch('/api/models')
23
  .then(r => r.json())
24
  .then(modelsData => {
 
 
25
  setAvailableModels(modelsData.models || []);
26
 
 
27
  const persistedModel = localStorage.getItem(SELECTED_MODEL_KEY);
28
  if (modelsData.models && modelsData.models.length > 0) {
29
  if (persistedModel && modelsData.models.find((m: any) => m.m_code === persistedModel && m.is_available)) {
 
36
  }
37
  })
38
  .catch(err => {
39
+
40
  });
41
  };
42
 
 
53
  });
54
 
55
  if (response.ok) {
 
56
  setAvailableModels(prev =>
57
  prev.map(model =>
58
  model.m_code === modelCode
 
60
  : model
61
  )
62
  );
 
63
  } else {
64
  const errorData = await response.json();
 
65
  alert(`Failed to toggle model availability: ${errorData.error || 'Unknown error'}`);
66
  }
67
  } catch (error) {
 
68
  alert('Error toggling model availability');
69
  }
70
  };
71
 
 
72
  const handleModelChange = (modelCode: string) => {
73
  setSelectedModel(modelCode);
74
  localStorage.setItem(SELECTED_MODEL_KEY, modelCode);
 
184
  fetch('/api/models')
185
  .then(r => r.json())
186
  .then(data => {
187
+ alert('Models API response received successfully');
 
188
  })
189
  .catch(err => {
190
+ alert('Models API error occurred');
 
191
  });
192
  }}
193
  >
 
203
  fetch(`/api/models/${selectedModel}/test`)
204
  .then(r => r.json())
205
  .then(data => {
206
+ alert('Model test completed successfully');
 
207
  })
208
  .catch(err => {
209
+ alert('Model test failed');
 
210
  });
211
  }}
212
  >
frontend/src/pages/ExplorePage.tsx DELETED
@@ -1,365 +0,0 @@
1
- import { PageContainer, Heading, TextInput, SelectInput, MultiSelectInput, Button } from '@ifrc-go/ui';
2
- import { useState, useEffect, useMemo } from 'react';
3
- import { useNavigate } from 'react-router-dom';
4
- import { StarLineIcon } from '@ifrc-go/icons';
5
-
6
- interface MapOut {
7
- image_id: string;
8
- file_key: string;
9
- image_url: string;
10
- source: string;
11
- type: string;
12
- epsg: string;
13
- image_type: string;
14
- countries?: {c_code: string, label: string, r_code: string}[];
15
- caption?: {
16
- title: string;
17
- generated: string;
18
- edited?: string;
19
- starred?: boolean;
20
- };
21
- }
22
-
23
- export default function ExplorePage() {
24
- const navigate = useNavigate();
25
- const [maps, setMaps] = useState<MapOut[]>([]);
26
- const [search, setSearch] = useState('');
27
- const [srcFilter, setSrcFilter] = useState('');
28
- const [catFilter, setCatFilter] = useState('');
29
- const [regionFilter, setRegionFilter] = useState('');
30
- const [countryFilter, setCountryFilter] = useState('');
31
- const [showStarredOnly, setShowStarredOnly] = useState(false);
32
- const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
33
- const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
34
- const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]);
35
- const [countries, setCountries] = useState<{c_code: string, label: string, r_code: string}[]>([]);
36
- const [isLoadingFilters, setIsLoadingFilters] = useState(true);
37
-
38
- const fetchMaps = () => {
39
- setIsLoadingFilters(true);
40
- // Fetch maps
41
- fetch('/api/images/')
42
- .then(r => {
43
- if (!r.ok) {
44
- throw new Error(`HTTP ${r.status}: ${r.statusText}`);
45
- }
46
- return r.json();
47
- })
48
- .then(data => {
49
- // Ensure data is an array
50
- if (Array.isArray(data)) {
51
- setMaps(data);
52
- console.log(`Loaded ${data.length} maps`);
53
- if (data.length > 0) {
54
- console.log('Sample map data:', {
55
- image_id: data[0].image_id,
56
- source: data[0].source,
57
- type: data[0].type,
58
- countries: data[0].countries?.length || 0,
59
- caption: data[0].caption ? 'has caption' : 'no caption'
60
- });
61
- }
62
- } else {
63
- console.error('Expected array from /api/images/, got:', data);
64
- setMaps([]);
65
- }
66
- })
67
- .catch(err => {
68
- console.error('Failed to fetch maps:', err);
69
- setMaps([]);
70
- })
71
- .finally(() => {
72
- setIsLoadingFilters(false);
73
- });
74
- };
75
-
76
- useEffect(() => {
77
- fetchMaps();
78
- }, []);
79
-
80
- // Auto-refresh when component becomes visible (user navigates back)
81
- useEffect(() => {
82
- const handleVisibilityChange = () => {
83
- if (!document.hidden) {
84
- fetchMaps();
85
- }
86
- };
87
-
88
- document.addEventListener('visibilitychange', handleVisibilityChange);
89
- return () => {
90
- document.removeEventListener('visibilitychange', handleVisibilityChange);
91
- };
92
- }, []);
93
-
94
- useEffect(() => {
95
- // Fetch lookup data
96
- console.log('Fetching filter data...');
97
- setIsLoadingFilters(true);
98
-
99
- Promise.all([
100
- fetch('/api/sources').then(r => {
101
- console.log('Sources response:', r.status, r.statusText);
102
- if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
103
- return r.json();
104
- }),
105
- fetch('/api/types').then(r => {
106
- console.log('Types response:', r.status, r.statusText);
107
- if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
108
- return r.json();
109
- }),
110
- fetch('/api/regions').then(r => {
111
- console.log('Regions response:', r.status, r.statusText);
112
- if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
113
- return r.json();
114
- }),
115
- fetch('/api/countries').then(r => {
116
- console.log('Countries response:', r.status, r.statusText);
117
- if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
118
- return r.json();
119
- })
120
- ]).then(([sourcesData, typesData, regionsData, countriesData]) => {
121
- console.log('Fetched filter data:', {
122
- sources: sourcesData.length,
123
- types: typesData.length,
124
- regions: regionsData.length,
125
- countries: countriesData.length
126
- });
127
-
128
- if (Array.isArray(sourcesData)) {
129
- setSources(sourcesData);
130
- } else {
131
- console.error('Expected array from /api/sources, got:', sourcesData);
132
- setSources([]);
133
- }
134
-
135
- if (Array.isArray(typesData)) {
136
- setTypes(typesData);
137
- } else {
138
- console.error('Expected array from /api/types, got:', typesData);
139
- setTypes([]);
140
- }
141
-
142
- if (Array.isArray(regionsData)) {
143
- setRegions(regionsData);
144
- } else {
145
- console.error('Expected array from /api/regions, got:', regionsData);
146
- setRegions([]);
147
- }
148
-
149
- if (Array.isArray(countriesData)) {
150
- setCountries(countriesData);
151
- } else {
152
- console.error('Expected array from /api/countries, got:', countriesData);
153
- setCountries([]);
154
- }
155
-
156
- setIsLoadingFilters(false);
157
- }).catch(err => {
158
- console.error('Failed to fetch filter data:', err);
159
- // Set empty arrays on error to prevent undefined issues
160
- setSources([]);
161
- setTypes([]);
162
- setRegions([]);
163
- setCountries([]);
164
- setIsLoadingFilters(false);
165
- });
166
- }, []);
167
-
168
- const filtered = useMemo(() => {
169
- // Ensure maps is an array before filtering
170
- if (!Array.isArray(maps)) {
171
- console.warn('maps is not an array:', maps);
172
- return [];
173
- }
174
-
175
- return maps.filter(m => {
176
- // Search in filename, source, type, title, and caption
177
- const searchLower = search.toLowerCase();
178
- const searchMatch = !search ||
179
- m.file_key.toLowerCase().includes(searchLower) ||
180
- m.source.toLowerCase().includes(searchLower) ||
181
- m.type.toLowerCase().includes(searchLower) ||
182
- (m.caption?.title && m.caption.title.toLowerCase().includes(searchLower)) ||
183
- (m.caption?.edited && m.caption.edited.toLowerCase().includes(searchLower)) ||
184
- (m.caption?.generated && m.caption.generated.toLowerCase().includes(searchLower));
185
-
186
- // Filter by source
187
- const sourceMatch = !srcFilter || m.source === srcFilter;
188
-
189
- // Filter by type
190
- const typeMatch = !catFilter || m.type === catFilter;
191
-
192
- // Filter by region (check if any country in the image belongs to the selected region)
193
- const regionMatch = !regionFilter || (m.countries && m.countries.some(c => c.r_code === regionFilter));
194
-
195
- // Filter by country (check if any country in the image matches the selected country)
196
- const countryMatch = !countryFilter || (m.countries && m.countries.some(c => c.c_code === countryFilter));
197
-
198
- // Filter by starred status
199
- const starredMatch = !showStarredOnly || (m.caption && m.caption.starred === true);
200
-
201
- return searchMatch && sourceMatch && typeMatch && regionMatch && countryMatch && starredMatch;
202
- });
203
- }, [maps, search, srcFilter, catFilter, regionFilter, countryFilter]);
204
-
205
- return (
206
- <PageContainer>
207
- <div className="space-y-6">
208
- {/* Header Section */}
209
- <div className="flex justify-between items-center">
210
- <div>
211
- <Heading level={2}>Explore Examples</Heading>
212
- <p className="text-gray-600 mt-1">Browse and search through uploaded crisis maps</p>
213
- </div>
214
- <div className="flex gap-2">
215
- <Button
216
- name="reference-examples"
217
- variant={showStarredOnly ? "primary" : "secondary"}
218
- onClick={() => setShowStarredOnly(!showStarredOnly)}
219
- >
220
- <StarLineIcon className="w-4 h-4" />
221
- <span className="inline ml-2">Reference Examples</span>
222
- </Button>
223
- <Button
224
- name="export"
225
- variant="secondary"
226
- onClick={() => {
227
- const data = {
228
- maps: maps,
229
- filters: {
230
- sources: sources,
231
- types: types,
232
- regions: regions,
233
- countries: countries
234
- },
235
- timestamp: new Date().toISOString()
236
- };
237
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
238
- const url = URL.createObjectURL(blob);
239
- const a = document.createElement('a');
240
- a.href = url;
241
- a.download = `promptaid-vision-data-${new Date().toISOString().split('T')[0]}.json`;
242
- document.body.appendChild(a);
243
- a.click();
244
- document.body.removeChild(a);
245
- URL.revokeObjectURL(url);
246
- }}
247
- >
248
- Export
249
- </Button>
250
- </div>
251
- </div>
252
-
253
- {/* Filters Bar */}
254
- <div className="bg-gray-50 rounded-lg p-4">
255
- <div className="flex flex-wrap gap-4 items-center">
256
- <TextInput
257
- name="search"
258
- placeholder="Search by filename, title…"
259
- value={search}
260
- onChange={(e) => setSearch(e || '')}
261
- className="flex-1 min-w-[12rem]"
262
- />
263
-
264
- <SelectInput
265
- name="source"
266
- placeholder={isLoadingFilters ? "Loading..." : "All Sources"}
267
- options={sources}
268
- value={srcFilter || null}
269
- onChange={(v) => setSrcFilter(v as string || '')}
270
- keySelector={(o) => o.s_code}
271
- labelSelector={(o) => o.label}
272
- required={false}
273
- disabled={isLoadingFilters}
274
- />
275
-
276
- <SelectInput
277
- name="type"
278
- placeholder={isLoadingFilters ? "Loading..." : "All Types"}
279
- options={types}
280
- value={catFilter || null}
281
- onChange={(v) => setCatFilter(v as string || '')}
282
- keySelector={(o) => o.t_code}
283
- labelSelector={(o) => o.label}
284
- required={false}
285
- disabled={isLoadingFilters}
286
- />
287
-
288
- <SelectInput
289
- name="region"
290
- placeholder={isLoadingFilters ? "Loading..." : "All Regions"}
291
- options={regions}
292
- value={regionFilter || null}
293
- onChange={(v) => setRegionFilter(v as string || '')}
294
- keySelector={(o) => o.r_code}
295
- labelSelector={(o) => o.label}
296
- required={false}
297
- disabled={isLoadingFilters}
298
- />
299
-
300
- <MultiSelectInput
301
- name="country"
302
- placeholder={isLoadingFilters ? "Loading..." : "All Countries"}
303
- options={countries}
304
- value={countryFilter ? [countryFilter] : []}
305
- onChange={(v) => setCountryFilter((v as string[])[0] || '')}
306
- keySelector={(o) => o.c_code}
307
- labelSelector={(o) => o.label}
308
- disabled={isLoadingFilters}
309
- />
310
- </div>
311
- </div>
312
-
313
- {/* Results Section */}
314
- <div className="space-y-4">
315
- <div className="flex justify-between items-center">
316
- <p className="text-sm text-gray-600">
317
- {filtered.length} of {maps.length} examples
318
- </p>
319
- </div>
320
-
321
- {/* List */}
322
- <div className="space-y-4">
323
- {filtered.map(m => (
324
- <div key={m.image_id} className="border border-gray-200 rounded-lg p-4 flex gap-4 cursor-pointer hover:bg-gray-50 transition-colors" onClick={() => navigate(`/map/${m.image_id}`)}>
325
- <div className="bg-gray-100 flex items-center justify-center text-gray-400 text-xs overflow-hidden rounded" style={{ width: '120px', height: '80px' }}>
326
- {m.image_url ? (
327
- <img
328
- src={m.image_url}
329
- alt={m.file_key}
330
- className="w-full h-full object-cover"
331
- style={{ imageRendering: 'pixelated' }}
332
- onError={(e) => {
333
- // Fallback to placeholder if image fails to load
334
- const target = e.target as HTMLImageElement;
335
- target.style.display = 'none';
336
- target.parentElement!.innerHTML = 'Img';
337
- }}
338
- />
339
- ) : (
340
- 'Img'
341
- )}
342
- </div>
343
- <div className="flex-1 min-w-0">
344
- <h3 className="font-medium text-gray-900 mb-2">
345
- {m.caption?.title || 'No title'}
346
- </h3>
347
- <div className="flex flex-wrap gap-2 mb-2">
348
- <span className="px-2 py-1 bg-ifrcRed/10 text-ifrcRed text-xs rounded">{m.source}</span>
349
- <span className="px-2 py-1 bg-ifrcRed text-xs rounded">{m.type}</span>
350
- </div>
351
- </div>
352
- </div>
353
- ))}
354
-
355
- {!filtered.length && (
356
- <div className="text-center py-12">
357
- <p className="text-gray-500">No examples found.</p>
358
- </div>
359
- )}
360
- </div>
361
- </div>
362
- </div>
363
- </PageContainer>
364
- );
365
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/pages/ExplorePage/ExplorePage.module.css ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .metadataTags {
2
+ display: flex;
3
+ flex-wrap: wrap;
4
+ gap: var(--go-ui-spacing-sm);
5
+ align-items: center;
6
+ }
7
+
8
+ .metadataTag {
9
+ padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
10
+ background-color: var(--go-ui-color-red-10);
11
+ color: var(--go-ui-color-red-90);
12
+ font-size: var(--go-ui-font-size-xs);
13
+ border-radius: var(--go-ui-border-radius-md);
14
+ font-weight: var(--go-ui-font-weight-medium);
15
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-20);
16
+ transition: all var(--go-ui-duration-transition-fast) ease;
17
+ white-space: nowrap;
18
+ }
19
+
20
+ .metadataTag:hover {
21
+ background-color: var(--go-ui-color-red-20);
22
+ transform: translateY(-1px);
23
+ box-shadow: var(--go-ui-box-shadow-xs);
24
+ }
25
+
26
+ .metadataTagSource {
27
+ padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
28
+ background-color: var(--go-ui-color-blue-10);
29
+ color: var(--go-ui-color-blue-90);
30
+ font-size: var(--go-ui-font-size-xs);
31
+ border-radius: var(--go-ui-border-radius-md);
32
+ font-weight: var(--go-ui-font-weight-medium);
33
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-blue-20);
34
+ white-space: nowrap;
35
+ }
36
+
37
+ .metadataTagType {
38
+ padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
39
+ background-color: var(--go-ui-color-red-90);
40
+ color: var(--go-ui-color-white);
41
+ font-size: var(--go-ui-font-size-xs);
42
+ border-radius: var(--go-ui-border-radius-md);
43
+ font-weight: var(--go-ui-font-weight-medium);
44
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-90);
45
+ white-space: nowrap;
46
+ }
47
+
48
+ .mapItem {
49
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
50
+ border-radius: var(--go-ui-border-radius-lg);
51
+ padding: var(--go-ui-spacing-lg);
52
+ display: flex;
53
+ gap: var(--go-ui-spacing-lg);
54
+ cursor: pointer;
55
+ transition: all var(--go-ui-duration-transition-medium) ease;
56
+ background-color: var(--go-ui-color-white);
57
+ }
58
+
59
+ .mapItem:hover {
60
+ background-color: var(--go-ui-color-gray-10);
61
+ border-color: var(--go-ui-color-gray-30);
62
+ box-shadow: var(--go-ui-box-shadow-sm);
63
+ transform: translateY(-1px);
64
+ }
65
+
66
+ .mapItemImage {
67
+ background-color: var(--go-ui-color-gray-20);
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ color: var(--go-ui-color-gray-60);
72
+ font-size: var(--go-ui-font-size-xs);
73
+ overflow: hidden;
74
+ border-radius: var(--go-ui-border-radius-md);
75
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
76
+ flex-shrink: 0;
77
+ }
78
+
79
+ .mapItemImage img {
80
+ width: 100%;
81
+ height: 100%;
82
+ object-fit: cover;
83
+ image-rendering: pixelated;
84
+ }
85
+
86
+ .mapItemContent {
87
+ flex: 1;
88
+ min-width: 0;
89
+ }
90
+
91
+ .mapItemTitle {
92
+ font-weight: var(--go-ui-font-weight-medium);
93
+ color: var(--go-ui-color-text);
94
+ margin-bottom: var(--go-ui-spacing-sm);
95
+ font-size: var(--go-ui-font-size-md);
96
+ line-height: var(--go-ui-line-height-md);
97
+ }
98
+
99
+ .mapItemMetadata {
100
+ margin-bottom: var(--go-ui-spacing-sm);
101
+ }
102
+
103
+ /* Responsive adjustments */
104
+ @media (max-width: 768px) {
105
+ .mapItem {
106
+ flex-direction: column;
107
+ gap: var(--go-ui-spacing-md);
108
+ }
109
+
110
+ .mapItemImage {
111
+ width: 100%;
112
+ height: 120px;
113
+ }
114
+
115
+ .metadataTags {
116
+ gap: var(--go-ui-spacing-xs);
117
+ }
118
+
119
+ .metadataTag,
120
+ .metadataTagSource,
121
+ .metadataTagType {
122
+ font-size: var(--go-ui-font-size-xs);
123
+ padding: var(--go-ui-spacing-2xs) var(--go-ui-spacing-xs);
124
+ }
125
+ }
frontend/src/pages/ExplorePage/ExplorePage.tsx ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PageContainer, TextInput, SelectInput, MultiSelectInput, Button, Container } from '@ifrc-go/ui';
2
+ import { useState, useEffect, useMemo } from 'react';
3
+ import { useNavigate } from 'react-router-dom';
4
+ import { StarLineIcon } from '@ifrc-go/icons';
5
+ import styles from './ExplorePage.module.css';
6
+
7
+ interface CaptionWithImageOut {
8
+ cap_id: string;
9
+ image_id: string;
10
+ title: string;
11
+ prompt: string;
12
+ model: string;
13
+ schema_id: string;
14
+ raw_json: any;
15
+ generated: string;
16
+ edited?: string;
17
+ accuracy?: number;
18
+ context?: number;
19
+ usability?: number;
20
+ starred: boolean;
21
+ created_at?: string;
22
+ updated_at?: string;
23
+ file_key: string;
24
+ image_url: string;
25
+ source: string;
26
+ event_type: string;
27
+ epsg: string;
28
+ image_type: string;
29
+ countries: {c_code: string, label: string, r_code: string}[];
30
+ }
31
+
32
+ export default function ExplorePage() {
33
+ const navigate = useNavigate();
34
+ const [captions, setCaptions] = useState<CaptionWithImageOut[]>([]);
35
+ const [search, setSearch] = useState('');
36
+ const [srcFilter, setSrcFilter] = useState('');
37
+ const [catFilter, setCatFilter] = useState('');
38
+ const [regionFilter, setRegionFilter] = useState('');
39
+ const [countryFilter, setCountryFilter] = useState('');
40
+ const [showStarredOnly, setShowStarredOnly] = useState(false);
41
+ const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
42
+ const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
43
+ const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]);
44
+ const [countries, setCountries] = useState<{c_code: string, label: string, r_code: string}[]>([]);
45
+ const [isLoadingFilters, setIsLoadingFilters] = useState(true);
46
+
47
+ const fetchCaptions = () => {
48
+ setIsLoadingFilters(true);
49
+ fetch('/api/captions')
50
+ .then(r => {
51
+ if (!r.ok) {
52
+ throw new Error(`HTTP ${r.status}: ${r.statusText}`);
53
+ }
54
+ return r.json();
55
+ })
56
+ .then(data => {
57
+ if (Array.isArray(data)) {
58
+ setCaptions(data);
59
+
60
+ } else {
61
+
62
+ setCaptions([]);
63
+ }
64
+ })
65
+ .catch(() => {
66
+ setCaptions([]);
67
+ })
68
+ .finally(() => {
69
+ setIsLoadingFilters(false);
70
+ });
71
+ };
72
+
73
+ useEffect(() => {
74
+ fetchCaptions();
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ const handleVisibilityChange = () => {
79
+ if (!document.hidden) {
80
+ fetchCaptions();
81
+ }
82
+ };
83
+
84
+ document.addEventListener('visibilitychange', handleVisibilityChange);
85
+ return () => {
86
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
87
+ };
88
+ }, []);
89
+
90
+ useEffect(() => {
91
+
92
+ setIsLoadingFilters(true);
93
+
94
+ Promise.all([
95
+ fetch('/api/sources').then(r => {
96
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
97
+ return r.json();
98
+ }),
99
+ fetch('/api/types').then(r => {
100
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
101
+ return r.json();
102
+ }),
103
+ fetch('/api/regions').then(r => {
104
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
105
+ return r.json();
106
+ }),
107
+ fetch('/api/countries').then(r => {
108
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
109
+ return r.json();
110
+ })
111
+ ]).then(([sourcesData, typesData, regionsData, countriesData]) => {
112
+
113
+
114
+ if (Array.isArray(sourcesData)) {
115
+ setSources(sourcesData);
116
+ } else {
117
+
118
+ setSources([]);
119
+ }
120
+
121
+ if (Array.isArray(typesData)) {
122
+ setTypes(typesData);
123
+ } else {
124
+
125
+ setTypes([]);
126
+ }
127
+
128
+ if (Array.isArray(regionsData)) {
129
+ setRegions(regionsData);
130
+ } else {
131
+
132
+ setRegions([]);
133
+ }
134
+
135
+ if (Array.isArray(countriesData)) {
136
+ setCountries(countriesData);
137
+ } else {
138
+
139
+ setCountries([]);
140
+ }
141
+
142
+ setIsLoadingFilters(false);
143
+ }).catch(() => {
144
+
145
+ setSources([]);
146
+ setTypes([]);
147
+ setRegions([]);
148
+ setCountries([]);
149
+ setIsLoadingFilters(false);
150
+ });
151
+ }, []);
152
+
153
+ const filtered = useMemo(() => {
154
+ if (!Array.isArray(captions)) {
155
+
156
+ return [];
157
+ }
158
+
159
+ return captions.filter(c => {
160
+ const searchLower = search.toLowerCase();
161
+ const searchMatch = !search ||
162
+ c.file_key.toLowerCase().includes(searchLower) ||
163
+ c.source.toLowerCase().includes(searchLower) ||
164
+ c.event_type.toLowerCase().includes(searchLower) ||
165
+ c.title.toLowerCase().includes(searchLower) ||
166
+ (c.edited && c.edited.toLowerCase().includes(searchLower)) ||
167
+ (c.generated && c.generated.toLowerCase().includes(searchLower));
168
+
169
+ const sourceMatch = !srcFilter || c.source === srcFilter;
170
+ const typeMatch = !catFilter || c.event_type === catFilter;
171
+ const regionMatch = !regionFilter || (c.countries && c.countries.some(c => c.r_code === regionFilter));
172
+ const countryMatch = !countryFilter || (c.countries && c.countries.some(c => c.c_code === countryFilter));
173
+ const starredMatch = !showStarredOnly || c.starred === true;
174
+
175
+ return searchMatch && sourceMatch && typeMatch && regionMatch && countryMatch && starredMatch;
176
+ });
177
+ }, [captions, search, srcFilter, catFilter, regionFilter, countryFilter]);
178
+
179
+ return (
180
+ <PageContainer>
181
+ <Container
182
+ heading="Explore Examples"
183
+ headingLevel={2}
184
+ withHeaderBorder
185
+ withInternalPadding
186
+ className="max-w-7xl mx-auto"
187
+ >
188
+ <div className="space-y-6">
189
+ {/* Header Section */}
190
+ <div className="flex justify-between items-center">
191
+ <div>
192
+ <p className="text-gray-600 mt-1">Browse and search through uploaded crisis maps</p>
193
+ </div>
194
+ <div className="flex gap-2">
195
+ <Button
196
+ name="reference-examples"
197
+ variant={showStarredOnly ? "primary" : "secondary"}
198
+ onClick={() => setShowStarredOnly(!showStarredOnly)}
199
+ >
200
+ <StarLineIcon className="w-4 h-4" />
201
+ <span className="inline ml-2">Reference Examples</span>
202
+ </Button>
203
+ <Button
204
+ name="export"
205
+ variant="secondary"
206
+ onClick={() => {
207
+ const data = {
208
+ captions: captions,
209
+ filters: {
210
+ sources: sources,
211
+ types: types,
212
+ regions: regions,
213
+ countries: countries
214
+ },
215
+ timestamp: new Date().toISOString()
216
+ };
217
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
218
+ const url = URL.createObjectURL(blob);
219
+ const a = document.createElement('a');
220
+ a.href = url;
221
+ a.download = `promptaid-vision-captions-${new Date().toISOString().split('T')[0]}.json`;
222
+ document.body.appendChild(a);
223
+ a.click();
224
+ document.body.removeChild(a);
225
+ URL.revokeObjectURL(url);
226
+ }}
227
+ >
228
+ Export
229
+ </Button>
230
+ </div>
231
+ </div>
232
+
233
+ {/* Filters Bar */}
234
+ <Container heading="Search & Filters" headingLevel={3} withHeaderBorder withInternalPadding>
235
+ <div className="flex flex-wrap gap-4 items-center">
236
+ <TextInput
237
+ name="search"
238
+ placeholder="Search by filename, title…"
239
+ value={search}
240
+ onChange={(e) => setSearch(e || '')}
241
+ className="flex-1 min-w-[12rem]"
242
+ />
243
+
244
+ <SelectInput
245
+ name="source"
246
+ placeholder={isLoadingFilters ? "Loading..." : "All Sources"}
247
+ options={sources}
248
+ value={srcFilter || null}
249
+ onChange={(v) => setSrcFilter(v as string || '')}
250
+ keySelector={(o) => o.s_code}
251
+ labelSelector={(o) => o.label}
252
+ required={false}
253
+ disabled={isLoadingFilters}
254
+ />
255
+
256
+ <SelectInput
257
+ name="type"
258
+ placeholder={isLoadingFilters ? "Loading..." : "All Types"}
259
+ options={types}
260
+ value={catFilter || null}
261
+ onChange={(v) => setCatFilter(v as string || '')}
262
+ keySelector={(o) => o.t_code}
263
+ labelSelector={(o) => o.label}
264
+ required={false}
265
+ disabled={isLoadingFilters}
266
+ />
267
+
268
+ <SelectInput
269
+ name="region"
270
+ placeholder={isLoadingFilters ? "Loading..." : "All Regions"}
271
+ options={regions}
272
+ value={regionFilter || null}
273
+ onChange={(v) => setRegionFilter(v as string || '')}
274
+ keySelector={(o) => o.r_code}
275
+ labelSelector={(o) => o.label}
276
+ required={false}
277
+ disabled={isLoadingFilters}
278
+ />
279
+
280
+ <MultiSelectInput
281
+ name="country"
282
+ placeholder={isLoadingFilters ? "Loading..." : "All Countries"}
283
+ options={countries}
284
+ value={countryFilter ? [countryFilter] : []}
285
+ onChange={(v) => setCountryFilter((v as string[])[0] || '')}
286
+ keySelector={(o) => o.c_code}
287
+ labelSelector={(o) => o.label}
288
+ disabled={isLoadingFilters}
289
+ />
290
+ </div>
291
+ </Container>
292
+
293
+ {/* Results Section */}
294
+ <Container heading="Results" headingLevel={3} withHeaderBorder withInternalPadding>
295
+ <div className="space-y-4">
296
+ <div className="flex justify-between items-center">
297
+ <p className="text-sm text-gray-600">
298
+ {filtered.length} of {captions.length} examples
299
+ </p>
300
+ </div>
301
+
302
+ {/* List */}
303
+ <div className="space-y-4">
304
+ {filtered.map(c => (
305
+ <div key={c.cap_id} className={styles.mapItem} onClick={() => navigate(`/map/${c.image_id}?captionId=${c.cap_id}`)}>
306
+ <div className={styles.mapItemImage} style={{ width: '120px', height: '80px' }}>
307
+ {c.image_url ? (
308
+ <img
309
+ src={c.image_url}
310
+ alt={c.file_key}
311
+ onError={(e) => {
312
+ const target = e.target as HTMLImageElement;
313
+ target.style.display = 'none';
314
+ target.parentElement!.innerHTML = 'Img';
315
+ }}
316
+ />
317
+ ) : (
318
+ 'Img'
319
+ )}
320
+ </div>
321
+ <div className={styles.mapItemContent}>
322
+ <h3 className={styles.mapItemTitle}>
323
+ {c.title}
324
+ </h3>
325
+ <div className={styles.mapItemMetadata}>
326
+ <div className={styles.metadataTags}>
327
+ <span className={styles.metadataTagSource}>
328
+ {c.source}
329
+ </span>
330
+ <span className={styles.metadataTagType}>
331
+ {c.event_type}
332
+ </span>
333
+ <span className={styles.metadataTag}>
334
+ {c.epsg}
335
+ </span>
336
+ <span className={styles.metadataTag}>
337
+ {c.image_type}
338
+ </span>
339
+ </div>
340
+ </div>
341
+ </div>
342
+ </div>
343
+ ))}
344
+
345
+ {!filtered.length && (
346
+ <div className="text-center py-12">
347
+ <p className="text-gray-500">No examples found.</p>
348
+ </div>
349
+ )}
350
+ </div>
351
+ </div>
352
+ </Container>
353
+ </div>
354
+ </Container>
355
+ </PageContainer>
356
+ );
357
+ }
frontend/src/pages/ExplorePage/index.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export { default } from './ExplorePage';
frontend/src/pages/MapDetailsPage/MapDetailPage.module.css ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .backButton {
2
+ margin-bottom: var(--go-ui-spacing-lg);
3
+ }
4
+
5
+ .imageContainer {
6
+ background-color: var(--go-ui-color-gray-20);
7
+ border-radius: var(--go-ui-border-radius-lg);
8
+ overflow: hidden;
9
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
10
+ box-shadow: var(--go-ui-box-shadow-sm);
11
+ transition: box-shadow var(--go-ui-duration-transition-medium) ease;
12
+ }
13
+
14
+ .imageContainer:hover {
15
+ box-shadow: var(--go-ui-box-shadow-md);
16
+ }
17
+
18
+ .imageContainer img {
19
+ width: 100%;
20
+ height: auto;
21
+ object-fit: contain;
22
+ image-rendering: pixelated;
23
+ display: block;
24
+ }
25
+
26
+ .imagePlaceholder {
27
+ width: 100%;
28
+ height: 16rem;
29
+ background-color: var(--go-ui-color-gray-30);
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ color: var(--go-ui-color-gray-60);
34
+ font-size: var(--go-ui-font-size-sm);
35
+ font-weight: var(--go-ui-font-weight-medium);
36
+ }
37
+
38
+ .metadataTags {
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ gap: var(--go-ui-spacing-sm);
42
+ }
43
+
44
+ .metadataTag {
45
+ padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
46
+ background-color: var(--go-ui-color-red-10);
47
+ color: var(--go-ui-color-red-90);
48
+ font-size: var(--go-ui-font-size-sm);
49
+ border-radius: var(--go-ui-border-radius-md);
50
+ font-weight: var(--go-ui-font-weight-medium);
51
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-20);
52
+ transition: all var(--go-ui-duration-transition-fast) ease;
53
+ }
54
+
55
+ .metadataTag:hover {
56
+ background-color: var(--go-ui-color-red-20);
57
+ transform: translateY(-1px);
58
+ box-shadow: var(--go-ui-box-shadow-xs);
59
+ }
60
+
61
+ .captionContainer {
62
+ padding: var(--go-ui-spacing-md);
63
+ background-color: var(--go-ui-color-gray-10);
64
+ border-radius: var(--go-ui-border-radius-md);
65
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
66
+ }
67
+
68
+ .captionText {
69
+ margin-bottom: var(--go-ui-spacing-md);
70
+ line-height: 1.6;
71
+ color: var(--go-ui-color-gray-900);
72
+ }
73
+
74
+ .captionText:last-child {
75
+ margin-bottom: 0;
76
+ }
77
+
78
+ .highlightedCaption {
79
+ background-color: var(--go-ui-color-blue-10);
80
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-blue-30);
81
+ border-radius: var(--go-ui-border-radius-md);
82
+ padding: var(--go-ui-spacing-md);
83
+ margin: var(--go-ui-spacing-md) 0;
84
+ }
85
+
86
+ .captionHighlight {
87
+ margin-top: var(--go-ui-spacing-sm);
88
+ font-size: var(--go-ui-font-size-sm);
89
+ color: var(--go-ui-color-blue-70);
90
+ font-style: italic;
91
+ }
92
+
93
+ .contributeSection {
94
+ margin-top: var(--go-ui-spacing-2xl);
95
+ padding-top: var(--go-ui-spacing-lg);
96
+ border-top: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
97
+ display: flex;
98
+ justify-content: center;
99
+ }
100
+
101
+ .contributeButton {
102
+ background-color: var(--go-ui-color-red-90);
103
+ color: var(--go-ui-color-white);
104
+ padding: var(--go-ui-spacing-sm) var(--go-ui-spacing-xl);
105
+ border-radius: var(--go-ui-border-radius-lg);
106
+ font-weight: var(--go-ui-font-weight-medium);
107
+ transition: all var(--go-ui-duration-transition-medium) ease;
108
+ box-shadow: var(--go-ui-box-shadow-sm);
109
+ border: none;
110
+ cursor: pointer;
111
+ font-size: var(--go-ui-font-size-md);
112
+ }
113
+
114
+ .contributeButton:hover {
115
+ background-color: var(--go-ui-color-red-hover);
116
+ transform: translateY(-2px);
117
+ box-shadow: var(--go-ui-box-shadow-md);
118
+ }
119
+
120
+ .contributeButton:active {
121
+ transform: translateY(0);
122
+ box-shadow: var(--go-ui-box-shadow-sm);
123
+ }
124
+
125
+ .gridLayout {
126
+ display: grid;
127
+ grid-template-columns: 1fr;
128
+ gap: var(--go-ui-spacing-2xl);
129
+ }
130
+
131
+ @media (min-width: 1024px) {
132
+ .gridLayout {
133
+ grid-template-columns: 1fr 1fr;
134
+ }
135
+ }
136
+
137
+ .detailsSection {
138
+ display: flex;
139
+ flex-direction: column;
140
+ gap: var(--go-ui-spacing-lg);
141
+ }
142
+
143
+ .loadingContainer {
144
+ display: flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ min-height: 400px;
148
+ color: var(--go-ui-color-gray-60);
149
+ font-size: var(--go-ui-font-size-lg);
150
+ font-weight: var(--go-ui-font-weight-medium);
151
+ }
152
+
153
+ .errorContainer {
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: center;
157
+ min-height: 400px;
158
+ color: var(--go-ui-color-negative);
159
+ font-size: var(--go-ui-font-size-lg);
160
+ font-weight: var(--go-ui-font-weight-medium);
161
+ }
162
+
163
+
164
+
165
+ /* Responsive adjustments */
166
+ @media (max-width: 768px) {
167
+ .gridLayout {
168
+ gap: var(--go-ui-spacing-lg);
169
+ }
170
+
171
+ .metadataTags {
172
+ gap: var(--go-ui-spacing-xs);
173
+ }
174
+
175
+ .metadataTag {
176
+ font-size: var(--go-ui-font-size-xs);
177
+ padding: var(--go-ui-spacing-2xs) var(--go-ui-spacing-xs);
178
+ }
179
+
180
+ .contributeButton {
181
+ padding: var(--go-ui-spacing-sm) var(--go-ui-spacing-lg);
182
+ font-size: var(--go-ui-font-size-sm);
183
+ }
184
+ }
frontend/src/pages/{MapDetailPage.tsx → MapDetailsPage/MapDetailPage.tsx} RENAMED
@@ -1,29 +1,38 @@
1
- import { PageContainer, Button } from '@ifrc-go/ui';
2
  import { useState, useEffect } from 'react';
3
- import { useParams, useNavigate } from 'react-router-dom';
 
4
 
5
  interface MapOut {
6
  image_id: string;
7
  file_key: string;
8
  image_url: string;
9
  source: string;
10
- type: string;
11
  epsg: string;
12
  image_type: string;
13
- caption?: {
 
 
 
 
 
14
  title: string;
15
  generated: string;
16
  edited?: string;
17
- };
 
18
  }
19
 
20
  export default function MapDetailPage() {
21
  const { mapId } = useParams<{ mapId: string }>();
22
  const navigate = useNavigate();
 
 
23
  const [map, setMap] = useState<MapOut | null>(null);
24
  const [loading, setLoading] = useState(true);
25
  const [error, setError] = useState<string | null>(null);
26
- const [contributing, setContributing] = useState(false);
27
 
28
  useEffect(() => {
29
  if (!mapId) {
@@ -32,7 +41,6 @@ export default function MapDetailPage() {
32
  return;
33
  }
34
 
35
- // Fetch the specific map
36
  fetch(`/api/images/${mapId}`)
37
  .then(response => {
38
  if (!response.ok) {
@@ -50,50 +58,23 @@ export default function MapDetailPage() {
50
  });
51
  }, [mapId]);
52
 
53
- const handleContribute = async () => {
54
  if (!map) return;
55
 
56
- setContributing(true);
57
- try {
58
- // Simulate uploading the current image by creating a new map entry
59
- const formData = new FormData();
60
- formData.append('source', map.source);
61
- formData.append('type', map.type);
62
- formData.append('epsg', map.epsg);
63
- formData.append('image_type', map.image_type);
64
-
65
- // We'll need to fetch the image and create a file from it
66
- const imageResponse = await fetch(map.image_url);
67
- const imageBlob = await imageResponse.blob();
68
- const file = new File([imageBlob], map.file_key, { type: 'image/jpeg' });
69
- formData.append('file', file);
70
-
71
- const response = await fetch('/api/images/', {
72
- method: 'POST',
73
- body: formData,
74
- });
75
-
76
- if (!response.ok) {
77
- throw new Error('Failed to contribute image');
78
- }
79
-
80
- const result = await response.json();
81
-
82
- // Navigate to the upload page with the new map ID and step 2
83
- navigate(`/upload?mapId=${result.image_id}&step=2`);
84
- } catch (err) {
85
- console.error('Contribution failed:', err);
86
- alert('Failed to contribute image. Please try again.');
87
- } finally {
88
- setContributing(false);
89
- }
90
  };
91
 
92
  if (loading) {
93
  return (
94
  <PageContainer>
95
- <div className="flex items-center justify-center min-h-[400px]">
96
- <div className="text-gray-500">Loading...</div>
 
 
 
97
  </div>
98
  </PageContainer>
99
  );
@@ -102,8 +83,19 @@ export default function MapDetailPage() {
102
  if (error || !map) {
103
  return (
104
  <PageContainer>
105
- <div className="flex items-center justify-center min-h-[400px]">
106
- <div className="text-red-500">{error || 'Map not found'}</div>
 
 
 
 
 
 
 
 
 
 
 
107
  </div>
108
  </PageContainer>
109
  );
@@ -111,85 +103,121 @@ export default function MapDetailPage() {
111
 
112
  return (
113
  <PageContainer>
114
- <div className="mb-4">
115
  <Button
116
  name="back"
117
  variant="secondary"
118
  onClick={() => navigate('/explore')}
119
- className="mb-4"
120
  >
121
  ← Back to Explore
122
  </Button>
123
  </div>
124
 
125
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
126
  {/* Image Section */}
127
- <div className="space-y-4">
128
- <div className="bg-gray-100 rounded-lg overflow-hidden">
 
 
 
 
 
 
129
  {map.image_url ? (
130
  <img
131
  src={map.image_url}
132
  alt={map.file_key}
133
- className="w-full h-auto object-contain"
134
- style={{ imageRendering: 'pixelated' }}
135
  />
136
  ) : (
137
- <div className="w-full h-64 bg-gray-200 flex items-center justify-center text-gray-400">
138
  No image available
139
  </div>
140
  )}
141
  </div>
142
- </div>
143
 
144
  {/* Details Section */}
145
- <div className="space-y-6">
146
- <div>
147
- <h3 className="text-lg font-semibold mb-2">Title</h3>
148
- <div className="space-y-2 text-sm">
149
- <div className="text-gray-700">
150
- {map.caption?.title || '— no title —'}
151
- </div>
 
 
 
152
  </div>
153
- </div>
154
-
155
- <div>
156
- <h3 className="text-lg font-semibold mb-2">Metadata</h3>
157
- <div className="flex flex-wrap gap-2">
158
- <span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
 
 
 
 
 
159
  {map.source}
160
  </span>
161
- <span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
162
- {map.type}
163
  </span>
164
- <span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
165
  {map.epsg}
166
  </span>
167
- <span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
168
  {map.image_type}
169
  </span>
170
  </div>
171
- </div>
172
-
173
- <div>
174
- <h3 className="text-lg font-semibold mb-2">Generated Caption</h3>
175
- <div className="bg-gray-50 p-4 rounded-lg">
176
- <p className="mt-2 text-sm text-gray-700 line-clamp-2">
177
- {map.caption?.edited || map.caption?.generated || '— no caption yet —'}
178
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  </div>
180
- </div>
181
  </div>
182
  </div>
183
 
184
  {/* Contribute Section */}
185
- <div className="mt-8 pt-6 border-t border-gray-200 flex justify-center">
186
  <Button
187
  name="contribute"
188
  onClick={handleContribute}
189
- disabled={contributing}
190
- className="bg-ifrcRed hover:bg-ifrcRed/90 text-white px-6 py-2 rounded-lg"
191
  >
192
- {contributing ? 'Contributing...' : 'Contribute'}
193
  </Button>
194
  </div>
195
  </PageContainer>
 
1
+ import { PageContainer, Button, Container, Spinner } from '@ifrc-go/ui';
2
  import { useState, useEffect } from 'react';
3
+ import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
4
+ import styles from './MapDetailPage.module.css';
5
 
6
  interface MapOut {
7
  image_id: string;
8
  file_key: string;
9
  image_url: string;
10
  source: string;
11
+ event_type: string;
12
  epsg: string;
13
  image_type: string;
14
+ countries?: Array<{
15
+ c_code: string;
16
+ label: string;
17
+ r_code: string;
18
+ }>;
19
+ captions?: Array<{
20
  title: string;
21
  generated: string;
22
  edited?: string;
23
+ cap_id?: string;
24
+ }>;
25
  }
26
 
27
  export default function MapDetailPage() {
28
  const { mapId } = useParams<{ mapId: string }>();
29
  const navigate = useNavigate();
30
+ const [searchParams] = useSearchParams();
31
+ const captionId = searchParams.get('captionId');
32
  const [map, setMap] = useState<MapOut | null>(null);
33
  const [loading, setLoading] = useState(true);
34
  const [error, setError] = useState<string | null>(null);
35
+
36
 
37
  useEffect(() => {
38
  if (!mapId) {
 
41
  return;
42
  }
43
 
 
44
  fetch(`/api/images/${mapId}`)
45
  .then(response => {
46
  if (!response.ok) {
 
58
  });
59
  }, [mapId]);
60
 
61
+ const handleContribute = () => {
62
  if (!map) return;
63
 
64
+ const url = captionId ?
65
+ `/upload?mapId=${map.image_id}&step=2&captionId=${captionId}` :
66
+ `/upload?mapId=${map.image_id}&step=2`;
67
+ navigate(url);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  };
69
 
70
  if (loading) {
71
  return (
72
  <PageContainer>
73
+ <div className={styles.loadingContainer}>
74
+ <div className="flex flex-col items-center gap-4">
75
+ <Spinner className="text-ifrcRed" />
76
+ <div>Loading map details...</div>
77
+ </div>
78
  </div>
79
  </PageContainer>
80
  );
 
83
  if (error || !map) {
84
  return (
85
  <PageContainer>
86
+ <div className={styles.errorContainer}>
87
+ <div className="flex flex-col items-center gap-4 text-center">
88
+ <div className="text-4xl">⚠️</div>
89
+ <div className="text-xl font-semibold">Unable to load map</div>
90
+ <div>{error || 'Map not found'}</div>
91
+ <Button
92
+ name="back-to-explore"
93
+ variant="secondary"
94
+ onClick={() => navigate('/explore')}
95
+ >
96
+ Return to Explore
97
+ </Button>
98
+ </div>
99
  </div>
100
  </PageContainer>
101
  );
 
103
 
104
  return (
105
  <PageContainer>
106
+ <div className={styles.backButton}>
107
  <Button
108
  name="back"
109
  variant="secondary"
110
  onClick={() => navigate('/explore')}
 
111
  >
112
  ← Back to Explore
113
  </Button>
114
  </div>
115
 
116
+ <div className={styles.gridLayout}>
117
  {/* Image Section */}
118
+ <Container
119
+ heading="Map Image"
120
+ headingLevel={3}
121
+ withHeaderBorder
122
+ withInternalPadding
123
+ spacing="comfortable"
124
+ >
125
+ <div className={styles.imageContainer}>
126
  {map.image_url ? (
127
  <img
128
  src={map.image_url}
129
  alt={map.file_key}
 
 
130
  />
131
  ) : (
132
+ <div className={styles.imagePlaceholder}>
133
  No image available
134
  </div>
135
  )}
136
  </div>
137
+ </Container>
138
 
139
  {/* Details Section */}
140
+ <div className={styles.detailsSection}>
141
+ <Container
142
+ heading="Title"
143
+ headingLevel={3}
144
+ withHeaderBorder
145
+ withInternalPadding
146
+ spacing="comfortable"
147
+ >
148
+ <div className="text-gray-700">
149
+ {map.captions && map.captions.length > 0 ? map.captions[0].title : '— no title —'}
150
  </div>
151
+ </Container>
152
+
153
+ <Container
154
+ heading="Metadata"
155
+ headingLevel={3}
156
+ withHeaderBorder
157
+ withInternalPadding
158
+ spacing="comfortable"
159
+ >
160
+ <div className={styles.metadataTags}>
161
+ <span className={styles.metadataTag}>
162
  {map.source}
163
  </span>
164
+ <span className={styles.metadataTag}>
165
+ {map.event_type}
166
  </span>
167
+ <span className={styles.metadataTag}>
168
  {map.epsg}
169
  </span>
170
+ <span className={styles.metadataTag}>
171
  {map.image_type}
172
  </span>
173
  </div>
174
+ </Container>
175
+
176
+ <Container
177
+ heading="Generated Caption"
178
+ headingLevel={3}
179
+ withHeaderBorder
180
+ withInternalPadding
181
+ spacing="comfortable"
182
+ >
183
+ <div className={styles.captionContainer}>
184
+ {map.captions && map.captions.length > 0 ? (
185
+ map.captions.map((caption, index) => (
186
+ <div
187
+ key={index}
188
+ className={`${styles.captionText} ${
189
+ captionId && map.captions && map.captions[index] &&
190
+ 'cap_id' in map.captions[index] &&
191
+ map.captions[index].cap_id === captionId ?
192
+ styles.highlightedCaption : ''
193
+ }`}
194
+ >
195
+ <p>{caption.edited || caption.generated}</p>
196
+ {captionId && map.captions && map.captions[index] &&
197
+ 'cap_id' in map.captions[index] &&
198
+ map.captions[index].cap_id === captionId && (
199
+ <div className={styles.captionHighlight}>
200
+ ← This is the caption you selected
201
+ </div>
202
+ )}
203
+ </div>
204
+ ))
205
+ ) : (
206
+ <p>— no caption yet —</p>
207
+ )}
208
  </div>
209
+ </Container>
210
  </div>
211
  </div>
212
 
213
  {/* Contribute Section */}
214
+ <div className={styles.contributeSection}>
215
  <Button
216
  name="contribute"
217
  onClick={handleContribute}
218
+ className={styles.contributeButton}
 
219
  >
220
+ Contribute
221
  </Button>
222
  </div>
223
  </PageContainer>
frontend/src/pages/MapDetailsPage/index.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export { default } from './MapDetailPage';
frontend/src/pages/UploadPage/UploadPage.module.css ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .uploadContainer {
2
+ margin: 0 auto;
3
+ max-width: var(--go-ui-width-screen-lg);
4
+ text-align: center;
5
+ padding: var(--go-ui-spacing-lg) var(--go-ui-spacing-md) var(--go-ui-spacing-2xl) var(--go-ui-spacing-md);
6
+ overflow-x: hidden;
7
+ }
8
+
9
+ .dropZone {
10
+ border: var(--go-ui-width-separator-thick) dashed var(--go-ui-color-gray-40);
11
+ background-color: var(--go-ui-color-gray-20);
12
+ border-radius: var(--go-ui-border-radius-xl);
13
+ padding: var(--go-ui-spacing-2xl) var(--go-ui-spacing-lg);
14
+ display: flex;
15
+ flex-direction: column;
16
+ align-items: center;
17
+ gap: var(--go-ui-spacing-lg);
18
+ transition: all var(--go-ui-duration-transition-medium) ease;
19
+ max-width: var(--go-ui-width-screen-md);
20
+ margin: 0 auto;
21
+ min-height: 250px;
22
+ justify-content: center;
23
+ }
24
+
25
+ .dropZone:hover {
26
+ background-color: var(--go-ui-color-gray-30);
27
+ border-color: var(--go-ui-color-gray-50);
28
+ }
29
+
30
+ .dropZone.hasFile {
31
+ background-color: var(--go-ui-color-white);
32
+ border-color: var(--go-ui-color-gray-30);
33
+ min-height: 300px;
34
+ padding: var(--go-ui-spacing-lg);
35
+ }
36
+
37
+ .dropZoneIcon {
38
+ width: 2.5rem;
39
+ height: 2.5rem;
40
+ color: var(--go-ui-color-red-90);
41
+ }
42
+
43
+ .dropZoneText {
44
+ font-size: var(--go-ui-font-size-sm);
45
+ color: var(--go-ui-color-gray-70);
46
+ text-align: center;
47
+ }
48
+
49
+ .dropZoneSubtext {
50
+ font-size: var(--go-ui-font-size-sm);
51
+ color: var(--go-ui-color-gray-50);
52
+ margin: var(--go-ui-spacing-md) 0;
53
+ }
54
+
55
+ .filePreview {
56
+ width: 100%;
57
+ max-width: 100%;
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: center;
61
+ animation: fadeIn 0.3s ease-in-out;
62
+ }
63
+
64
+ .filePreviewImage {
65
+ position: relative;
66
+ max-width: 100%;
67
+ max-height: 20rem;
68
+ overflow: visible;
69
+ border-radius: var(--go-ui-border-radius-lg);
70
+ background-color: var(--go-ui-color-gray-20);
71
+ display: flex;
72
+ justify-content: center;
73
+ align-items: center;
74
+ padding: var(--go-ui-spacing-sm);
75
+ transition: all var(--go-ui-duration-transition-medium) ease;
76
+ }
77
+
78
+ .filePreviewImage:hover {
79
+ background-color: var(--go-ui-color-gray-30);
80
+ transform: translateY(-2px);
81
+ box-shadow: var(--go-ui-box-shadow-md);
82
+ }
83
+
84
+ .filePreviewImage img {
85
+ max-width: 100%;
86
+ max-height: 18rem;
87
+ width: auto;
88
+ height: auto;
89
+ object-fit: contain;
90
+ border-radius: var(--go-ui-border-radius-md);
91
+ box-shadow: var(--go-ui-box-shadow-sm);
92
+ transition: transform var(--go-ui-duration-transition-medium) ease;
93
+ }
94
+
95
+ .filePreviewImage img:hover {
96
+ transform: scale(1.02);
97
+ }
98
+
99
+ @keyframes fadeIn {
100
+ from {
101
+ opacity: 0;
102
+ transform: translateY(10px);
103
+ }
104
+ to {
105
+ opacity: 1;
106
+ transform: translateY(0);
107
+ }
108
+ }
109
+
110
+ .fileName {
111
+ font-size: var(--go-ui-font-size-sm);
112
+ font-weight: var(--go-ui-font-weight-medium);
113
+ color: var(--go-ui-color-gray-80);
114
+ margin-top: var(--go-ui-spacing-sm);
115
+ text-align: center;
116
+ }
117
+
118
+ .fileInfo {
119
+ font-size: var(--go-ui-font-size-xs);
120
+ color: var(--go-ui-color-gray-60);
121
+ margin-top: var(--go-ui-spacing-xs);
122
+ text-align: center;
123
+ }
124
+
125
+ .helpLink {
126
+ display: flex;
127
+ justify-content: center;
128
+ margin-top: var(--go-ui-spacing-md);
129
+ }
130
+
131
+ .helpLink a {
132
+ color: var(--go-ui-color-red-90);
133
+ font-size: var(--go-ui-font-size-xs);
134
+ transition: color var(--go-ui-duration-transition-fast) ease;
135
+ display: flex;
136
+ align-items: center;
137
+ gap: var(--go-ui-spacing-2xs);
138
+ }
139
+
140
+ .helpLink a:hover {
141
+ color: var(--go-ui-color-red-hover);
142
+ text-decoration: underline;
143
+ }
144
+
145
+ .loadingContainer {
146
+ display: flex;
147
+ flex-direction: column;
148
+ align-items: center;
149
+ gap: var(--go-ui-spacing-lg);
150
+ margin-top: var(--go-ui-spacing-2xl);
151
+ }
152
+
153
+ .loadingText {
154
+ color: var(--go-ui-color-gray-60);
155
+ }
156
+
157
+ .generateButtonContainer {
158
+ display: flex;
159
+ flex-direction: column;
160
+ align-items: center;
161
+ gap: var(--go-ui-spacing-lg);
162
+ margin-top: var(--go-ui-spacing-2xl);
163
+ }
164
+
165
+ .uploadedMapContainer {
166
+ margin-bottom: var(--go-ui-spacing-lg);
167
+ }
168
+
169
+ .uploadedMapImage {
170
+ width: 100%;
171
+ max-width: var(--go-ui-width-screen-lg);
172
+ max-height: 20rem;
173
+ overflow: visible;
174
+ background-color: var(--go-ui-color-gray-20);
175
+ border-radius: var(--go-ui-border-radius-lg);
176
+ box-shadow: var(--go-ui-box-shadow-sm);
177
+ display: flex;
178
+ justify-content: center;
179
+ align-items: center;
180
+ padding: var(--go-ui-spacing-sm);
181
+ transition: all var(--go-ui-duration-transition-medium) ease;
182
+ }
183
+
184
+ .uploadedMapImage:hover {
185
+ background-color: var(--go-ui-color-gray-30);
186
+ transform: translateY(-2px);
187
+ box-shadow: var(--go-ui-box-shadow-md);
188
+ }
189
+
190
+ .uploadedMapImage img {
191
+ max-width: 100%;
192
+ max-height: 18rem;
193
+ width: auto;
194
+ height: auto;
195
+ object-fit: contain;
196
+ border-radius: var(--go-ui-border-radius-md);
197
+ box-shadow: var(--go-ui-box-shadow-sm);
198
+ transition: transform var(--go-ui-duration-transition-medium) ease;
199
+ }
200
+
201
+ .uploadedMapImage img:hover {
202
+ transform: scale(1.02);
203
+ }
204
+
205
+ .formSection {
206
+ margin-bottom: var(--go-ui-spacing-lg);
207
+ }
208
+
209
+ .formGrid {
210
+ display: grid;
211
+ gap: var(--go-ui-spacing-lg);
212
+ grid-template-columns: 1fr;
213
+ text-align: left;
214
+ }
215
+
216
+ @media (min-width: 1024px) {
217
+ .formGrid {
218
+ grid-template-columns: 1fr 1fr;
219
+ }
220
+ }
221
+
222
+ .titleField {
223
+ grid-column: 1 / -1;
224
+ }
225
+
226
+ .ratingSection {
227
+ text-align: left;
228
+ }
229
+
230
+ .ratingDescription {
231
+ color: var(--go-ui-color-gray-70);
232
+ margin-bottom: var(--go-ui-spacing-lg);
233
+ }
234
+
235
+ .ratingSlider {
236
+ margin-top: var(--go-ui-spacing-lg);
237
+ display: flex;
238
+ align-items: center;
239
+ gap: var(--go-ui-spacing-sm);
240
+ }
241
+
242
+ .ratingLabel {
243
+ display: block;
244
+ font-size: var(--go-ui-font-size-sm);
245
+ font-weight: var(--go-ui-font-weight-medium);
246
+ text-transform: capitalize;
247
+ width: 5rem;
248
+ flex-shrink: 0;
249
+ }
250
+
251
+ .ratingInput {
252
+ width: 100%;
253
+ accent-color: var(--go-ui-color-red-90);
254
+ }
255
+
256
+ .ratingValue {
257
+ margin-left: var(--go-ui-spacing-sm);
258
+ width: 2.5rem;
259
+ text-align: right;
260
+ tabular-nums: true;
261
+ flex-shrink: 0;
262
+ font-size: var(--go-ui-font-size-sm);
263
+ color: var(--go-ui-color-gray-70);
264
+ }
265
+
266
+ .submitSection {
267
+ display: flex;
268
+ justify-content: center;
269
+ gap: var(--go-ui-spacing-md);
270
+ margin-top: var(--go-ui-spacing-2xl);
271
+ flex-wrap: wrap;
272
+ }
273
+
274
+ /* Success page styles */
275
+ .successContainer {
276
+ text-align: center;
277
+ padding: var(--go-ui-spacing-2xl);
278
+ }
279
+
280
+ .successHeading {
281
+ color: var(--go-ui-color-green-90);
282
+ margin-bottom: var(--go-ui-spacing-lg);
283
+ }
284
+
285
+ .successText {
286
+ color: var(--go-ui-color-gray-700);
287
+ margin-bottom: var(--go-ui-spacing-xl);
288
+ font-size: var(--go-ui-font-size-lg);
289
+ }
290
+
291
+ .successButton {
292
+ display: flex;
293
+ justify-content: center;
294
+ }
295
+
296
+ /* View Full Size Button */
297
+ .viewFullSizeButton {
298
+ display: flex;
299
+ justify-content: center;
300
+ margin-top: var(--go-ui-spacing-md);
301
+ padding-top: var(--go-ui-spacing-md);
302
+ border-top: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
303
+ }
304
+
305
+ /* Full Size Modal */
306
+ .fullSizeModalOverlay {
307
+ position: fixed;
308
+ top: 0;
309
+ left: 0;
310
+ right: 0;
311
+ bottom: 0;
312
+ background-color: rgba(0, 0, 0, 0.8);
313
+ display: flex;
314
+ justify-content: center;
315
+ align-items: center;
316
+ z-index: 1000;
317
+ padding: var(--go-ui-spacing-lg);
318
+ }
319
+
320
+ .fullSizeModalContent {
321
+ background-color: var(--go-ui-color-white);
322
+ border-radius: var(--go-ui-border-radius-lg);
323
+ max-width: 95vw;
324
+ max-height: 95vh;
325
+ overflow: hidden;
326
+ box-shadow: var(--go-ui-box-shadow-xl);
327
+ display: flex;
328
+ flex-direction: column;
329
+ }
330
+
331
+ .fullSizeModalHeader {
332
+ display: flex;
333
+ justify-content: space-between;
334
+ align-items: center;
335
+ padding: var(--go-ui-spacing-lg);
336
+ border-bottom: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
337
+ background-color: var(--go-ui-color-gray-10);
338
+ }
339
+
340
+ .fullSizeModalTitle {
341
+ margin: 0;
342
+ font-size: var(--go-ui-font-size-lg);
343
+ font-weight: var(--go-ui-font-weight-semibold);
344
+ color: var(--go-ui-color-gray-900);
345
+ }
346
+
347
+ .fullSizeModalImage {
348
+ flex: 1;
349
+ display: flex;
350
+ justify-content: center;
351
+ align-items: center;
352
+ padding: var(--go-ui-spacing-lg);
353
+ overflow: auto;
354
+ }
355
+
356
+ .fullSizeModalImage img {
357
+ max-width: 100%;
358
+ max-height: 100%;
359
+ object-fit: contain;
360
+ border-radius: var(--go-ui-border-radius-md);
361
+ box-shadow: var(--go-ui-box-shadow-md);
362
+ }
363
+
364
+ /* Responsive adjustments for modal */
365
+ @media (max-width: 768px) {
366
+ .fullSizeModalOverlay {
367
+ padding: var(--go-ui-spacing-sm);
368
+ }
369
+
370
+ .fullSizeModalContent {
371
+ max-width: 100vw;
372
+ max-height: 100vh;
373
+ }
374
+
375
+ .fullSizeModalHeader {
376
+ padding: var(--go-ui-spacing-md);
377
+ }
378
+
379
+ .fullSizeModalImage {
380
+ padding: var(--go-ui-spacing-md);
381
+ }
382
+ }
383
+
384
+ .confirmSection {
385
+ display: flex;
386
+ justify-content: center;
387
+ gap: var(--go-ui-spacing-md);
388
+ margin-top: var(--go-ui-spacing-xl);
389
+ padding-top: var(--go-ui-spacing-lg);
390
+ border-top: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
391
+ }
392
+
393
+ .step2Layout {
394
+ display: grid;
395
+ grid-template-columns: 1fr 1fr;
396
+ gap: var(--go-ui-spacing-2xl);
397
+ align-items: start;
398
+ }
399
+
400
+ .mapColumn {
401
+ position: sticky;
402
+ top: var(--go-ui-spacing-lg);
403
+ }
404
+
405
+ .contentColumn {
406
+ display: flex;
407
+ flex-direction: column;
408
+ gap: var(--go-ui-spacing-lg);
409
+ }
410
+
411
+ .contentColumn .formGrid {
412
+ display: grid;
413
+ gap: var(--go-ui-spacing-lg);
414
+ grid-template-columns: 1fr;
415
+ text-align: left;
416
+ }
417
+
418
+ /* Responsive adjustments */
419
+ @media (max-width: 1024px) {
420
+ .step2Layout {
421
+ grid-template-columns: 1fr;
422
+ gap: var(--go-ui-spacing-lg);
423
+ }
424
+
425
+ .mapColumn {
426
+ position: static;
427
+ }
428
+ }
429
+
430
+ @media (max-width: 768px) {
431
+ .uploadContainer {
432
+ padding: var(--go-ui-spacing-md) var(--go-ui-spacing-sm) var(--go-ui-spacing-xl) var(--go-ui-spacing-sm);
433
+ }
434
+
435
+ .dropZone {
436
+ padding: var(--go-ui-spacing-lg) var(--go-ui-spacing-md);
437
+ min-height: 200px;
438
+ }
439
+
440
+ .dropZone.hasFile {
441
+ min-height: 250px;
442
+ padding: var(--go-ui-spacing-md);
443
+ }
444
+
445
+ .filePreviewImage {
446
+ max-width: 100%;
447
+ max-height: 15rem;
448
+ padding: var(--go-ui-spacing-xs);
449
+ }
450
+
451
+ .filePreviewImage img {
452
+ max-height: 13rem;
453
+ }
454
+
455
+ .ratingSlider {
456
+ gap: var(--go-ui-spacing-xs);
457
+ }
458
+
459
+ .ratingLabel {
460
+ width: 4rem;
461
+ font-size: var(--go-ui-font-size-xs);
462
+ }
463
+
464
+ .ratingValue {
465
+ width: 2rem;
466
+ font-size: var(--go-ui-font-size-xs);
467
+ }
468
+ }
469
+
470
+ @media (max-width: 480px) {
471
+ .dropZone {
472
+ padding: var(--go-ui-spacing-md) var(--go-ui-spacing-sm);
473
+ min-height: 180px;
474
+ }
475
+
476
+ .dropZone.hasFile {
477
+ min-height: 220px;
478
+ }
479
+
480
+ .filePreviewImage {
481
+ max-height: 12rem;
482
+ }
483
+
484
+ .filePreviewImage img {
485
+ max-height: 10rem;
486
+ }
487
+ }
488
+
489
+ .metadataSectionCard {
490
+ background-color: var(--go-ui-color-white);
491
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
492
+ border-radius: var(--go-ui-border-radius-lg);
493
+ padding: var(--go-ui-spacing-lg);
494
+ box-shadow: var(--go-ui-box-shadow-xs);
495
+ }
frontend/src/pages/{UploadPage.tsx → UploadPage/UploadPage.tsx} RENAMED
@@ -10,48 +10,47 @@ import {
10
  DeleteBinLineIcon,
11
  } from '@ifrc-go/icons';
12
  import { Link, useSearchParams } from 'react-router-dom';
 
13
 
14
  const SELECTED_MODEL_KEY = 'selectedVlmModel';
15
 
16
  export default function UploadPage() {
17
  const [searchParams] = useSearchParams();
18
- const [step, setStep] = useState<1 | 2 | 3>(1);
19
  const [isLoading, setIsLoading] = useState(false);
20
  const stepRef = useRef(step);
21
  const uploadedImageIdRef = useRef<string | null>(null);
22
  const [preview, setPreview] = useState<string | null>(null);
23
- /* ---------------- local state ----------------- */
24
 
25
  const [file, setFile] = useState<File | null>(null);
26
  const [source, setSource] = useState('');
27
- const [type, setType] = useState('');
28
  const [epsg, setEpsg] = useState('');
29
  const [imageType, setImageType] = useState('');
30
  const [countries, setCountries] = useState<string[]>([]);
31
  const [title, setTitle] = useState('');
32
 
33
- // Metadata options from database
34
  const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
35
  const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
36
  const [spatialReferences, setSpatialReferences] = useState<{epsg: string, srid: string, proj4: string, wkt: string}[]>([]);
37
  const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
38
  const [countriesOptions, setCountriesOptions] = useState<{c_code: string, label: string, r_code: string}[]>([]);
39
 
40
- // Track uploaded image data for potential deletion
41
  const [uploadedImageId, setUploadedImageId] = useState<string | null>(null);
42
 
43
- // Keep refs updated with current values
44
  stepRef.current = step;
45
  uploadedImageIdRef.current = uploadedImageId;
46
 
47
- // Wrapper functions to handle OptionKey to string conversion
48
  const handleSourceChange = (value: any) => setSource(String(value));
49
- const handleTypeChange = (value: any) => setType(String(value));
50
  const handleEpsgChange = (value: any) => setEpsg(String(value));
51
  const handleImageTypeChange = (value: any) => setImageType(String(value));
52
  const handleCountriesChange = (value: any) => setCountries(Array.isArray(value) ? value.map(String) : []);
53
 
54
- // Fetch metadata options on component mount
 
 
 
55
  useEffect(() => {
56
  Promise.all([
57
  fetch('/api/sources').then(r => r.json()),
@@ -67,9 +66,8 @@ export default function UploadPage() {
67
  setImageTypes(imageTypesData);
68
  setCountriesOptions(countriesData);
69
 
70
- // Set default values
71
  if (sourcesData.length > 0) setSource(sourcesData[0].s_code);
72
- if (typesData.length > 0) setType(typesData[0].t_code);
73
  if (spatialData.length > 0) setEpsg(spatialData[0].epsg);
74
  if (imageTypesData.length > 0) setImageType(imageTypesData[0].image_type);
75
  });
@@ -95,44 +93,50 @@ export default function UploadPage() {
95
  const [imageUrl, setImageUrl] = useState<string|null>(null);
96
  const [draft, setDraft] = useState('');
97
 
98
- // Handle URL parameters for direct step 2 navigation
99
  useEffect(() => {
100
  const mapId = searchParams.get('mapId');
101
  const stepParam = searchParams.get('step');
 
102
 
103
  if (mapId && stepParam === '2') {
104
- // Load the map data and start at step 2
105
  fetch(`/api/images/${mapId}`)
106
  .then(response => response.json())
107
  .then(mapData => {
108
  setImageUrl(mapData.image_url);
109
  setSource(mapData.source);
110
- setType(mapData.type);
111
  setEpsg(mapData.epsg);
112
  setImageType(mapData.image_type);
113
 
114
- return fetch(`/api/images/${mapId}/caption`, {
115
- method: 'POST',
116
- headers: {
117
- 'Content-Type': 'application/x-www-form-urlencoded',
118
- },
119
- body: new URLSearchParams({
120
- title: 'Generated Caption',
121
- prompt: 'Describe this crisis map in detail',
122
- ...(localStorage.getItem(SELECTED_MODEL_KEY) && {
123
- model_name: localStorage.getItem(SELECTED_MODEL_KEY)!
124
- })
125
- })
126
- });
127
- })
128
- .then(capResponse => capResponse.json())
129
- .then(capData => {
130
- setCaptionId(capData.cap_id);
131
- setDraft(capData.edited || capData.generated);
132
- setStep(2);
 
 
 
 
 
 
 
 
133
  })
134
  .catch(err => {
135
- console.error('Failed to load map data:', err);
136
  alert('Failed to load map data. Please try again.');
137
  });
138
  }
@@ -155,9 +159,9 @@ export default function UploadPage() {
155
  usability: 50,
156
  });
157
 
 
158
 
159
 
160
- /* ---- drag-and-drop + file-picker handlers -------------------------- */
161
  const onDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
162
  e.preventDefault();
163
  const dropped = e.dataTransfer.files?.[0];
@@ -168,7 +172,6 @@ export default function UploadPage() {
168
  if (file) setFile(file);
169
  }, []);
170
 
171
- // blob URL / preview
172
  useEffect(() => {
173
  if (!file) {
174
  setPreview(null);
@@ -181,21 +184,20 @@ export default function UploadPage() {
181
 
182
 
183
  async function readJsonSafely(res: Response): Promise<any> {
184
- const text = await res.text(); // get raw body
185
  try {
186
- return text ? JSON.parse(text) : {}; // valid JSON or empty object
187
  } catch {
188
- return { error: text }; // plain text fallback
189
  }
190
  }
191
 
192
  function handleApiError(err: any, operation: string) {
193
- console.error(`${operation} failed:`, err);
194
  const message = err.message || `Failed to ${operation.toLowerCase()}`;
195
  alert(message);
196
  }
197
 
198
- /* ---- generate handler --------------------------------------------- */
199
  async function handleGenerate() {
200
  if (!file) return;
201
 
@@ -204,7 +206,7 @@ export default function UploadPage() {
204
  const fd = new FormData();
205
  fd.append('file', file);
206
  fd.append('source', source);
207
- fd.append('type', type);
208
  fd.append('epsg', epsg);
209
  fd.append('image_type', imageType);
210
  countries.forEach((c) => fd.append('countries', c));
@@ -215,7 +217,6 @@ export default function UploadPage() {
215
  }
216
 
217
  try {
218
- /* 1) upload */
219
  const mapRes = await fetch('/api/images/', { method: 'POST', body: fd });
220
  const mapJson = await readJsonSafely(mapRes);
221
  if (!mapRes.ok) throw new Error(mapJson.error || 'Upload failed');
@@ -225,7 +226,6 @@ export default function UploadPage() {
225
  if (!mapIdVal) throw new Error('Upload failed: image_id not found');
226
  setUploadedImageId(mapIdVal);
227
 
228
- /* 2) caption */
229
  const capRes = await fetch(
230
  `/api/images/${mapIdVal}/caption`,
231
  {
@@ -243,26 +243,20 @@ export default function UploadPage() {
243
  const capJson = await readJsonSafely(capRes);
244
  if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
245
  setCaptionId(capJson.cap_id);
246
- console.log(capJson);
247
 
248
- /* 3) Extract and apply metadata from AI response */
249
  const extractedMetadata = capJson.raw_json?.extracted_metadata;
250
  if (extractedMetadata) {
251
- console.log('Extracted metadata:', extractedMetadata);
252
-
253
- // Apply AI-extracted metadata to form fields
254
  if (extractedMetadata.title) setTitle(extractedMetadata.title);
255
  if (extractedMetadata.source) setSource(extractedMetadata.source);
256
- if (extractedMetadata.type) setType(extractedMetadata.type);
257
  if (extractedMetadata.epsg) setEpsg(extractedMetadata.epsg);
258
  if (extractedMetadata.countries && Array.isArray(extractedMetadata.countries)) {
259
  setCountries(extractedMetadata.countries);
260
  }
261
  }
262
 
263
- /* 4) continue workflow */
264
  setDraft(capJson.generated);
265
- setStep(2);
266
  } catch (err) {
267
  handleApiError(err, 'Upload');
268
  } finally {
@@ -270,15 +264,13 @@ export default function UploadPage() {
270
  }
271
  }
272
 
273
- /* ---- submit handler --------------------------------------------- */
274
  async function handleSubmit() {
275
  if (!captionId) return alert("No caption to submit");
276
 
277
  try {
278
- // 1. Update image metadata
279
  const metadataBody = {
280
  source: source,
281
- type: type,
282
  epsg: epsg,
283
  image_type: imageType,
284
  countries: countries,
@@ -291,10 +283,9 @@ export default function UploadPage() {
291
  const metadataJson = await readJsonSafely(metadataRes);
292
  if (!metadataRes.ok) throw new Error(metadataJson.error || "Metadata update failed");
293
 
294
- // 2. Update caption
295
  const captionBody = {
296
  title: title,
297
- edited: draft || '', // Use draft if available, otherwise empty string
298
  accuracy: scores.accuracy,
299
  context: scores.context,
300
  usability: scores.usability,
@@ -307,42 +298,114 @@ export default function UploadPage() {
307
  const captionJson = await readJsonSafely(captionRes);
308
  if (!captionRes.ok) throw new Error(captionJson.error || "Caption update failed");
309
 
310
- // Clear uploaded IDs since submission was successful
311
  setUploadedImageId(null);
312
- setStep(3);
313
  } catch (err) {
314
  handleApiError(err, 'Submit');
315
  }
316
  }
317
 
318
- /* ---- delete handler --------------------------------------------- */
319
  async function handleDelete() {
320
- if (!uploadedImageId) return;
 
 
 
 
321
 
322
- if (confirm("Are you sure you want to delete this uploaded image? This action cannot be undone.")) {
323
  try {
324
- // Delete the image (this will cascade delete the caption)
325
- const res = await fetch(`/api/images/${uploadedImageId}`, {
326
- method: "DELETE",
327
- });
 
 
 
328
 
329
- if (!res.ok) {
330
- const json = await readJsonSafely(res);
331
- throw new Error(json.error || "Delete failed");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  }
333
 
334
- // Reset to step 1
335
  resetToStep1();
336
  } catch (err) {
 
337
  handleApiError(err, 'Delete');
338
  }
339
  }
340
  }
341
 
342
- /* ------------------------------------------------------------------- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  return (
344
  <PageContainer>
345
- <div className="mx-auto max-w-screen-lg text-center px-4 sm:px-6 lg:px-8 py-6 sm:py-10 overflow-x-hidden" data-step={step}>
346
  {/* Drop-zone */}
347
  {step === 1 && (
348
  <Container
@@ -360,40 +423,40 @@ export default function UploadPage() {
360
  </p>
361
 
362
  {/* "More »" link */}
363
- <div className="flex justify-center">
364
  <Link
365
  to="/help"
366
- className="text-red-600 text-xs hover:text-red-700 hover:underline flex items-center gap-1"
367
  >
368
  More <ArrowRightLineIcon className="w-3 h-3" />
369
  </Link>
370
  </div>
371
 
372
  <div
373
- className={`border-2 border-dashed border-gray-300 bg-gray-50 rounded-xl py-8 sm:py-12 px-4 sm:px-8 flex flex-col items-center gap-4 sm:gap-6 hover:bg-gray-100 transition-colors max-w-sm sm:max-w-md lg:max-w-lg mx-auto min-h-[250px] sm:min-h-[300px] justify-center ${
374
- file ? 'bg-white' : ''
375
- }`}
376
  onDragOver={(e) => e.preventDefault()}
377
  onDrop={onDrop}
378
  >
379
  {file && preview ? (
380
- <div className="w-full max-w-full">
381
- <div className="relative w-1/2 mx-auto max-h-32 overflow-hidden rounded-lg bg-gray-100">
382
  <img
383
  src={preview}
384
  alt="File preview"
385
- className="w-full h-full object-contain"
386
  />
387
  </div>
388
- <p className="text-sm font-medium text-gray-800 mt-2 text-center">
389
  {file.name}
390
  </p>
 
 
 
391
  </div>
392
  ) : (
393
  <>
394
- <UploadCloudLineIcon className="w-10 h-10 text-ifrcRed" />
395
- <p className="text-sm text-gray-600">Drag &amp; Drop a file here</p>
396
- <p className="text-sm text-gray-500 my-4">or</p>
397
  </>
398
  )}
399
 
@@ -421,15 +484,15 @@ export default function UploadPage() {
421
 
422
  {/* Loading state */}
423
  {isLoading && (
424
- <div className="flex flex-col items-center justify-center gap-4 mt-12">
425
  <Spinner className="text-ifrcRed" />
426
- <p className="text-gray-600">Generating caption...</p>
427
  </div>
428
  )}
429
 
430
  {/* Generate button */}
431
  {step === 1 && !isLoading && (
432
- <div className="flex flex-col items-center justify-center gap-4 mt-12">
433
  <Button
434
  name="generate"
435
  disabled={!file}
@@ -440,171 +503,248 @@ export default function UploadPage() {
440
  </div>
441
  )}
442
 
443
- {step === 2 && imageUrl && (
444
- <Container
445
- heading="Uploaded Map"
446
- headingLevel={3}
447
- withHeaderBorder
448
- withInternalPadding
449
- >
450
- <div className="flex justify-center">
451
- <div className="w-full max-w-screen-lg max-h-80 overflow-hidden bg-red-50">
452
- <img
453
- src={preview || undefined}
454
- alt="Uploaded map preview"
455
- className="w-full h-full object-contain rounded shadow"
456
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  </div>
458
  </div>
459
- </Container>
460
  )}
461
 
462
- {step === 2 && (
463
- <div className="space-y-6">
464
- {/* ────── METADATA FORM ────── */}
465
- <Container
466
- heading="Map Metadata"
467
- headingLevel={3}
468
- withHeaderBorder
469
- withInternalPadding
470
- >
471
- <div className="grid gap-4 text-left grid-cols-1 lg:grid-cols-2">
472
- <div className="lg:col-span-2">
473
- <TextInput
474
- label="Title"
475
- name="title"
476
- value={title}
477
- onChange={(value) => setTitle(value || '')}
478
- placeholder="Enter a title for this map..."
479
- required
480
- />
481
- </div>
482
- <SelectInput
483
- label="Source"
484
- name="source"
485
- value={source}
486
- onChange={handleSourceChange}
487
- options={sources}
488
- keySelector={(o) => o.s_code}
489
- labelSelector={(o) => o.label}
490
- required
491
- />
492
- <SelectInput
493
- label="Type"
494
- name="type"
495
- value={type}
496
- onChange={handleTypeChange}
497
- options={types}
498
- keySelector={(o) => o.t_code}
499
- labelSelector={(o) => o.label}
500
- required
501
- />
502
- <SelectInput
503
- label="EPSG"
504
- name="epsg"
505
- value={epsg}
506
- onChange={handleEpsgChange}
507
- options={spatialReferences}
508
- keySelector={(o) => o.epsg}
509
- labelSelector={(o) => `${o.srid} (EPSG:${o.epsg})`}
510
- required
511
- />
512
- <SelectInput
513
- label="Image Type"
514
- name="image_type"
515
- value={imageType}
516
- onChange={handleImageTypeChange}
517
- options={imageTypes}
518
- keySelector={(o) => o.image_type}
519
- labelSelector={(o) => o.label}
520
- required
521
- />
522
- <MultiSelectInput
523
- label="Countries (optional)"
524
- name="countries"
525
- value={countries}
526
- onChange={handleCountriesChange}
527
- options={countriesOptions}
528
- keySelector={(o) => o.c_code}
529
- labelSelector={(o) => o.label}
530
- placeholder="Select one or more"
531
- />
532
  </div>
533
- </Container>
534
-
535
- {/* ────── RATING SLIDERS ────── */}
536
- <Container
537
- heading="AI Performance Rating"
538
- headingLevel={3}
539
- withHeaderBorder
540
- withInternalPadding
541
- >
542
- <div className="text-left">
543
- <p className="text-gray-700 mb-4">How well did the AI perform on the task?</p>
544
- {(['accuracy', 'context', 'usability'] as const).map((k) => (
545
- <div key={k} className="mt-6 flex items-center gap-2 sm:gap-4">
546
- <label className="block text-sm font-medium capitalize w-20 sm:w-28 flex-shrink-0">{k}</label>
547
- <input
548
- type="range"
549
- min={0}
550
- max={100}
551
- value={scores[k]}
552
- onChange={(e) =>
553
- setScores((s) => ({ ...s, [k]: Number(e.target.value) }))
554
- }
555
- className="w-full accent-ifrcRed"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  />
557
- <span className="ml-2 w-8 sm:w-10 text-right tabular-nums flex-shrink-0">{scores[k]}</span>
558
  </div>
559
- ))}
560
  </div>
561
- </Container>
562
-
563
- {/* ────── AI‑GENERATED CAPTION ────── */}
564
- <Container
565
- heading="AI‑Generated Caption"
566
- headingLevel={3}
567
- withHeaderBorder
568
- withInternalPadding
569
- >
570
- <div className="text-left">
571
- <TextArea
572
- name="caption"
573
- value={draft}
574
- onChange={(value) => setDraft(value || '')}
575
- rows={5}
576
- placeholder="AI-generated caption will appear here..."
577
- />
 
 
 
 
 
 
 
 
578
  </div>
579
- </Container>
580
-
581
- {/* ────── SUBMIT BUTTON ────── */}
582
- <div className="flex justify-center gap-4 mt-10">
583
- <IconButton
584
- name="delete"
585
- variant="tertiary"
586
- onClick={handleDelete}
587
- title="Delete"
588
- ariaLabel="Delete uploaded image"
589
- >
590
- <DeleteBinLineIcon />
591
- </IconButton>
592
- <Button
593
- name="submit"
594
- onClick={handleSubmit}
595
- >
596
- Submit
597
- </Button>
598
  </div>
599
  </div>
600
  )}
601
 
602
  {/* Success page */}
603
  {step === 3 && (
604
- <div className="text-center space-y-6">
605
- <Heading level={2}>Saved!</Heading>
606
- <p className="text-gray-700">Your caption has been successfully saved.</p>
607
- <div className="flex justify-center mt-6">
608
  <Button
609
  name="upload-another"
610
  onClick={resetToStep1}
@@ -614,6 +754,30 @@ export default function UploadPage() {
614
  </div>
615
  </div>
616
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
  </div>
618
  </PageContainer>
619
  );
 
10
  DeleteBinLineIcon,
11
  } from '@ifrc-go/icons';
12
  import { Link, useSearchParams } from 'react-router-dom';
13
+ import styles from './UploadPage.module.css';
14
 
15
  const SELECTED_MODEL_KEY = 'selectedVlmModel';
16
 
17
  export default function UploadPage() {
18
  const [searchParams] = useSearchParams();
19
+ const [step, setStep] = useState<1 | '2a' | '2b' | 3>(1);
20
  const [isLoading, setIsLoading] = useState(false);
21
  const stepRef = useRef(step);
22
  const uploadedImageIdRef = useRef<string | null>(null);
23
  const [preview, setPreview] = useState<string | null>(null);
 
24
 
25
  const [file, setFile] = useState<File | null>(null);
26
  const [source, setSource] = useState('');
27
+ const [eventType, setEventType] = useState('');
28
  const [epsg, setEpsg] = useState('');
29
  const [imageType, setImageType] = useState('');
30
  const [countries, setCountries] = useState<string[]>([]);
31
  const [title, setTitle] = useState('');
32
 
 
33
  const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
34
  const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
35
  const [spatialReferences, setSpatialReferences] = useState<{epsg: string, srid: string, proj4: string, wkt: string}[]>([]);
36
  const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
37
  const [countriesOptions, setCountriesOptions] = useState<{c_code: string, label: string, r_code: string}[]>([]);
38
 
 
39
  const [uploadedImageId, setUploadedImageId] = useState<string | null>(null);
40
 
 
41
  stepRef.current = step;
42
  uploadedImageIdRef.current = uploadedImageId;
43
 
 
44
  const handleSourceChange = (value: any) => setSource(String(value));
45
+ const handleEventTypeChange = (value: any) => setEventType(String(value));
46
  const handleEpsgChange = (value: any) => setEpsg(String(value));
47
  const handleImageTypeChange = (value: any) => setImageType(String(value));
48
  const handleCountriesChange = (value: any) => setCountries(Array.isArray(value) ? value.map(String) : []);
49
 
50
+ const handleStepChange = (newStep: 1 | '2a' | '2b' | 3) => {
51
+ setStep(newStep);
52
+ };
53
+
54
  useEffect(() => {
55
  Promise.all([
56
  fetch('/api/sources').then(r => r.json()),
 
66
  setImageTypes(imageTypesData);
67
  setCountriesOptions(countriesData);
68
 
 
69
  if (sourcesData.length > 0) setSource(sourcesData[0].s_code);
70
+ if (typesData.length > 0) setEventType(typesData[0].t_code);
71
  if (spatialData.length > 0) setEpsg(spatialData[0].epsg);
72
  if (imageTypesData.length > 0) setImageType(imageTypesData[0].image_type);
73
  });
 
93
  const [imageUrl, setImageUrl] = useState<string|null>(null);
94
  const [draft, setDraft] = useState('');
95
 
 
96
  useEffect(() => {
97
  const mapId = searchParams.get('mapId');
98
  const stepParam = searchParams.get('step');
99
+ const captionIdParam = searchParams.get('captionId');
100
 
101
  if (mapId && stepParam === '2') {
 
102
  fetch(`/api/images/${mapId}`)
103
  .then(response => response.json())
104
  .then(mapData => {
105
  setImageUrl(mapData.image_url);
106
  setSource(mapData.source);
107
+ setEventType(mapData.event_type);
108
  setEpsg(mapData.epsg);
109
  setImageType(mapData.image_type);
110
 
111
+ setUploadedImageId(mapId);
112
+
113
+ if (captionIdParam) {
114
+ setCaptionId(captionIdParam);
115
+ const existingCaption = mapData.captions?.find((c: any) => c.cap_id === captionIdParam);
116
+ if (existingCaption) {
117
+ setDraft(existingCaption.edited || existingCaption.generated);
118
+ setTitle(existingCaption.title || 'Generated Caption');
119
+ }
120
+
121
+ if (mapData.countries && Array.isArray(mapData.countries)) {
122
+ setCountries(mapData.countries.map((c: any) => c.c_code));
123
+ }
124
+
125
+ handleStepChange('2a');
126
+ } else {
127
+ setCaptionId(null);
128
+ setDraft('');
129
+ setTitle('');
130
+
131
+
132
+ if (mapData.countries && Array.isArray(mapData.countries)) {
133
+ setCountries(mapData.countries.map((c: any) => c.c_code));
134
+ }
135
+
136
+ handleStepChange('2a');
137
+ }
138
  })
139
  .catch(err => {
 
140
  alert('Failed to load map data. Please try again.');
141
  });
142
  }
 
159
  usability: 50,
160
  });
161
 
162
+ const [isFullSizeModalOpen, setIsFullSizeModalOpen] = useState(false);
163
 
164
 
 
165
  const onDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
166
  e.preventDefault();
167
  const dropped = e.dataTransfer.files?.[0];
 
172
  if (file) setFile(file);
173
  }, []);
174
 
 
175
  useEffect(() => {
176
  if (!file) {
177
  setPreview(null);
 
184
 
185
 
186
  async function readJsonSafely(res: Response): Promise<any> {
187
+ const text = await res.text();
188
  try {
189
+ return text ? JSON.parse(text) : {};
190
  } catch {
191
+ return { error: text };
192
  }
193
  }
194
 
195
  function handleApiError(err: any, operation: string) {
196
+
197
  const message = err.message || `Failed to ${operation.toLowerCase()}`;
198
  alert(message);
199
  }
200
 
 
201
  async function handleGenerate() {
202
  if (!file) return;
203
 
 
206
  const fd = new FormData();
207
  fd.append('file', file);
208
  fd.append('source', source);
209
+ fd.append('event_type', eventType);
210
  fd.append('epsg', epsg);
211
  fd.append('image_type', imageType);
212
  countries.forEach((c) => fd.append('countries', c));
 
217
  }
218
 
219
  try {
 
220
  const mapRes = await fetch('/api/images/', { method: 'POST', body: fd });
221
  const mapJson = await readJsonSafely(mapRes);
222
  if (!mapRes.ok) throw new Error(mapJson.error || 'Upload failed');
 
226
  if (!mapIdVal) throw new Error('Upload failed: image_id not found');
227
  setUploadedImageId(mapIdVal);
228
 
 
229
  const capRes = await fetch(
230
  `/api/images/${mapIdVal}/caption`,
231
  {
 
243
  const capJson = await readJsonSafely(capRes);
244
  if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
245
  setCaptionId(capJson.cap_id);
 
246
 
 
247
  const extractedMetadata = capJson.raw_json?.extracted_metadata;
248
  if (extractedMetadata) {
 
 
 
249
  if (extractedMetadata.title) setTitle(extractedMetadata.title);
250
  if (extractedMetadata.source) setSource(extractedMetadata.source);
251
+ if (extractedMetadata.type) setEventType(extractedMetadata.type);
252
  if (extractedMetadata.epsg) setEpsg(extractedMetadata.epsg);
253
  if (extractedMetadata.countries && Array.isArray(extractedMetadata.countries)) {
254
  setCountries(extractedMetadata.countries);
255
  }
256
  }
257
 
 
258
  setDraft(capJson.generated);
259
+ handleStepChange('2a');
260
  } catch (err) {
261
  handleApiError(err, 'Upload');
262
  } finally {
 
264
  }
265
  }
266
 
 
267
  async function handleSubmit() {
268
  if (!captionId) return alert("No caption to submit");
269
 
270
  try {
 
271
  const metadataBody = {
272
  source: source,
273
+ event_type: eventType,
274
  epsg: epsg,
275
  image_type: imageType,
276
  countries: countries,
 
283
  const metadataJson = await readJsonSafely(metadataRes);
284
  if (!metadataRes.ok) throw new Error(metadataJson.error || "Metadata update failed");
285
 
 
286
  const captionBody = {
287
  title: title,
288
+ edited: draft || '',
289
  accuracy: scores.accuracy,
290
  context: scores.context,
291
  usability: scores.usability,
 
298
  const captionJson = await readJsonSafely(captionRes);
299
  if (!captionRes.ok) throw new Error(captionJson.error || "Caption update failed");
300
 
 
301
  setUploadedImageId(null);
302
+ handleStepChange(3);
303
  } catch (err) {
304
  handleApiError(err, 'Submit');
305
  }
306
  }
307
 
 
308
  async function handleDelete() {
309
+ if (!uploadedImageId) {
310
+
311
+ alert('No caption to delete. Please try refreshing the page.');
312
+ return;
313
+ }
314
 
315
+ if (confirm("Are you sure you want to delete this caption? This action cannot be undone.")) {
316
  try {
317
+ const captionsResponse = await fetch(`/api/images/${uploadedImageId}/captions`);
318
+ let hasOtherCaptions = false;
319
+
320
+ if (captionsResponse.ok) {
321
+ const captions = await captionsResponse.json();
322
+ hasOtherCaptions = captions.some((cap: any) => cap.cap_id !== captionId);
323
+ }
324
 
325
+ if (hasOtherCaptions) {
326
+ if (captionId) {
327
+ const capRes = await fetch(`/api/captions/${captionId}`, {
328
+ method: "DELETE",
329
+ });
330
+ if (!capRes.ok) {
331
+ throw new Error('Failed to delete caption');
332
+ }
333
+ }
334
+ } else {
335
+ const res = await fetch(`/api/images/${uploadedImageId}`, {
336
+ method: "DELETE",
337
+ });
338
+
339
+ if (!res.ok) {
340
+ const json = await readJsonSafely(res);
341
+
342
+ throw new Error(json.error || `Delete failed with status ${res.status}`);
343
+ }
344
  }
345
 
 
346
  resetToStep1();
347
  } catch (err) {
348
+
349
  handleApiError(err, 'Delete');
350
  }
351
  }
352
  }
353
 
354
+ const handleProcessCaption = useCallback(async () => {
355
+ if (!uploadedImageId) {
356
+ alert('No image ID available to create a new caption.');
357
+ return;
358
+ }
359
+
360
+ setIsLoading(true);
361
+
362
+ try {
363
+ if (captionId) {
364
+ const captionBody = {
365
+ title: title,
366
+ edited: draft || '',
367
+ };
368
+ const captionRes = await fetch(`/api/captions/${captionId}`, {
369
+ method: "PUT",
370
+ headers: { "Content-Type": "application/json" },
371
+ body: JSON.stringify(captionBody),
372
+ });
373
+ if (!captionRes.ok) throw new Error('Failed to update caption');
374
+ } else {
375
+ const capRes = await fetch(
376
+ `/api/images/${uploadedImageId}/caption`,
377
+ {
378
+ method: 'POST',
379
+ headers: {
380
+ 'Content-Type': 'application/x-www-form-urlencoded',
381
+ },
382
+ body: new URLSearchParams({
383
+ title: 'New Contribution Caption',
384
+ prompt: 'Describe this crisis map in detail',
385
+ ...(localStorage.getItem(SELECTED_MODEL_KEY) && {
386
+ model_name: localStorage.getItem(SELECTED_MODEL_KEY)!
387
+ })
388
+ })
389
+ }
390
+ );
391
+ const capJson = await readJsonSafely(capRes);
392
+ if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
393
+
394
+ setCaptionId(capJson.cap_id);
395
+ setDraft(capJson.generated);
396
+ }
397
+
398
+ handleStepChange('2b');
399
+ } catch (err) {
400
+ handleApiError(err, 'Create New Caption');
401
+ } finally {
402
+ setIsLoading(false);
403
+ }
404
+ }, [uploadedImageId, title, captionId, draft]);
405
+
406
  return (
407
  <PageContainer>
408
+ <div className={styles.uploadContainer} data-step={step}>
409
  {/* Drop-zone */}
410
  {step === 1 && (
411
  <Container
 
423
  </p>
424
 
425
  {/* "More »" link */}
426
+ <div className={styles.helpLink}>
427
  <Link
428
  to="/help"
429
+ className={styles.helpLink}
430
  >
431
  More <ArrowRightLineIcon className="w-3 h-3" />
432
  </Link>
433
  </div>
434
 
435
  <div
436
+ className={`${styles.dropZone} ${file ? styles.hasFile : ''}`}
 
 
437
  onDragOver={(e) => e.preventDefault()}
438
  onDrop={onDrop}
439
  >
440
  {file && preview ? (
441
+ <div className={styles.filePreview}>
442
+ <div className={styles.filePreviewImage}>
443
  <img
444
  src={preview}
445
  alt="File preview"
 
446
  />
447
  </div>
448
+ <p className={styles.fileName}>
449
  {file.name}
450
  </p>
451
+ <p className={styles.fileInfo}>
452
+ {(file.size / 1024 / 1024).toFixed(2)} MB
453
+ </p>
454
  </div>
455
  ) : (
456
  <>
457
+ <UploadCloudLineIcon className={styles.dropZoneIcon} />
458
+ <p className={styles.dropZoneText}>Drag &amp; Drop a file here</p>
459
+ <p className={styles.dropZoneSubtext}>or</p>
460
  </>
461
  )}
462
 
 
484
 
485
  {/* Loading state */}
486
  {isLoading && (
487
+ <div className={styles.loadingContainer}>
488
  <Spinner className="text-ifrcRed" />
489
+ <p className={styles.loadingText}>Generating caption...</p>
490
  </div>
491
  )}
492
 
493
  {/* Generate button */}
494
  {step === 1 && !isLoading && (
495
+ <div className={styles.generateButtonContainer}>
496
  <Button
497
  name="generate"
498
  disabled={!file}
 
503
  </div>
504
  )}
505
 
506
+ {step === '2a' && (
507
+ <div className={styles.step2Layout}>
508
+ {/* Left Column - Map */}
509
+ <div className={styles.mapColumn}>
510
+ <Container heading="Uploaded Image" headingLevel={3} withHeaderBorder withInternalPadding>
511
+ <div className={styles.uploadedMapContainer}>
512
+ <div className={styles.uploadedMapImage}>
513
+ <img
514
+ src={imageUrl || preview || undefined}
515
+ alt="Uploaded image preview"
516
+ />
517
+ </div>
518
+ <div className={styles.viewFullSizeButton}>
519
+ <Button
520
+ name="view-full-size"
521
+ variant="secondary"
522
+ size={1}
523
+ onClick={() => setIsFullSizeModalOpen(true)}
524
+ >
525
+ View Image
526
+ </Button>
527
+ </div>
528
+ </div>
529
+ </Container>
530
+ </div>
531
+
532
+ {/* Right Column - Metadata Form */}
533
+ <div className={styles.contentColumn}>
534
+ <div className={styles.metadataSectionCard}>
535
+ <Container
536
+ heading="Confirm image details"
537
+ headingLevel={3}
538
+ withHeaderBorder
539
+ withInternalPadding
540
+ >
541
+ <div className={styles.formGrid}>
542
+ <div className={styles.titleField}>
543
+ <TextInput
544
+ label="Title"
545
+ name="title"
546
+ value={title}
547
+ onChange={(value) => setTitle(value || '')}
548
+ placeholder="Enter a title for this map..."
549
+ required
550
+ />
551
+ </div>
552
+ <SelectInput
553
+ label="Source"
554
+ name="source"
555
+ value={source}
556
+ onChange={handleSourceChange}
557
+ options={sources}
558
+ keySelector={(o) => o.s_code}
559
+ labelSelector={(o) => o.label}
560
+ required
561
+ />
562
+ <SelectInput
563
+ label="Event Type"
564
+ name="event_type"
565
+ value={eventType}
566
+ onChange={handleEventTypeChange}
567
+ options={types}
568
+ keySelector={(o) => o.t_code}
569
+ labelSelector={(o) => o.label}
570
+ required
571
+ />
572
+ <SelectInput
573
+ label="EPSG"
574
+ name="epsg"
575
+ value={epsg}
576
+ onChange={handleEpsgChange}
577
+ options={spatialReferences}
578
+ keySelector={(o) => o.epsg}
579
+ labelSelector={(o) => `${o.srid} (EPSG:${o.epsg})`}
580
+ required
581
+ />
582
+ <SelectInput
583
+ label="Image Type"
584
+ name="image_type"
585
+ value={imageType}
586
+ onChange={handleImageTypeChange}
587
+ options={imageTypes}
588
+ keySelector={(o) => o.image_type}
589
+ labelSelector={(o) => o.label}
590
+ required
591
+ />
592
+ <MultiSelectInput
593
+ label="Countries (optional)"
594
+ name="countries"
595
+ value={countries}
596
+ onChange={handleCountriesChange}
597
+ options={countriesOptions}
598
+ keySelector={(o) => o.c_code}
599
+ labelSelector={(o) => o.label}
600
+ placeholder="Select one or more"
601
+ />
602
+ </div>
603
+ <div className={styles.confirmSection}>
604
+ <IconButton
605
+ name="delete"
606
+ variant="tertiary"
607
+ onClick={handleDelete}
608
+ title="Delete"
609
+ ariaLabel="Delete uploaded image"
610
+ >
611
+ <DeleteBinLineIcon />
612
+ </IconButton>
613
+ <Button
614
+ name="confirm-metadata"
615
+ onClick={() => {
616
+ if (imageUrl && !file) {
617
+ handleProcessCaption();
618
+ } else {
619
+ handleStepChange('2b');
620
+ }
621
+ }}
622
+ >
623
+ {imageUrl && !file ?
624
+ (captionId ? 'Edit Caption' : 'Create New Caption') :
625
+ 'Next'
626
+ }
627
+ </Button>
628
+ </div>
629
+ </Container>
630
  </div>
631
  </div>
632
+ </div>
633
  )}
634
 
635
+ {step === '2b' && (
636
+ <div className={styles.step2Layout}>
637
+ {/* Left Column - Map */}
638
+ <div className={styles.mapColumn}>
639
+ <Container heading="Uploaded Image" headingLevel={3} withHeaderBorder withInternalPadding>
640
+ <div className={styles.uploadedMapContainer}>
641
+ <div className={styles.uploadedMapImage}>
642
+ <img
643
+ src={imageUrl || preview || undefined}
644
+ alt="Uploaded image preview"
645
+ />
646
+ </div>
647
+ <div className={styles.viewFullSizeButton}>
648
+ <Button
649
+ name="view-full-size"
650
+ variant="secondary"
651
+ size={1}
652
+ onClick={() => setIsFullSizeModalOpen(true)}
653
+ >
654
+ View Image
655
+ </Button>
656
+ </div>
657
+ </div>
658
+ </Container>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  </div>
660
+
661
+ {/* Right Column - Content */}
662
+ <div className={styles.contentColumn}>
663
+ {/* ────── RATING SLIDERS ────── */}
664
+ <div className={styles.metadataSectionCard}>
665
+ <Container
666
+ heading="AI Performance Rating"
667
+ headingLevel={3}
668
+ withHeaderBorder
669
+ withInternalPadding
670
+ >
671
+ <div className={styles.ratingSection}>
672
+ <p className={styles.ratingDescription}>How well did the AI perform on the task?</p>
673
+ {(['accuracy', 'context', 'usability'] as const).map((k) => (
674
+ <div key={k} className={styles.ratingSlider}>
675
+ <label className={styles.ratingLabel}>{k}</label>
676
+ <input
677
+ type="range"
678
+ min={0}
679
+ max={100}
680
+ value={scores[k]}
681
+ onChange={(e) =>
682
+ setScores((s) => ({ ...s, [k]: Number(e.target.value) }))
683
+ }
684
+ className={styles.ratingInput}
685
+ />
686
+ <span className={styles.ratingValue}>{scores[k]}</span>
687
+ </div>
688
+ ))}
689
+ </div>
690
+ </Container>
691
+ </div>
692
+
693
+ {/* ────── AI‑GENERATED CAPTION ────── */}
694
+ <div className={styles.metadataSectionCard}>
695
+ <Container
696
+ heading="AI‑Generated Caption"
697
+ headingLevel={3}
698
+ withHeaderBorder
699
+ withInternalPadding
700
+ >
701
+ <div className="text-left">
702
+ <TextArea
703
+ name="caption"
704
+ value={draft}
705
+ onChange={(value) => setDraft(value || '')}
706
+ rows={5}
707
+ placeholder="AI-generated caption will appear here..."
708
  />
 
709
  </div>
710
+ </Container>
711
  </div>
712
+
713
+ {/* ────── SUBMIT BUTTON ────── */}
714
+ <div className={styles.submitSection}>
715
+ <Button
716
+ name="back"
717
+ variant="secondary"
718
+ onClick={() => handleStepChange('2a')}
719
+ >
720
+ ← Back to Metadata
721
+ </Button>
722
+ <IconButton
723
+ name="delete"
724
+ variant="tertiary"
725
+ onClick={handleDelete}
726
+ title="Delete"
727
+ ariaLabel="Delete uploaded image"
728
+ >
729
+ <DeleteBinLineIcon />
730
+ </IconButton>
731
+ <Button
732
+ name="submit"
733
+ onClick={handleSubmit}
734
+ >
735
+ Submit
736
+ </Button>
737
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
738
  </div>
739
  </div>
740
  )}
741
 
742
  {/* Success page */}
743
  {step === 3 && (
744
+ <div className={styles.successContainer}>
745
+ <Heading level={2} className={styles.successHeading}>Saved!</Heading>
746
+ <p className={styles.successText}>Your caption has been successfully saved.</p>
747
+ <div className={styles.successButton}>
748
  <Button
749
  name="upload-another"
750
  onClick={resetToStep1}
 
754
  </div>
755
  </div>
756
  )}
757
+
758
+ {/* Full Size Image Modal */}
759
+ {isFullSizeModalOpen && (
760
+ <div className={styles.fullSizeModalOverlay} onClick={() => setIsFullSizeModalOpen(false)}>
761
+ <div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
762
+ <div className={styles.fullSizeModalHeader}>
763
+ <Button
764
+ name="close-modal"
765
+ variant="tertiary"
766
+ size={1}
767
+ onClick={() => setIsFullSizeModalOpen(false)}
768
+ >
769
+
770
+ </Button>
771
+ </div>
772
+ <div className={styles.fullSizeModalImage}>
773
+ <img
774
+ src={imageUrl || preview || undefined}
775
+ alt="Full size map"
776
+ />
777
+ </div>
778
+ </div>
779
+ </div>
780
+ )}
781
  </div>
782
  </PageContainer>
783
  );
frontend/src/pages/UploadPage/index.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export { default } from './UploadPage';
frontend/src/types.ts CHANGED
@@ -1,13 +1,13 @@
1
  export interface MapOut {
2
- map_id: string; // UUID as string
3
  file_key: string;
4
  sha256: string;
5
  source: string;
6
  region: string;
7
  category: string;
8
  caption?: {
9
- cap_id: string; // UUID as string
10
- map_id: string; // UUID as string
11
  generated: string;
12
  edited?: string;
13
  accuracy?: number;
 
1
  export interface MapOut {
2
+ map_id: string;
3
  file_key: string;
4
  sha256: string;
5
  source: string;
6
  region: string;
7
  category: string;
8
  caption?: {
9
+ cap_id: string;
10
+ map_id: string;
11
  generated: string;
12
  edited?: string;
13
  accuracy?: number;
frontend/tailwind.config.js CHANGED
@@ -1,4 +1,3 @@
1
- /** @type {import('tailwindcss').Config} */
2
  export default {
3
  content: [
4
  "./index.html",
 
 
1
  export default {
2
  content: [
3
  "./index.html",
frontend/tsconfig.app.json CHANGED
@@ -6,16 +6,12 @@
6
  "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
  "module": "ESNext",
8
  "skipLibCheck": true,
9
-
10
- /* Bundler mode */
11
  "moduleResolution": "bundler",
12
  "allowImportingTsExtensions": true,
13
  "verbatimModuleSyntax": true,
14
  "moduleDetection": "force",
15
  "noEmit": true,
16
  "jsx": "react-jsx",
17
-
18
- /* Linting */
19
  "strict": true,
20
  "noUnusedLocals": true,
21
  "noUnusedParameters": true,
 
6
  "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
  "module": "ESNext",
8
  "skipLibCheck": true,
 
 
9
  "moduleResolution": "bundler",
10
  "allowImportingTsExtensions": true,
11
  "verbatimModuleSyntax": true,
12
  "moduleDetection": "force",
13
  "noEmit": true,
14
  "jsx": "react-jsx",
 
 
15
  "strict": true,
16
  "noUnusedLocals": true,
17
  "noUnusedParameters": true,
frontend/tsconfig.node.json CHANGED
@@ -5,15 +5,11 @@
5
  "lib": ["ES2023"],
6
  "module": "ESNext",
7
  "skipLibCheck": true,
8
-
9
- /* Bundler mode */
10
  "moduleResolution": "bundler",
11
  "allowImportingTsExtensions": true,
12
  "verbatimModuleSyntax": true,
13
  "moduleDetection": "force",
14
  "noEmit": true,
15
-
16
- /* Linting */
17
  "strict": true,
18
  "noUnusedLocals": true,
19
  "noUnusedParameters": true,
 
5
  "lib": ["ES2023"],
6
  "module": "ESNext",
7
  "skipLibCheck": true,
 
 
8
  "moduleResolution": "bundler",
9
  "allowImportingTsExtensions": true,
10
  "verbatimModuleSyntax": true,
11
  "moduleDetection": "force",
12
  "noEmit": true,
 
 
13
  "strict": true,
14
  "noUnusedLocals": true,
15
  "noUnusedParameters": true,
frontend/vite.config.ts CHANGED
@@ -1,4 +1,3 @@
1
- // vite.config.ts
2
  import { defineConfig } from 'vite'
3
  import react from '@vitejs/plugin-react'
4
 
@@ -6,12 +5,10 @@ export default defineConfig({
6
  plugins: [react()],
7
  server: {
8
  proxy: {
9
- // proxy any /api/* request to localhost:8080
10
  '/api': {
11
  target: 'http://localhost:8080',
12
  changeOrigin: true,
13
  secure: false,
14
- // rewrite: (path) => path.replace(/^\/api/, '/api'), // not needed if same prefix
15
  },
16
  },
17
  },
 
 
1
  import { defineConfig } from 'vite'
2
  import react from '@vitejs/plugin-react'
3
 
 
5
  plugins: [react()],
6
  server: {
7
  proxy: {
 
8
  '/api': {
9
  target: 'http://localhost:8080',
10
  changeOrigin: true,
11
  secure: false,
 
12
  },
13
  },
14
  },
go-web-app-develop/.changeset/README.md DELETED
@@ -1,8 +0,0 @@
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 DELETED
@@ -1,15 +0,0 @@
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 DELETED
@@ -1,5 +0,0 @@
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 DELETED
@@ -1,15 +0,0 @@
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 DELETED
@@ -1,8 +0,0 @@
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 DELETED
@@ -1,9 +0,0 @@
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 DELETED
@@ -1,7 +0,0 @@
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 DELETED
@@ -1,148 +0,0 @@
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 DELETED
@@ -1,92 +0,0 @@
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 DELETED
@@ -1,39 +0,0 @@
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 DELETED
@@ -1,37 +0,0 @@
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 DELETED
@@ -1,5 +0,0 @@
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 DELETED
@@ -1,27 +0,0 @@
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 DELETED
@@ -1,30 +0,0 @@
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 DELETED
@@ -1,16 +0,0 @@
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 DELETED
@@ -1,127 +0,0 @@
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 DELETED
@@ -1,304 +0,0 @@
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 DELETED
@@ -1,147 +0,0 @@
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 DELETED
@@ -1,127 +0,0 @@
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 DELETED
@@ -1,43 +0,0 @@
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 DELETED
@@ -1 +0,0 @@
1
- enable-pre-post-scripts=true
 
 
go-web-app-develop/COLLABORATING.md DELETED
@@ -1,18 +0,0 @@
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 DELETED
@@ -1,81 +0,0 @@
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).