diff --git a/docker-compose.yml b/docker-compose.yml index 02f9503d4a563a8b9936156230544704f7250714..5240fac3b65512fad50832efb7220aec2d62aad4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,8 +32,8 @@ services: MINIO_ROOT_USER: promptaid MINIO_ROOT_PASSWORD: promptaid ports: - - "9000:9000" # S3 API - - "9001:9001" # web console + - "9000:9000" + - "9001:9001" volumes: - minio_data:/data depends_on: diff --git a/frontend/src/App.css b/frontend/src/App.css index 1aced9c0da719bd6635b9b2220b6f5bced3b0b81..1b5b820a49ca8f63202f775375f55bf4dc66e83b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -17,7 +17,6 @@ html, body { transform-origin: top center; } -/* Responsive adjustments for different screen sizes */ @media (min-width: 640px) { #root { padding: 1.5rem; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2b6f079c008349a90185eeb1d1b054cbe20c0ab2..5d4b10e425942edb366588c90a0cf2ea11e94893 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,13 +7,13 @@ import UploadPage from './pages/UploadPage'; import AnalyticsPage from './pages/AnalyticsPage'; import ExplorePage from './pages/ExplorePage'; import HelpPage from './pages/HelpPage'; -import MapDetailPage from './pages/MapDetailPage'; +import MapDetailPage from './pages/MapDetailsPage'; import DemoPage from './pages/DemoPage'; import DevPage from './pages/DevPage'; const router = createBrowserRouter([ { - element: , // header sticks here + element: , children: [ { path: '/', element: }, { path: '/upload', element: }, @@ -28,7 +28,6 @@ const router = createBrowserRouter([ ]); function Application() { - // ALERTS const [alerts, setAlerts] = useState([]); const addAlert = useCallback((alert: AlertParams) => { @@ -79,7 +78,6 @@ function Application() { [alerts, addAlert, removeAlert, updateAlert], ); - // LANGUAGE const languageContextValue = useMemo( () => ({ languageNamespaceStatus: {}, diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx index 7eb1d15c2e0ad9e7f659547d0d0d6b7ca3bd5d88..fbb8d0cf6ca4a95760f521f19c96cde04e0bfef1 100644 --- a/frontend/src/components/Card.tsx +++ b/frontend/src/components/Card.tsx @@ -1,24 +1,10 @@ -// src/components/Card.tsx import React from 'react' export interface CardProps { - /** extra Tailwind classes to apply to the wrapper */ className?: string - /** contents of the card */ children: React.ReactNode } -/** - * A simple white card with rounded corners, padding and soft shadow. - * - * Usage: - * import Card from '../components/Card' - * - * - *

Title

- *

Body content

- *
- */ export default function Card({ children, className = '' }: CardProps) { return (
+ - {/* ── Right-side utility buttons ───────────── */} diff --git a/frontend/src/index.css b/frontend/src/index.css index 8ba88433850ef0176558f6479cae63617e1af0db..1f60f4449c88b4f27841196f3ec7d2984cd577f3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,3 @@ -/* src/index.css */ @tailwind base; @tailwind components; @tailwind utilities; diff --git a/frontend/src/layouts/RootLayout.tsx b/frontend/src/layouts/RootLayout.tsx index 9561e9b731e80235bda03c1706c4537cdb91436b..2ee80a6e0d99c85fbbb87d033d2a53430649dc6c 100644 --- a/frontend/src/layouts/RootLayout.tsx +++ b/frontend/src/layouts/RootLayout.tsx @@ -5,7 +5,6 @@ export default function RootLayout() { return ( <> - {/* All routed pages render here */} ); diff --git a/frontend/src/pages/AnalyticsPage/AnalyticsPage.module.css b/frontend/src/pages/AnalyticsPage/AnalyticsPage.module.css new file mode 100644 index 0000000000000000000000000000000000000000..af0e038d15a7a436720e4ecdc7216824bebb7eb0 --- /dev/null +++ b/frontend/src/pages/AnalyticsPage/AnalyticsPage.module.css @@ -0,0 +1,110 @@ +.tabSelector { + display: flex; + justify-content: center; + margin: var(--go-ui-spacing-xl) 0; +} + +.summaryStats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--go-ui-spacing-lg); + margin-bottom: var(--go-ui-spacing-lg); +} + +.progressSection { + margin-top: var(--go-ui-spacing-lg); + padding-top: var(--go-ui-spacing-lg); + border-top: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); +} + +.progressLabel { + display: flex; + justify-content: space-between; + margin-bottom: var(--go-ui-spacing-sm); + color: var(--go-ui-color-text); + font-weight: var(--go-ui-font-weight-medium); +} + +.chartGrid { + display: grid; + grid-template-columns: 1fr; + gap: var(--go-ui-spacing-xl); +} + +.chartSection { + display: grid; + grid-template-columns: 1fr; + gap: var(--go-ui-spacing-lg); +} + +.chartContainer { + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; + background-color: var(--go-ui-color-gray-10); + border-radius: var(--go-ui-border-radius-lg); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + padding: var(--go-ui-spacing-lg); +} + +.tableContainer { + background-color: var(--go-ui-color-white); + border-radius: var(--go-ui-border-radius-lg); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + overflow: hidden; + box-shadow: var(--go-ui-box-shadow-sm); +} + +.modelPerformance { + background-color: var(--go-ui-color-white); + border-radius: var(--go-ui-border-radius-lg); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + overflow: hidden; + box-shadow: var(--go-ui-box-shadow-sm); +} + +.loadingContainer { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + color: var(--go-ui-color-gray-60); + font-size: var(--go-ui-font-size-lg); + font-weight: var(--go-ui-font-weight-medium); +} + +.errorContainer { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + color: var(--go-ui-color-negative); + font-size: var(--go-ui-font-size-lg); + font-weight: var(--go-ui-font-weight-medium); +} + + + +/* Responsive adjustments */ +@media (min-width: 1024px) { + .chartSection { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 768px) { + .summaryStats { + grid-template-columns: 1fr; + gap: var(--go-ui-spacing-md); + } + + .chartContainer { + min-height: 250px; + padding: var(--go-ui-spacing-md); + } + + .tabSelector { + margin: var(--go-ui-spacing-lg) 0; + } +} diff --git a/frontend/src/pages/AnalyticsPage.tsx b/frontend/src/pages/AnalyticsPage/AnalyticsPage.tsx similarity index 78% rename from frontend/src/pages/AnalyticsPage.tsx rename to frontend/src/pages/AnalyticsPage/AnalyticsPage.tsx index 31e3cc70cdde7fc09124e0d2f46314f1f5cfe592..2ed3a4aeae2bc471d00b75401ddabcbfff6f7271 100644 --- a/frontend/src/pages/AnalyticsPage.tsx +++ b/frontend/src/pages/AnalyticsPage/AnalyticsPage.tsx @@ -1,5 +1,3 @@ -// src/pages/AnalyticsPage.tsx - import { PageContainer, PieChart, @@ -16,7 +14,7 @@ import { numericIdSelector } from '@ifrc-go/ui/utils'; import { useState, useEffect, useMemo } from 'react'; -// icons not used on this page +import styles from './AnalyticsPage.module.css'; interface AnalyticsData { totalCaptions: number; @@ -80,7 +78,6 @@ export default function AnalyticsPage() { const [typesLookup, setTypesLookup] = useState([]); const [regionsLookup, setRegionsLookup] = useState([]); - // SegmentInput options for analytics view const viewOptions = [ { key: 'general' as const, label: 'General Analytics' }, { key: 'vlm' as const, label: 'VLM Analytics' } @@ -107,23 +104,22 @@ export default function AnalyticsPage() { maps.forEach((map: any) => { if (map.source) analytics.sources[map.source] = (analytics.sources[map.source] || 0) + 1; - if (map.type) analytics.types[map.type] = (analytics.types[map.type] || 0) + 1; + if (map.event_type) analytics.types[map.event_type] = (analytics.types[map.event_type] || 0) + 1; if (map.countries) { map.countries.forEach((c: any) => { if (c.r_code) analytics.regions[c.r_code] = (analytics.regions[c.r_code] || 0) + 1; }); } - if (map.caption?.model) { - const m = map.caption.model; + if (map.captions && map.captions.length > 0 && map.captions[0].model) { + const m = map.captions[0].model; const ctr = analytics.models[m] ||= { count: 0, avgAccuracy: 0, avgContext: 0, avgUsability: 0, totalScore: 0 }; ctr.count++; - if (map.caption.accuracy != null) ctr.avgAccuracy += map.caption.accuracy; - if (map.caption.context != null) ctr.avgContext += map.caption.context; - if (map.caption.usability != null) ctr.avgUsability += map.caption.usability; + if (map.captions[0].accuracy != null) ctr.avgAccuracy += map.captions[0].accuracy; + if (map.captions[0].context != null) ctr.avgContext += map.captions[0].context; + if (map.captions[0].usability != null) ctr.avgUsability += map.captions[0].usability; } }); - // Add all sources and types with 0 values for missing data sourcesLookup.forEach(source => { if (source.s_code && !analytics.sources[source.s_code]) { analytics.sources[source.s_code] = 0; @@ -136,14 +132,12 @@ export default function AnalyticsPage() { } }); - // Add all regions with 0 values for missing data regionsLookup.forEach(region => { if (region.r_code && !analytics.regions[region.r_code]) { analytics.regions[region.r_code] = 0; } }); - // Add all models with 0 values for missing data const allModels = ['GPT-4', 'Claude', 'Gemini', 'Llama', 'Other']; allModels.forEach(model => { if (!analytics.models[model]) { @@ -153,16 +147,16 @@ export default function AnalyticsPage() { Object.values(analytics.models).forEach(m => { if (m.count > 0) { - m.avgAccuracy = Math.round(m.avgAccuracy / m.count); - m.avgContext = Math.round(m.avgContext / m.count); + m.avgAccuracy = Math.round(m.avgAccuracy / m.count); + m.avgContext = Math.round(m.avgContext / m.count); m.avgUsability = Math.round(m.avgUsability / m.count); - m.totalScore = Math.round((m.avgAccuracy + m.avgContext + m.avgUsability) / 3); + m.totalScore = Math.round((m.avgAccuracy + m.avgContext + m.avgUsability) / 3); } }); setData(analytics); } catch (e) { - console.error(e); + setData(null); } finally { setLoading(false); @@ -183,7 +177,7 @@ export default function AnalyticsPage() { setTypesLookup(types); setRegionsLookup(regions); } catch (e) { - console.error('Failed to fetch lookup data:', e); + } } @@ -197,16 +191,9 @@ export default function AnalyticsPage() { return type ? type.label : code; }; - // const getRegionLabel = (code: string) => { - // const region = regionsLookup.find(r => r.r_code === code); - // return region ? region.label : code; - // }; - - // Transform regions data for IFRC Table - show all regions including 0 data const regionsTableData = useMemo(() => { if (!data || !regionsLookup.length) return []; - // Create a map of all regions with their counts (0 if no data) const allRegions = regionsLookup.reduce((acc, region) => { if (region.r_code) { acc[region.r_code] = { @@ -217,7 +204,6 @@ export default function AnalyticsPage() { return acc; }, {} as Record); - // Convert to array and sort by count descending return Object.entries(allRegions) .sort(([,a], [,b]) => b.count - a.count) .map(([_, { name, count }], index) => ({ @@ -228,7 +214,6 @@ export default function AnalyticsPage() { })); }, [data, regionsLookup]); - // Transform types data for IFRC Table const typesTableData = useMemo(() => { if (!data) return []; @@ -242,7 +227,6 @@ export default function AnalyticsPage() { })); }, [data, typesLookup]); - // Transform sources data for IFRC Table const sourcesTableData = useMemo(() => { if (!data) return []; @@ -256,7 +240,6 @@ export default function AnalyticsPage() { })); }, [data, sourcesLookup]); - // Transform models data for IFRC Table const modelsTableData = useMemo(() => { if (!data) return []; @@ -273,7 +256,6 @@ export default function AnalyticsPage() { })); }, [data]); - // Create columns for regions table const regionsColumns = useMemo(() => [ createStringColumn( 'name', @@ -296,7 +278,6 @@ export default function AnalyticsPage() { ), ], []); - // Create columns for types table const typesColumns = useMemo(() => [ createStringColumn( 'name', @@ -319,7 +300,6 @@ export default function AnalyticsPage() { ), ], []); - // Create columns for sources table const sourcesColumns = useMemo(() => [ createStringColumn( 'name', @@ -342,7 +322,6 @@ export default function AnalyticsPage() { ), ], []); - // Create columns for models table const modelsColumns = useMemo(() => [ createStringColumn( 'name', @@ -395,7 +374,7 @@ export default function AnalyticsPage() { if (loading) { return ( -
+
@@ -405,7 +384,7 @@ export default function AnalyticsPage() { if (!data) { return ( -
+
Failed to load analytics data. Please try again.
@@ -413,22 +392,13 @@ export default function AnalyticsPage() { } const sourcesChartData = Object.entries(data.sources).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value })); - const typesChartData = Object.entries(data.types).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value })); + const typesChartData = Object.entries(data.types).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value })); const regionsChartData = Object.entries(data.regions).filter(([, value]) => value > 0).map(([name, value]) => ({ name, value })); - // Official IFRC color palette for all pie charts - same order for all charts const ifrcColors = [ - '#F5333F', // IFRC Primary Red (--go-ui-color-red-90) - '#F64752', // IFRC Red 80 - '#F75C65', // IFRC Red 70 - '#F87079', // IFRC Red 60 - '#F9858C', // IFRC Red 50 - '#FA999F', // IFRC Red 40 - '#FBADB2', // IFRC Red 30 - '#FCC2C5' // IFRC Red 20 + '#F5333F', '#F64752', '#F75C65', '#F87079', '#F9858C', '#FA999F', '#FBADB2', '#FCC2C5' ]; - return ( - {/* Tab selector */} -
+
{view === 'general' ? ( -
- {/* Summary Statistics */} +
-
+
-
-
+
+
Progress towards target {Math.round((data.totalCaptions / 2000) * 100)}%
@@ -479,11 +447,9 @@ export default function AnalyticsPage() {
- - {/* Regions Chart & Data */} -
-
+
+
d.value} @@ -493,7 +459,7 @@ export default function AnalyticsPage() { showPercentageInLegend />
-
+
- {/* Sources Chart & Data */} -
-
+
+
d.value} @@ -518,7 +483,7 @@ export default function AnalyticsPage() { showPercentageInLegend />
-
+
- {/* Types Chart & Data */} -
-
+
+
d.value} @@ -543,7 +507,7 @@ export default function AnalyticsPage() { showPercentageInLegend />
-
+
) : ( -
- {/* Model Performance */} +
-
+
+
+ )} diff --git a/frontend/src/pages/AnalyticsPage/index.ts b/frontend/src/pages/AnalyticsPage/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7f159b62ff9ae21894fc90a3b2cda687366c8ba --- /dev/null +++ b/frontend/src/pages/AnalyticsPage/index.ts @@ -0,0 +1 @@ +export { default } from './AnalyticsPage'; diff --git a/frontend/src/pages/DemoPage.tsx b/frontend/src/pages/DemoPage.tsx index ee5fb33eaa577a15b93f1311cf9a3f07e7e8ca1e..a7103c682d80cd098bdfe340d9c98b5c319d700e 100644 --- a/frontend/src/pages/DemoPage.tsx +++ b/frontend/src/pages/DemoPage.tsx @@ -10,132 +10,51 @@ import { SearchMultiSelectInput, TextArea, Checkbox, - Radio, Switch, DateInput, NumberInput, PasswordInput, RawFileInput, Container, - Alert, - Message, Spinner, ProgressBar, StackedProgressBar, KeyFigure, PieChart, BarChart, - TimeSeriesChart, - Table, - HeaderCell, - TableRow, - TableData, - Tabs, - Tab, - TabList, - TabPanel, - Chip, - Tooltip, - Modal, - Popup, - DropdownMenu, IconButton, ConfirmButton, - Breadcrumbs, - List, - Grid, - ExpandableContainer, - BlockLoading, - InputContainer, InputLabel, InputHint, - InputError, InputSection, BooleanInput, BooleanOutput, DateOutput, - DateRangeOutput, NumberOutput, TextOutput, - HtmlOutput, - DismissableTextOutput, - DismissableListOutput, - DismissableMultiListOutput, - Legend, - LegendItem, - ChartContainer, - ChartAxes, - InfoPopup, Footer, NavigationTabList, - Pager, - RawButton, - RawInput, - RawTextArea, - RawList, SegmentInput, - SelectInputContainer, - ReducedListDisplay, - Image, - TopBanner, + BlockLoading, } from '@ifrc-go/ui'; import { UploadCloudLineIcon, - ArrowRightLineIcon, SearchLineIcon, - QuestionLineIcon, - GoMainIcon, - StarLineIcon, - DashboardIcon, - AnalysisIcon, - FilterLineIcon, - DropLineIcon, - CartIcon, ChevronDownLineIcon, - ChevronUpLineIcon, CloseLineIcon, EditLineIcon, DeleteBinLineIcon, DownloadLineIcon, ShareLineIcon, - SettingsLineIcon, - RulerLineIcon, - MagicLineIcon, - PantoneLineIcon, - MarkupLineIcon, - CalendarLineIcon, - LockLineIcon, LocationIcon, - HeartLineIcon, - ThumbUpLineIcon, - ThumbDownLineIcon, - EyeLineIcon, - EyeOffLineIcon, CheckLineIcon, - CropLineIcon, AlertLineIcon, InfoIcon, - AlarmWarningLineIcon, - SliceLineIcon, - ArrowLeftLineIcon, - ArrowDownLineIcon, - ArrowUpLineIcon, - MenuLineIcon, - MoreLineIcon, - RefreshLineIcon, - PaintLineIcon, - NotificationIcon, - HammerLineIcon, - ShapeLineIcon, - LinkLineIcon, - ExternalLinkLineIcon, - CopyLineIcon, } from '@ifrc-go/icons'; export default function DemoPage() { const [showModal, setShowModal] = useState(false); const [showPopup, setShowPopup] = useState(false); - const [activeTab, setActiveTab] = useState('components'); const [loading, setLoading] = useState(false); const [textValue, setTextValue] = useState(''); const [selectValue, setSelectValue] = useState(''); @@ -149,7 +68,7 @@ export default function DemoPage() { const [booleanValue, setBooleanValue] = useState(false); const [segmentValue, setSegmentValue] = useState('option1'); - // Dummy data + const dummyOptions = [ { key: 'option1', label: 'Option 1' }, { key: 'option2', label: 'Option 2' }, @@ -168,12 +87,7 @@ export default function DemoPage() { { c_code: 'FR', label: 'France', r_code: 'EUR' }, ]; - const dummyTableData = [ - { id: 1, name: 'John Doe', age: 30, country: 'United States', status: 'Active' }, - { id: 2, name: 'Jane Smith', age: 25, country: 'Canada', status: 'Inactive' }, - { id: 3, name: 'Bob Johnson', age: 35, country: 'Mexico', status: 'Active' }, - { id: 4, name: 'Alice Brown', age: 28, country: 'Brazil', status: 'Active' }, - ]; + const dummyChartData = [ { name: 'Red Cross', value: 45 }, @@ -182,14 +96,7 @@ export default function DemoPage() { { name: 'WFP', value: 10 }, ]; - const dummyTimeSeriesData = [ - { date: '2024-01', value: 100 }, - { date: '2024-02', value: 120 }, - { date: '2024-03', value: 110 }, - { date: '2024-04', value: 140 }, - { date: '2024-05', value: 130 }, - { date: '2024-06', value: 160 }, - ]; + const dummyBarData = [ { name: 'Q1', value: 100 }, @@ -203,47 +110,47 @@ export default function DemoPage() { setTimeout(() => setLoading(false), 2000); }; - const handleTextChange = (value: string | undefined, name: string) => { + const handleTextChange = (value: string | undefined) => { setTextValue(value || ''); }; - const handlePasswordChange = (value: string | undefined, name: string) => { + const handlePasswordChange = (value: string | undefined) => { setPasswordValue(value || ''); }; - const handleNumberChange = (value: number | undefined, name: string) => { + const handleNumberChange = (value: number | undefined) => { setNumberValue(value); }; - const handleDateChange = (value: string | undefined, name: string) => { + const handleDateChange = (value: string | undefined) => { setDateValue(value || ''); }; - const handleSelectChange = (value: string | undefined, name: string) => { + const handleSelectChange = (value: string | undefined) => { setSelectValue(value || ''); }; - const handleMultiSelectChange = (value: string[], name: string) => { + const handleMultiSelectChange = (value: string[]) => { setMultiSelectValue(value); }; - const handleCheckboxChange = (value: boolean, name: string) => { + const handleCheckboxChange = (value: boolean) => { setCheckboxValue(value); }; - const handleRadioChange = (value: string, name: string) => { + const handleRadioChange = (value: string) => { setRadioValue(value); }; - const handleSwitchChange = (value: boolean, name: string) => { + const handleSwitchChange = (value: boolean) => { setSwitchValue(value); }; - const handleBooleanChange = (value: boolean, name: string) => { + const handleBooleanChange = (value: boolean) => { setBooleanValue(value); }; - const handleSegmentChange = (value: string, name: string) => { + const handleSegmentChange = (value: string) => { setSegmentValue(value); }; @@ -255,7 +162,7 @@ export default function DemoPage() {
{/* Navigation Tabs */}
-

Navigation Tab List

+ Navigation Tab List @@ -266,7 +173,7 @@ export default function DemoPage() { {/* Top Banner */}
-

Top Banner

+ Top Banner
@@ -282,7 +189,7 @@ export default function DemoPage() { {/* Breadcrumbs */}
-

Breadcrumbs

+ Breadcrumbs
- - - - - - - - - - {dummyTableData.map((row) => ( - - - - - - - ))} - -
NameAgeCountryStatus
{row.name}{row.age}{row.country} - - {row.status} - -
-
-
+ {/* Lists */}
diff --git a/frontend/src/pages/DevPage.tsx b/frontend/src/pages/DevPage.tsx index f4d1a1eaedfbd94278cc2bd866984a92054acdd2..64b1a2040985d05c5a38b2bb630a1b198e4ea600 100644 --- a/frontend/src/pages/DevPage.tsx +++ b/frontend/src/pages/DevPage.tsx @@ -3,11 +3,9 @@ import { PageContainer, Heading, Button, Container, } from '@ifrc-go/ui'; -// Local storage key for selected model const SELECTED_MODEL_KEY = 'selectedVlmModel'; export default function DevPage() { - // Model selection state const [availableModels, setAvailableModels] = useState>([]); const [selectedModel, setSelectedModel] = useState(''); - // Fetch models on component mount useEffect(() => { fetchModels(); }, []); @@ -25,11 +22,8 @@ export default function DevPage() { fetch('/api/models') .then(r => r.json()) .then(modelsData => { - console.log('Models data:', modelsData); - console.log('Available models count:', modelsData.models?.length || 0); setAvailableModels(modelsData.models || []); - // Load persisted model or set default model (first available model) const persistedModel = localStorage.getItem(SELECTED_MODEL_KEY); if (modelsData.models && modelsData.models.length > 0) { if (persistedModel && modelsData.models.find((m: any) => m.m_code === persistedModel && m.is_available)) { @@ -42,7 +36,7 @@ export default function DevPage() { } }) .catch(err => { - console.error('Failed to fetch models:', err); + }); }; @@ -59,7 +53,6 @@ export default function DevPage() { }); if (response.ok) { - // Update local state setAvailableModels(prev => prev.map(model => model.m_code === modelCode @@ -67,19 +60,15 @@ export default function DevPage() { : model ) ); - console.log(`Model ${modelCode} availability toggled to ${!currentStatus}`); } else { const errorData = await response.json(); - console.error('Failed to toggle model availability:', errorData); alert(`Failed to toggle model availability: ${errorData.error || 'Unknown error'}`); } } catch (error) { - console.error('Error toggling model availability:', error); alert('Error toggling model availability'); } }; - // Handle model selection change const handleModelChange = (modelCode: string) => { setSelectedModel(modelCode); localStorage.setItem(SELECTED_MODEL_KEY, modelCode); @@ -195,12 +184,10 @@ export default function DevPage() { fetch('/api/models') .then(r => r.json()) .then(data => { - console.log('Models API response:', data); - alert('Check console for models API response'); + alert('Models API response received successfully'); }) .catch(err => { - console.error('Models API error:', err); - alert('Models API error - check console'); + alert('Models API error occurred'); }); }} > @@ -216,12 +203,10 @@ export default function DevPage() { fetch(`/api/models/${selectedModel}/test`) .then(r => r.json()) .then(data => { - console.log('Model test response:', data); - alert('Check console for model test response'); + alert('Model test completed successfully'); }) .catch(err => { - console.error('Model test error:', err); - alert('Model test error - check console'); + alert('Model test failed'); }); }} > diff --git a/frontend/src/pages/ExplorePage.tsx b/frontend/src/pages/ExplorePage.tsx deleted file mode 100644 index 419c71ae7354acaf331a36d5dc3b595f7ac41f03..0000000000000000000000000000000000000000 --- a/frontend/src/pages/ExplorePage.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import { PageContainer, Heading, TextInput, SelectInput, MultiSelectInput, Button } from '@ifrc-go/ui'; -import { useState, useEffect, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { StarLineIcon } from '@ifrc-go/icons'; - -interface MapOut { - image_id: string; - file_key: string; - image_url: string; - source: string; - type: string; - epsg: string; - image_type: string; - countries?: {c_code: string, label: string, r_code: string}[]; - caption?: { - title: string; - generated: string; - edited?: string; - starred?: boolean; - }; -} - -export default function ExplorePage() { - const navigate = useNavigate(); - const [maps, setMaps] = useState([]); - const [search, setSearch] = useState(''); - const [srcFilter, setSrcFilter] = useState(''); - const [catFilter, setCatFilter] = useState(''); - const [regionFilter, setRegionFilter] = useState(''); - const [countryFilter, setCountryFilter] = useState(''); - const [showStarredOnly, setShowStarredOnly] = useState(false); - const [sources, setSources] = useState<{s_code: string, label: string}[]>([]); - const [types, setTypes] = useState<{t_code: string, label: string}[]>([]); - const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]); - const [countries, setCountries] = useState<{c_code: string, label: string, r_code: string}[]>([]); - const [isLoadingFilters, setIsLoadingFilters] = useState(true); - - const fetchMaps = () => { - setIsLoadingFilters(true); - // Fetch maps - fetch('/api/images/') - .then(r => { - if (!r.ok) { - throw new Error(`HTTP ${r.status}: ${r.statusText}`); - } - return r.json(); - }) - .then(data => { - // Ensure data is an array - if (Array.isArray(data)) { - setMaps(data); - console.log(`Loaded ${data.length} maps`); - if (data.length > 0) { - console.log('Sample map data:', { - image_id: data[0].image_id, - source: data[0].source, - type: data[0].type, - countries: data[0].countries?.length || 0, - caption: data[0].caption ? 'has caption' : 'no caption' - }); - } - } else { - console.error('Expected array from /api/images/, got:', data); - setMaps([]); - } - }) - .catch(err => { - console.error('Failed to fetch maps:', err); - setMaps([]); - }) - .finally(() => { - setIsLoadingFilters(false); - }); - }; - - useEffect(() => { - fetchMaps(); - }, []); - - // Auto-refresh when component becomes visible (user navigates back) - useEffect(() => { - const handleVisibilityChange = () => { - if (!document.hidden) { - fetchMaps(); - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, []); - - useEffect(() => { - // Fetch lookup data - console.log('Fetching filter data...'); - setIsLoadingFilters(true); - - Promise.all([ - fetch('/api/sources').then(r => { - console.log('Sources response:', r.status, r.statusText); - if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`); - return r.json(); - }), - fetch('/api/types').then(r => { - console.log('Types response:', r.status, r.statusText); - if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`); - return r.json(); - }), - fetch('/api/regions').then(r => { - console.log('Regions response:', r.status, r.statusText); - if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`); - return r.json(); - }), - fetch('/api/countries').then(r => { - console.log('Countries response:', r.status, r.statusText); - if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`); - return r.json(); - }) - ]).then(([sourcesData, typesData, regionsData, countriesData]) => { - console.log('Fetched filter data:', { - sources: sourcesData.length, - types: typesData.length, - regions: regionsData.length, - countries: countriesData.length - }); - - if (Array.isArray(sourcesData)) { - setSources(sourcesData); - } else { - console.error('Expected array from /api/sources, got:', sourcesData); - setSources([]); - } - - if (Array.isArray(typesData)) { - setTypes(typesData); - } else { - console.error('Expected array from /api/types, got:', typesData); - setTypes([]); - } - - if (Array.isArray(regionsData)) { - setRegions(regionsData); - } else { - console.error('Expected array from /api/regions, got:', regionsData); - setRegions([]); - } - - if (Array.isArray(countriesData)) { - setCountries(countriesData); - } else { - console.error('Expected array from /api/countries, got:', countriesData); - setCountries([]); - } - - setIsLoadingFilters(false); - }).catch(err => { - console.error('Failed to fetch filter data:', err); - // Set empty arrays on error to prevent undefined issues - setSources([]); - setTypes([]); - setRegions([]); - setCountries([]); - setIsLoadingFilters(false); - }); - }, []); - - const filtered = useMemo(() => { - // Ensure maps is an array before filtering - if (!Array.isArray(maps)) { - console.warn('maps is not an array:', maps); - return []; - } - - return maps.filter(m => { - // Search in filename, source, type, title, and caption - const searchLower = search.toLowerCase(); - const searchMatch = !search || - m.file_key.toLowerCase().includes(searchLower) || - m.source.toLowerCase().includes(searchLower) || - m.type.toLowerCase().includes(searchLower) || - (m.caption?.title && m.caption.title.toLowerCase().includes(searchLower)) || - (m.caption?.edited && m.caption.edited.toLowerCase().includes(searchLower)) || - (m.caption?.generated && m.caption.generated.toLowerCase().includes(searchLower)); - - // Filter by source - const sourceMatch = !srcFilter || m.source === srcFilter; - - // Filter by type - const typeMatch = !catFilter || m.type === catFilter; - - // Filter by region (check if any country in the image belongs to the selected region) - const regionMatch = !regionFilter || (m.countries && m.countries.some(c => c.r_code === regionFilter)); - - // Filter by country (check if any country in the image matches the selected country) - const countryMatch = !countryFilter || (m.countries && m.countries.some(c => c.c_code === countryFilter)); - - // Filter by starred status - const starredMatch = !showStarredOnly || (m.caption && m.caption.starred === true); - - return searchMatch && sourceMatch && typeMatch && regionMatch && countryMatch && starredMatch; - }); - }, [maps, search, srcFilter, catFilter, regionFilter, countryFilter]); - - return ( - -
- {/* Header Section */} -
-
- Explore Examples -

Browse and search through uploaded crisis maps

-
-
- - -
-
- - {/* Filters Bar */} -
-
- setSearch(e || '')} - className="flex-1 min-w-[12rem]" - /> - - setSrcFilter(v as string || '')} - keySelector={(o) => o.s_code} - labelSelector={(o) => o.label} - required={false} - disabled={isLoadingFilters} - /> - - setCatFilter(v as string || '')} - keySelector={(o) => o.t_code} - labelSelector={(o) => o.label} - required={false} - disabled={isLoadingFilters} - /> - - setRegionFilter(v as string || '')} - keySelector={(o) => o.r_code} - labelSelector={(o) => o.label} - required={false} - disabled={isLoadingFilters} - /> - - setCountryFilter((v as string[])[0] || '')} - keySelector={(o) => o.c_code} - labelSelector={(o) => o.label} - disabled={isLoadingFilters} - /> -
-
- - {/* Results Section */} -
-
-

- {filtered.length} of {maps.length} examples -

-
- - {/* List */} -
- {filtered.map(m => ( -
navigate(`/map/${m.image_id}`)}> -
- {m.image_url ? ( - {m.file_key} { - // Fallback to placeholder if image fails to load - const target = e.target as HTMLImageElement; - target.style.display = 'none'; - target.parentElement!.innerHTML = 'Img'; - }} - /> - ) : ( - 'Img' - )} -
-
-

- {m.caption?.title || 'No title'} -

-
- {m.source} - {m.type} -
-
-
- ))} - - {!filtered.length && ( -
-

No examples found.

-
- )} -
-
-
-
- ); -} diff --git a/frontend/src/pages/ExplorePage/ExplorePage.module.css b/frontend/src/pages/ExplorePage/ExplorePage.module.css new file mode 100644 index 0000000000000000000000000000000000000000..8a15a666bda61711c1cf825ce18cfb09bf597740 --- /dev/null +++ b/frontend/src/pages/ExplorePage/ExplorePage.module.css @@ -0,0 +1,125 @@ +.metadataTags { + display: flex; + flex-wrap: wrap; + gap: var(--go-ui-spacing-sm); + align-items: center; +} + +.metadataTag { + padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm); + background-color: var(--go-ui-color-red-10); + color: var(--go-ui-color-red-90); + font-size: var(--go-ui-font-size-xs); + border-radius: var(--go-ui-border-radius-md); + font-weight: var(--go-ui-font-weight-medium); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-20); + transition: all var(--go-ui-duration-transition-fast) ease; + white-space: nowrap; +} + +.metadataTag:hover { + background-color: var(--go-ui-color-red-20); + transform: translateY(-1px); + box-shadow: var(--go-ui-box-shadow-xs); +} + +.metadataTagSource { + padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm); + background-color: var(--go-ui-color-blue-10); + color: var(--go-ui-color-blue-90); + font-size: var(--go-ui-font-size-xs); + border-radius: var(--go-ui-border-radius-md); + font-weight: var(--go-ui-font-weight-medium); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-blue-20); + white-space: nowrap; +} + +.metadataTagType { + padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm); + background-color: var(--go-ui-color-red-90); + color: var(--go-ui-color-white); + font-size: var(--go-ui-font-size-xs); + border-radius: var(--go-ui-border-radius-md); + font-weight: var(--go-ui-font-weight-medium); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-90); + white-space: nowrap; +} + +.mapItem { + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + border-radius: var(--go-ui-border-radius-lg); + padding: var(--go-ui-spacing-lg); + display: flex; + gap: var(--go-ui-spacing-lg); + cursor: pointer; + transition: all var(--go-ui-duration-transition-medium) ease; + background-color: var(--go-ui-color-white); +} + +.mapItem:hover { + background-color: var(--go-ui-color-gray-10); + border-color: var(--go-ui-color-gray-30); + box-shadow: var(--go-ui-box-shadow-sm); + transform: translateY(-1px); +} + +.mapItemImage { + background-color: var(--go-ui-color-gray-20); + display: flex; + align-items: center; + justify-content: center; + color: var(--go-ui-color-gray-60); + font-size: var(--go-ui-font-size-xs); + overflow: hidden; + border-radius: var(--go-ui-border-radius-md); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + flex-shrink: 0; +} + +.mapItemImage img { + width: 100%; + height: 100%; + object-fit: cover; + image-rendering: pixelated; +} + +.mapItemContent { + flex: 1; + min-width: 0; +} + +.mapItemTitle { + font-weight: var(--go-ui-font-weight-medium); + color: var(--go-ui-color-text); + margin-bottom: var(--go-ui-spacing-sm); + font-size: var(--go-ui-font-size-md); + line-height: var(--go-ui-line-height-md); +} + +.mapItemMetadata { + margin-bottom: var(--go-ui-spacing-sm); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .mapItem { + flex-direction: column; + gap: var(--go-ui-spacing-md); + } + + .mapItemImage { + width: 100%; + height: 120px; + } + + .metadataTags { + gap: var(--go-ui-spacing-xs); + } + + .metadataTag, + .metadataTagSource, + .metadataTagType { + font-size: var(--go-ui-font-size-xs); + padding: var(--go-ui-spacing-2xs) var(--go-ui-spacing-xs); + } +} diff --git a/frontend/src/pages/ExplorePage/ExplorePage.tsx b/frontend/src/pages/ExplorePage/ExplorePage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d965f4ed297ca67e4620535f0a700d31586e65fe --- /dev/null +++ b/frontend/src/pages/ExplorePage/ExplorePage.tsx @@ -0,0 +1,357 @@ +import { PageContainer, TextInput, SelectInput, MultiSelectInput, Button, Container } from '@ifrc-go/ui'; +import { useState, useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { StarLineIcon } from '@ifrc-go/icons'; +import styles from './ExplorePage.module.css'; + +interface CaptionWithImageOut { + cap_id: string; + image_id: string; + title: string; + prompt: string; + model: string; + schema_id: string; + raw_json: any; + generated: string; + edited?: string; + accuracy?: number; + context?: number; + usability?: number; + starred: boolean; + created_at?: string; + updated_at?: string; + file_key: string; + image_url: string; + source: string; + event_type: string; + epsg: string; + image_type: string; + countries: {c_code: string, label: string, r_code: string}[]; +} + +export default function ExplorePage() { + const navigate = useNavigate(); + const [captions, setCaptions] = useState([]); + const [search, setSearch] = useState(''); + const [srcFilter, setSrcFilter] = useState(''); + const [catFilter, setCatFilter] = useState(''); + const [regionFilter, setRegionFilter] = useState(''); + const [countryFilter, setCountryFilter] = useState(''); + const [showStarredOnly, setShowStarredOnly] = useState(false); + const [sources, setSources] = useState<{s_code: string, label: string}[]>([]); + const [types, setTypes] = useState<{t_code: string, label: string}[]>([]); + const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]); + const [countries, setCountries] = useState<{c_code: string, label: string, r_code: string}[]>([]); + const [isLoadingFilters, setIsLoadingFilters] = useState(true); + + const fetchCaptions = () => { + setIsLoadingFilters(true); + fetch('/api/captions') + .then(r => { + if (!r.ok) { + throw new Error(`HTTP ${r.status}: ${r.statusText}`); + } + return r.json(); + }) + .then(data => { + if (Array.isArray(data)) { + setCaptions(data); + + } else { + + setCaptions([]); + } + }) + .catch(() => { + setCaptions([]); + }) + .finally(() => { + setIsLoadingFilters(false); + }); + }; + + useEffect(() => { + fetchCaptions(); + }, []); + + useEffect(() => { + const handleVisibilityChange = () => { + if (!document.hidden) { + fetchCaptions(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + useEffect(() => { + + setIsLoadingFilters(true); + + Promise.all([ + fetch('/api/sources').then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`); + return r.json(); + }), + fetch('/api/types').then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`); + return r.json(); + }), + fetch('/api/regions').then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`); + return r.json(); + }), + fetch('/api/countries').then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`); + return r.json(); + }) + ]).then(([sourcesData, typesData, regionsData, countriesData]) => { + + + if (Array.isArray(sourcesData)) { + setSources(sourcesData); + } else { + + setSources([]); + } + + if (Array.isArray(typesData)) { + setTypes(typesData); + } else { + + setTypes([]); + } + + if (Array.isArray(regionsData)) { + setRegions(regionsData); + } else { + + setRegions([]); + } + + if (Array.isArray(countriesData)) { + setCountries(countriesData); + } else { + + setCountries([]); + } + + setIsLoadingFilters(false); + }).catch(() => { + + setSources([]); + setTypes([]); + setRegions([]); + setCountries([]); + setIsLoadingFilters(false); + }); + }, []); + + const filtered = useMemo(() => { + if (!Array.isArray(captions)) { + + return []; + } + + return captions.filter(c => { + const searchLower = search.toLowerCase(); + const searchMatch = !search || + c.file_key.toLowerCase().includes(searchLower) || + c.source.toLowerCase().includes(searchLower) || + c.event_type.toLowerCase().includes(searchLower) || + c.title.toLowerCase().includes(searchLower) || + (c.edited && c.edited.toLowerCase().includes(searchLower)) || + (c.generated && c.generated.toLowerCase().includes(searchLower)); + + const sourceMatch = !srcFilter || c.source === srcFilter; + const typeMatch = !catFilter || c.event_type === catFilter; + const regionMatch = !regionFilter || (c.countries && c.countries.some(c => c.r_code === regionFilter)); + const countryMatch = !countryFilter || (c.countries && c.countries.some(c => c.c_code === countryFilter)); + const starredMatch = !showStarredOnly || c.starred === true; + + return searchMatch && sourceMatch && typeMatch && regionMatch && countryMatch && starredMatch; + }); + }, [captions, search, srcFilter, catFilter, regionFilter, countryFilter]); + + return ( + + +
+ {/* Header Section */} +
+
+

Browse and search through uploaded crisis maps

+
+
+ + +
+
+ + {/* Filters Bar */} + +
+ setSearch(e || '')} + className="flex-1 min-w-[12rem]" + /> + + setSrcFilter(v as string || '')} + keySelector={(o) => o.s_code} + labelSelector={(o) => o.label} + required={false} + disabled={isLoadingFilters} + /> + + setCatFilter(v as string || '')} + keySelector={(o) => o.t_code} + labelSelector={(o) => o.label} + required={false} + disabled={isLoadingFilters} + /> + + setRegionFilter(v as string || '')} + keySelector={(o) => o.r_code} + labelSelector={(o) => o.label} + required={false} + disabled={isLoadingFilters} + /> + + setCountryFilter((v as string[])[0] || '')} + keySelector={(o) => o.c_code} + labelSelector={(o) => o.label} + disabled={isLoadingFilters} + /> +
+
+ + {/* Results Section */} + +
+
+

+ {filtered.length} of {captions.length} examples +

+
+ + {/* List */} +
+ {filtered.map(c => ( +
navigate(`/map/${c.image_id}?captionId=${c.cap_id}`)}> +
+ {c.image_url ? ( + {c.file_key} { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + target.parentElement!.innerHTML = 'Img'; + }} + /> + ) : ( + 'Img' + )} +
+
+

+ {c.title} +

+
+
+ + {c.source} + + + {c.event_type} + + + {c.epsg} + + + {c.image_type} + +
+
+
+
+ ))} + + {!filtered.length && ( +
+

No examples found.

+
+ )} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/ExplorePage/index.ts b/frontend/src/pages/ExplorePage/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f1bf9312a3e42ded6dccade29424485e5fcce6d --- /dev/null +++ b/frontend/src/pages/ExplorePage/index.ts @@ -0,0 +1 @@ +export { default } from './ExplorePage'; diff --git a/frontend/src/pages/MapDetailPage.tsx b/frontend/src/pages/MapDetailPage.tsx deleted file mode 100644 index 3ec4b864a47e3702f7a839f14c580fce956a8ac2..0000000000000000000000000000000000000000 --- a/frontend/src/pages/MapDetailPage.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { PageContainer, Button } from '@ifrc-go/ui'; -import { useState, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; - -interface MapOut { - image_id: string; - file_key: string; - image_url: string; - source: string; - type: string; - epsg: string; - image_type: string; - caption?: { - title: string; - generated: string; - edited?: string; - }; -} - -export default function MapDetailPage() { - const { mapId } = useParams<{ mapId: string }>(); - const navigate = useNavigate(); - const [map, setMap] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [contributing, setContributing] = useState(false); - - useEffect(() => { - if (!mapId) { - setError('Map ID is required'); - setLoading(false); - return; - } - - // Fetch the specific map - fetch(`/api/images/${mapId}`) - .then(response => { - if (!response.ok) { - throw new Error('Map not found'); - } - return response.json(); - }) - .then(data => { - setMap(data); - setLoading(false); - }) - .catch(err => { - setError(err.message); - setLoading(false); - }); - }, [mapId]); - - const handleContribute = async () => { - if (!map) return; - - setContributing(true); - try { - // Simulate uploading the current image by creating a new map entry - const formData = new FormData(); - formData.append('source', map.source); - formData.append('type', map.type); - formData.append('epsg', map.epsg); - formData.append('image_type', map.image_type); - - // We'll need to fetch the image and create a file from it - const imageResponse = await fetch(map.image_url); - const imageBlob = await imageResponse.blob(); - const file = new File([imageBlob], map.file_key, { type: 'image/jpeg' }); - formData.append('file', file); - - const response = await fetch('/api/images/', { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - throw new Error('Failed to contribute image'); - } - - const result = await response.json(); - - // Navigate to the upload page with the new map ID and step 2 - navigate(`/upload?mapId=${result.image_id}&step=2`); - } catch (err) { - console.error('Contribution failed:', err); - alert('Failed to contribute image. Please try again.'); - } finally { - setContributing(false); - } - }; - - if (loading) { - return ( - -
-
Loading...
-
-
- ); - } - - if (error || !map) { - return ( - -
-
{error || 'Map not found'}
-
-
- ); - } - - return ( - -
- -
- -
- {/* Image Section */} -
-
- {map.image_url ? ( - {map.file_key} - ) : ( -
- No image available -
- )} -
-
- - {/* Details Section */} -
-
-

Title

-
-
- {map.caption?.title || '— no title —'} -
-
-
- -
-

Metadata

-
- - {map.source} - - - {map.type} - - - {map.epsg} - - - {map.image_type} - -
-
- -
-

Generated Caption

-
-

- {map.caption?.edited || map.caption?.generated || '— no caption yet —'} -

-
-
-
-
- - {/* Contribute Section */} -
- -
-
- ); -} \ No newline at end of file diff --git a/frontend/src/pages/MapDetailsPage/MapDetailPage.module.css b/frontend/src/pages/MapDetailsPage/MapDetailPage.module.css new file mode 100644 index 0000000000000000000000000000000000000000..ea1d736fcaab465fb1b9af2b3aa2835bcc4cc070 --- /dev/null +++ b/frontend/src/pages/MapDetailsPage/MapDetailPage.module.css @@ -0,0 +1,184 @@ +.backButton { + margin-bottom: var(--go-ui-spacing-lg); +} + +.imageContainer { + background-color: var(--go-ui-color-gray-20); + border-radius: var(--go-ui-border-radius-lg); + overflow: hidden; + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + box-shadow: var(--go-ui-box-shadow-sm); + transition: box-shadow var(--go-ui-duration-transition-medium) ease; +} + +.imageContainer:hover { + box-shadow: var(--go-ui-box-shadow-md); +} + +.imageContainer img { + width: 100%; + height: auto; + object-fit: contain; + image-rendering: pixelated; + display: block; +} + +.imagePlaceholder { + width: 100%; + height: 16rem; + background-color: var(--go-ui-color-gray-30); + display: flex; + align-items: center; + justify-content: center; + color: var(--go-ui-color-gray-60); + font-size: var(--go-ui-font-size-sm); + font-weight: var(--go-ui-font-weight-medium); +} + +.metadataTags { + display: flex; + flex-wrap: wrap; + gap: var(--go-ui-spacing-sm); +} + +.metadataTag { + padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm); + background-color: var(--go-ui-color-red-10); + color: var(--go-ui-color-red-90); + font-size: var(--go-ui-font-size-sm); + border-radius: var(--go-ui-border-radius-md); + font-weight: var(--go-ui-font-weight-medium); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-20); + transition: all var(--go-ui-duration-transition-fast) ease; +} + +.metadataTag:hover { + background-color: var(--go-ui-color-red-20); + transform: translateY(-1px); + box-shadow: var(--go-ui-box-shadow-xs); +} + +.captionContainer { + padding: var(--go-ui-spacing-md); + background-color: var(--go-ui-color-gray-10); + border-radius: var(--go-ui-border-radius-md); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); +} + +.captionText { + margin-bottom: var(--go-ui-spacing-md); + line-height: 1.6; + color: var(--go-ui-color-gray-900); +} + +.captionText:last-child { + margin-bottom: 0; +} + +.highlightedCaption { + background-color: var(--go-ui-color-blue-10); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-blue-30); + border-radius: var(--go-ui-border-radius-md); + padding: var(--go-ui-spacing-md); + margin: var(--go-ui-spacing-md) 0; +} + +.captionHighlight { + margin-top: var(--go-ui-spacing-sm); + font-size: var(--go-ui-font-size-sm); + color: var(--go-ui-color-blue-70); + font-style: italic; +} + +.contributeSection { + margin-top: var(--go-ui-spacing-2xl); + padding-top: var(--go-ui-spacing-lg); + border-top: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + display: flex; + justify-content: center; +} + +.contributeButton { + background-color: var(--go-ui-color-red-90); + color: var(--go-ui-color-white); + padding: var(--go-ui-spacing-sm) var(--go-ui-spacing-xl); + border-radius: var(--go-ui-border-radius-lg); + font-weight: var(--go-ui-font-weight-medium); + transition: all var(--go-ui-duration-transition-medium) ease; + box-shadow: var(--go-ui-box-shadow-sm); + border: none; + cursor: pointer; + font-size: var(--go-ui-font-size-md); +} + +.contributeButton:hover { + background-color: var(--go-ui-color-red-hover); + transform: translateY(-2px); + box-shadow: var(--go-ui-box-shadow-md); +} + +.contributeButton:active { + transform: translateY(0); + box-shadow: var(--go-ui-box-shadow-sm); +} + +.gridLayout { + display: grid; + grid-template-columns: 1fr; + gap: var(--go-ui-spacing-2xl); +} + +@media (min-width: 1024px) { + .gridLayout { + grid-template-columns: 1fr 1fr; + } +} + +.detailsSection { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-lg); +} + +.loadingContainer { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + color: var(--go-ui-color-gray-60); + font-size: var(--go-ui-font-size-lg); + font-weight: var(--go-ui-font-weight-medium); +} + +.errorContainer { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + color: var(--go-ui-color-negative); + font-size: var(--go-ui-font-size-lg); + font-weight: var(--go-ui-font-weight-medium); +} + + + +/* Responsive adjustments */ +@media (max-width: 768px) { + .gridLayout { + gap: var(--go-ui-spacing-lg); + } + + .metadataTags { + gap: var(--go-ui-spacing-xs); + } + + .metadataTag { + font-size: var(--go-ui-font-size-xs); + padding: var(--go-ui-spacing-2xs) var(--go-ui-spacing-xs); + } + + .contributeButton { + padding: var(--go-ui-spacing-sm) var(--go-ui-spacing-lg); + font-size: var(--go-ui-font-size-sm); + } +} diff --git a/frontend/src/pages/MapDetailsPage/MapDetailPage.tsx b/frontend/src/pages/MapDetailsPage/MapDetailPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..751cf05aa787e599e9a8e66619a995064d8beb1c --- /dev/null +++ b/frontend/src/pages/MapDetailsPage/MapDetailPage.tsx @@ -0,0 +1,225 @@ +import { PageContainer, Button, Container, Spinner } from '@ifrc-go/ui'; +import { useState, useEffect } from 'react'; +import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; +import styles from './MapDetailPage.module.css'; + +interface MapOut { + image_id: string; + file_key: string; + image_url: string; + source: string; + event_type: string; + epsg: string; + image_type: string; + countries?: Array<{ + c_code: string; + label: string; + r_code: string; + }>; + captions?: Array<{ + title: string; + generated: string; + edited?: string; + cap_id?: string; + }>; +} + +export default function MapDetailPage() { + const { mapId } = useParams<{ mapId: string }>(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const captionId = searchParams.get('captionId'); + const [map, setMap] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + + useEffect(() => { + if (!mapId) { + setError('Map ID is required'); + setLoading(false); + return; + } + + fetch(`/api/images/${mapId}`) + .then(response => { + if (!response.ok) { + throw new Error('Map not found'); + } + return response.json(); + }) + .then(data => { + setMap(data); + setLoading(false); + }) + .catch(err => { + setError(err.message); + setLoading(false); + }); + }, [mapId]); + + const handleContribute = () => { + if (!map) return; + + const url = captionId ? + `/upload?mapId=${map.image_id}&step=2&captionId=${captionId}` : + `/upload?mapId=${map.image_id}&step=2`; + navigate(url); + }; + + if (loading) { + return ( + +
+
+ +
Loading map details...
+
+
+
+ ); + } + + if (error || !map) { + return ( + +
+
+
⚠️
+
Unable to load map
+
{error || 'Map not found'}
+ +
+
+
+ ); + } + + return ( + +
+ +
+ +
+ {/* Image Section */} + +
+ {map.image_url ? ( + {map.file_key} + ) : ( +
+ No image available +
+ )} +
+
+ + {/* Details Section */} +
+ +
+ {map.captions && map.captions.length > 0 ? map.captions[0].title : '— no title —'} +
+
+ + +
+ + {map.source} + + + {map.event_type} + + + {map.epsg} + + + {map.image_type} + +
+
+ + +
+ {map.captions && map.captions.length > 0 ? ( + map.captions.map((caption, index) => ( +
+

{caption.edited || caption.generated}

+ {captionId && map.captions && map.captions[index] && + 'cap_id' in map.captions[index] && + map.captions[index].cap_id === captionId && ( +
+ ← This is the caption you selected +
+ )} +
+ )) + ) : ( +

— no caption yet —

+ )} +
+
+
+
+ + {/* Contribute Section */} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/MapDetailsPage/index.ts b/frontend/src/pages/MapDetailsPage/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..385792c83914c5860cdb267d1a07026e7ca6c351 --- /dev/null +++ b/frontend/src/pages/MapDetailsPage/index.ts @@ -0,0 +1 @@ +export { default } from './MapDetailPage'; diff --git a/frontend/src/pages/UploadPage.tsx b/frontend/src/pages/UploadPage.tsx deleted file mode 100644 index 65c537a198401308869e1ad512c006e571d9f377..0000000000000000000000000000000000000000 --- a/frontend/src/pages/UploadPage.tsx +++ /dev/null @@ -1,620 +0,0 @@ -import { useCallback, useState, useEffect, useRef } from 'react'; -import type { DragEvent } from 'react'; -import { - PageContainer, Heading, Button, - SelectInput, MultiSelectInput, Container, IconButton, TextInput, TextArea, Spinner, -} from '@ifrc-go/ui'; -import { - UploadCloudLineIcon, - ArrowRightLineIcon, - DeleteBinLineIcon, -} from '@ifrc-go/icons'; -import { Link, useSearchParams } from 'react-router-dom'; - -const SELECTED_MODEL_KEY = 'selectedVlmModel'; - -export default function UploadPage() { - const [searchParams] = useSearchParams(); - const [step, setStep] = useState<1 | 2 | 3>(1); - const [isLoading, setIsLoading] = useState(false); - const stepRef = useRef(step); - const uploadedImageIdRef = useRef(null); - const [preview, setPreview] = useState(null); - /* ---------------- local state ----------------- */ - - const [file, setFile] = useState(null); - const [source, setSource] = useState(''); - const [type, setType] = useState(''); - const [epsg, setEpsg] = useState(''); - const [imageType, setImageType] = useState(''); - const [countries, setCountries] = useState([]); - const [title, setTitle] = useState(''); - - // Metadata options from database - const [sources, setSources] = useState<{s_code: string, label: string}[]>([]); - const [types, setTypes] = useState<{t_code: string, label: string}[]>([]); - const [spatialReferences, setSpatialReferences] = useState<{epsg: string, srid: string, proj4: string, wkt: string}[]>([]); - const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]); - const [countriesOptions, setCountriesOptions] = useState<{c_code: string, label: string, r_code: string}[]>([]); - - // Track uploaded image data for potential deletion - const [uploadedImageId, setUploadedImageId] = useState(null); - - // Keep refs updated with current values - stepRef.current = step; - uploadedImageIdRef.current = uploadedImageId; - - // Wrapper functions to handle OptionKey to string conversion - const handleSourceChange = (value: any) => setSource(String(value)); - const handleTypeChange = (value: any) => setType(String(value)); - const handleEpsgChange = (value: any) => setEpsg(String(value)); - const handleImageTypeChange = (value: any) => setImageType(String(value)); - const handleCountriesChange = (value: any) => setCountries(Array.isArray(value) ? value.map(String) : []); - - // Fetch metadata options on component mount - useEffect(() => { - Promise.all([ - fetch('/api/sources').then(r => r.json()), - fetch('/api/types').then(r => r.json()), - fetch('/api/spatial-references').then(r => r.json()), - fetch('/api/image-types').then(r => r.json()), - fetch('/api/countries').then(r => r.json()), - fetch('/api/models').then(r => r.json()) - ]).then(([sourcesData, typesData, spatialData, imageTypesData, countriesData]) => { - setSources(sourcesData); - setTypes(typesData); - setSpatialReferences(spatialData); - setImageTypes(imageTypesData); - setCountriesOptions(countriesData); - - // Set default values - if (sourcesData.length > 0) setSource(sourcesData[0].s_code); - if (typesData.length > 0) setType(typesData[0].t_code); - if (spatialData.length > 0) setEpsg(spatialData[0].epsg); - if (imageTypesData.length > 0) setImageType(imageTypesData[0].image_type); - }); - }, []); - - useEffect(() => { - const handleBeforeUnload = () => { - if (uploadedImageIdRef.current && stepRef.current !== 3) { - fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" }).catch(console.error); - } - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload); - if (uploadedImageIdRef.current && stepRef.current !== 3) { - fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" }).catch(console.error); - } - }; - }, []); - - const [captionId, setCaptionId] = useState(null); - const [imageUrl, setImageUrl] = useState(null); - const [draft, setDraft] = useState(''); - - // Handle URL parameters for direct step 2 navigation - useEffect(() => { - const mapId = searchParams.get('mapId'); - const stepParam = searchParams.get('step'); - - if (mapId && stepParam === '2') { - // Load the map data and start at step 2 - fetch(`/api/images/${mapId}`) - .then(response => response.json()) - .then(mapData => { - setImageUrl(mapData.image_url); - setSource(mapData.source); - setType(mapData.type); - setEpsg(mapData.epsg); - setImageType(mapData.image_type); - - return fetch(`/api/images/${mapId}/caption`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - title: 'Generated Caption', - prompt: 'Describe this crisis map in detail', - ...(localStorage.getItem(SELECTED_MODEL_KEY) && { - model_name: localStorage.getItem(SELECTED_MODEL_KEY)! - }) - }) - }); - }) - .then(capResponse => capResponse.json()) - .then(capData => { - setCaptionId(capData.cap_id); - setDraft(capData.edited || capData.generated); - setStep(2); - }) - .catch(err => { - console.error('Failed to load map data:', err); - alert('Failed to load map data. Please try again.'); - }); - } - }, [searchParams]); - - const resetToStep1 = () => { - setStep(1); - setFile(null); - setPreview(null); - setImageUrl(null); - setCaptionId(null); - setDraft(''); - setTitle(''); - setScores({ accuracy: 50, context: 50, usability: 50 }); - setUploadedImageId(null); - }; - const [scores, setScores] = useState({ - accuracy: 50, - context: 50, - usability: 50, - }); - - - - /* ---- drag-and-drop + file-picker handlers -------------------------- */ - const onDrop = useCallback((e: DragEvent) => { - e.preventDefault(); - const dropped = e.dataTransfer.files?.[0]; - if (dropped) setFile(dropped); - }, []); - - const onFileChange = useCallback((file: File | undefined, _name: string) => { - if (file) setFile(file); - }, []); - - // blob URL / preview - useEffect(() => { - if (!file) { - setPreview(null); - return; - } - const url = URL.createObjectURL(file); - setPreview(url); - return () => URL.revokeObjectURL(url); - }, [file]); - - - async function readJsonSafely(res: Response): Promise { - const text = await res.text(); // get raw body - try { - return text ? JSON.parse(text) : {}; // valid JSON or empty object - } catch { - return { error: text }; // plain text fallback - } - } - - function handleApiError(err: any, operation: string) { - console.error(`${operation} failed:`, err); - const message = err.message || `Failed to ${operation.toLowerCase()}`; - alert(message); - } - - /* ---- generate handler --------------------------------------------- */ - async function handleGenerate() { - if (!file) return; - - setIsLoading(true); - - const fd = new FormData(); - fd.append('file', file); - fd.append('source', source); - fd.append('type', type); - fd.append('epsg', epsg); - fd.append('image_type', imageType); - countries.forEach((c) => fd.append('countries', c)); - - const modelName = localStorage.getItem(SELECTED_MODEL_KEY); - if (modelName) { - fd.append('model_name', modelName); - } - - try { - /* 1) upload */ - const mapRes = await fetch('/api/images/', { method: 'POST', body: fd }); - const mapJson = await readJsonSafely(mapRes); - if (!mapRes.ok) throw new Error(mapJson.error || 'Upload failed'); - setImageUrl(mapJson.image_url); - - const mapIdVal = mapJson.image_id; - if (!mapIdVal) throw new Error('Upload failed: image_id not found'); - setUploadedImageId(mapIdVal); - - /* 2) caption */ - const capRes = await fetch( - `/api/images/${mapIdVal}/caption`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - title: title || 'Generated Caption', - prompt: 'Analyze this crisis map and provide a detailed description of the emergency situation, affected areas, and key information shown in the map.', - ...(modelName && { model_name: modelName }) - }) - }, - ); - const capJson = await readJsonSafely(capRes); - if (!capRes.ok) throw new Error(capJson.error || 'Caption failed'); - setCaptionId(capJson.cap_id); - console.log(capJson); - - /* 3) Extract and apply metadata from AI response */ - const extractedMetadata = capJson.raw_json?.extracted_metadata; - if (extractedMetadata) { - console.log('Extracted metadata:', extractedMetadata); - - // Apply AI-extracted metadata to form fields - if (extractedMetadata.title) setTitle(extractedMetadata.title); - if (extractedMetadata.source) setSource(extractedMetadata.source); - if (extractedMetadata.type) setType(extractedMetadata.type); - if (extractedMetadata.epsg) setEpsg(extractedMetadata.epsg); - if (extractedMetadata.countries && Array.isArray(extractedMetadata.countries)) { - setCountries(extractedMetadata.countries); - } - } - - /* 4) continue workflow */ - setDraft(capJson.generated); - setStep(2); - } catch (err) { - handleApiError(err, 'Upload'); - } finally { - setIsLoading(false); - } - } - - /* ---- submit handler --------------------------------------------- */ - async function handleSubmit() { - if (!captionId) return alert("No caption to submit"); - - try { - // 1. Update image metadata - const metadataBody = { - source: source, - type: type, - epsg: epsg, - image_type: imageType, - countries: countries, - }; - const metadataRes = await fetch(`/api/images/${uploadedImageId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(metadataBody), - }); - const metadataJson = await readJsonSafely(metadataRes); - if (!metadataRes.ok) throw new Error(metadataJson.error || "Metadata update failed"); - - // 2. Update caption - const captionBody = { - title: title, - edited: draft || '', // Use draft if available, otherwise empty string - accuracy: scores.accuracy, - context: scores.context, - usability: scores.usability, - }; - const captionRes = await fetch(`/api/captions/${captionId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(captionBody), - }); - const captionJson = await readJsonSafely(captionRes); - if (!captionRes.ok) throw new Error(captionJson.error || "Caption update failed"); - - // Clear uploaded IDs since submission was successful - setUploadedImageId(null); - setStep(3); - } catch (err) { - handleApiError(err, 'Submit'); - } - } - - /* ---- delete handler --------------------------------------------- */ - async function handleDelete() { - if (!uploadedImageId) return; - - if (confirm("Are you sure you want to delete this uploaded image? This action cannot be undone.")) { - try { - // Delete the image (this will cascade delete the caption) - const res = await fetch(`/api/images/${uploadedImageId}`, { - method: "DELETE", - }); - - if (!res.ok) { - const json = await readJsonSafely(res); - throw new Error(json.error || "Delete failed"); - } - - // Reset to step 1 - resetToStep1(); - } catch (err) { - handleApiError(err, 'Delete'); - } - } - } - - /* ------------------------------------------------------------------- */ - return ( - -
- {/* Drop-zone */} - {step === 1 && ( - -
-

- This app evaluates how well multimodal AI models turn emergency maps - into meaningful text. Upload your map, let the AI generate a - description, then review and rate the result based on your expertise. -

- - {/* "More »" link */} -
- - More - -
- -
e.preventDefault()} - onDrop={onDrop} - > - {file && preview ? ( -
-
- File preview -
-

- {file.name} -

-
- ) : ( - <> - -

Drag & Drop a file here

-

or

- - )} - - {/* File-picker button - always visible */} - -
-
-
- )} - - {/* Loading state */} - {isLoading && ( -
- -

Generating caption...

-
- )} - - {/* Generate button */} - {step === 1 && !isLoading && ( -
- -
- )} - - {step === 2 && imageUrl && ( - -
-
- Uploaded map preview -
-
-
- )} - - {step === 2 && ( -
- {/* ────── METADATA FORM ────── */} - -
-
- setTitle(value || '')} - placeholder="Enter a title for this map..." - required - /> -
- o.s_code} - labelSelector={(o) => o.label} - required - /> - o.t_code} - labelSelector={(o) => o.label} - required - /> - o.epsg} - labelSelector={(o) => `${o.srid} (EPSG:${o.epsg})`} - required - /> - o.image_type} - labelSelector={(o) => o.label} - required - /> - o.c_code} - labelSelector={(o) => o.label} - placeholder="Select one or more" - /> -
-
- - {/* ────── RATING SLIDERS ────── */} - -
-

How well did the AI perform on the task?

- {(['accuracy', 'context', 'usability'] as const).map((k) => ( -
- - - setScores((s) => ({ ...s, [k]: Number(e.target.value) })) - } - className="w-full accent-ifrcRed" - /> - {scores[k]} -
- ))} -
-
- - {/* ────── AI‑GENERATED CAPTION ────── */} - -
-