Add bilingual i18n UI and lighten component shadows

This commit is contained in:
Bernard Ngandu
2025-10-10 10:30:28 +02:00
parent 8f4b954af8
commit 0422becdd0
20 changed files with 622 additions and 141 deletions
+87 -1
View File
@@ -15,11 +15,13 @@
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.5.3",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^16.0.0",
"tailwind-merge": "^3.3.1",
"tslib": "^2.8.1"
},
@@ -291,6 +293,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -2922,6 +2933,46 @@
"node": ">= 0.4"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.5.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.3.tgz",
"integrity": "sha512-joFqorDeQ6YpIXni944upwnuHBf5IoPMuqAchGVeQLdWC2JOjxgM9V8UGLhNIIH/Q8QleRxIi0BSRQehSrDLcg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4007,6 +4058,32 @@
"react": "^19.2.0"
}
},
"node_modules/react-i18next": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz",
"integrity": "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 25.5.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4629,7 +4706,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4869,6 +4946,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2
View File
@@ -17,11 +17,13 @@
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.5.3",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^16.0.0",
"tailwind-merge": "^3.3.1",
"tslib": "^2.8.1"
},
+73 -60
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AppHeader } from '@/components/layout/AppHeader'
import { ActivityPanel } from '@/components/panels/ActivityPanel'
@@ -19,33 +20,28 @@ import { useHotspotFeed } from '@/hooks/useHotspotFeed'
import { useLeafletHeatmap } from '@/hooks/useLeafletHeatmap'
import { useUserLocation } from '@/hooks/useUserLocation'
import { distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils'
import type { FeedStatus, LatLng } from '@/types/api'
import type { LatLng } from '@/types/api'
const RADIUS_KM = 1
function getStatusLabel(status: FeedStatus): string {
switch (status) {
case 'loading':
return 'Syncing map'
case 'posting':
return 'Sending signal'
case 'refreshing':
return 'Updating heat'
case 'error':
return 'Offline'
default:
return 'Live feed'
}
}
export default function App() {
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isConfirming, setIsConfirming] = useState(false)
const { t, i18n } = useTranslation()
const locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US'
const distanceFormatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
[locale],
)
const {
status,
errorMessage,
error,
submitPoint,
fetchSnapshot,
selectVisibleDensity,
@@ -88,21 +84,22 @@ export default function App() {
return myLatestPoint
}, [myLatestPoint, userLocation])
const statusLabel = getStatusLabel(status)
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated) : 'never'
const statusLabel = t(`status.${status}`)
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated, locale) : t('common.never')
const isLoading = status === 'loading'
const isPosting = status === 'posting'
const isRefreshing = status === 'refreshing'
const hasLocation = Boolean(userLocation)
const showLocationCta = !hasLocation || Boolean(locationError)
const locationHint = locationError
? locationError
const translatedLocationError = locationError ? t(locationError) : null
const locationHint = translatedLocationError
? translatedLocationError
: hasLocation
? `Showing reports within ${RADIUS_KM}km of you.`
? t('location.hint.showing', { radius: RADIUS_KM })
: isRequestingLocation
? 'Fetching your location…'
: 'Allow location access to view nearby reports.'
? t('location.hint.requesting')
: t('location.hint.allow')
const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({
heatCells: visibleDensity,
@@ -159,16 +156,25 @@ export default function App() {
[...visibleDensity]
.sort((a, b) => b.intensity - a.intensity)
.slice(0, 5)
.map((cell, index) => ({
id: `${cell.lat}-${cell.lng}-${index}`,
title: `Hotspot #${index + 1}`,
subtitle: hasLocation
? `${distanceInKm(userLocation!, cell).toFixed(2)}km away · ${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°`
: `${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°`,
intensity: cell.intensity,
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15),
})),
[visibleDensity, hasLocation, userLocation, focusOn],
.map((cell, index) => {
const coordinates = t('common.coordinates', {
lat: formatCoordinate(cell.lat, locale),
lng: formatCoordinate(cell.lng, locale),
})
return {
id: `${cell.lat}-${cell.lng}-${index}`,
title: t('hotspots.itemTitle', { index: index + 1 }),
subtitle: hasLocation
? t('hotspots.itemSubtitleWithDistance', {
distance: `${distanceFormatter.format(distanceInKm(userLocation!, cell))} km`,
coordinates,
})
: t('hotspots.itemSubtitle', { coordinates }),
intensity: cell.intensity,
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15),
}
}),
[visibleDensity, hasLocation, userLocation, focusOn, distanceFormatter, t, locale],
)
const recentActivity = useMemo(
@@ -176,21 +182,30 @@ export default function App() {
[...visiblePoints]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 8)
.map((point) => ({
id: point.id,
title: `${formatCoordinate(point.lat)}°, ${formatCoordinate(point.lng)}°`,
subtitle: `User ${point.userKey.slice(0, 4).toUpperCase()}`,
timestampLabel: formatRelativeTime(point.createdAt),
distanceLabel: hasLocation
? `${distanceInKm(userLocation!, point).toFixed(2)}km away`
: formatTimestamp(point.createdAt),
onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15),
})),
[visiblePoints, hasLocation, userLocation, focusOn],
.map((point) => {
const coordinates = t('common.coordinates', {
lat: formatCoordinate(point.lat, locale),
lng: formatCoordinate(point.lng, locale),
})
const distanceLabel = userLocation
? t('activityItem.distance', {
distance: `${distanceFormatter.format(distanceInKm(userLocation, point))} km`,
})
: formatTimestamp(point.createdAt, locale)
return {
id: point.id,
title: coordinates,
subtitle: t('activityItem.user', { id: point.userKey.slice(0, 4).toUpperCase() }),
timestampLabel: formatRelativeTime(point.createdAt, locale),
distanceLabel,
onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15),
}
}),
[visiblePoints, userLocation, focusOn, distanceFormatter, t, locale],
)
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat) : '--'
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng) : '--'
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : '--'
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : '--'
const isDialogDisabled = !pendingSpot || isConfirming
return (
@@ -216,8 +231,8 @@ export default function App() {
nearbySignals={localTotals.points}
uniqueContributors={localTotals.contributors}
lastUpdatedLabel={lastUpdatedLabel}
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt) : null}
errorMessage={errorMessage}
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt, locale) : null}
error={error}
onReport={handleManualReport}
onRetry={handleRefresh}
isPosting={isPosting || isConfirming}
@@ -231,7 +246,7 @@ export default function App() {
locationHint={locationHint}
cells={dangerCells}
/>
<ActivityPanel items={recentActivity} emptyMessage="No recent signals within your area yet." />
<ActivityPanel items={recentActivity} emptyMessage={t('activity.empty')} />
</div>
<div className="order-1 flex w-full flex-1 lg:order-2">
@@ -239,7 +254,7 @@ export default function App() {
containerRef={mapContainerRef}
isPosting={isPosting || isConfirming}
isLoading={isLoading}
confirmationHint={isConfirmOpen ? 'Confirm the new signal in the dialog to send it.' : null}
confirmationHint={isConfirmOpen ? t('map.confirmationHint') : null}
/>
</div>
</section>
@@ -257,26 +272,24 @@ export default function App() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm new signal</AlertDialogTitle>
<AlertDialogDescription>
You&apos;re about to publish a community alert at these coordinates. Double-check the spot before confirming.
</AlertDialogDescription>
<AlertDialogTitle>{t('dialog.confirmSignal.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('dialog.confirmSignal.description')}</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Latitude</span>
<span className="text-muted-foreground">{t('dialog.confirmSignal.latitude')}</span>
<span className="font-medium text-foreground">{confirmationLat}°</span>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-muted-foreground">Longitude</span>
<span className="text-muted-foreground">{t('dialog.confirmSignal.longitude')}</span>
<span className="font-medium text-foreground">{confirmationLng}°</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Signals are visible to travellers within {RADIUS_KM}km and help the community stay aware of hotspots.
{t('dialog.confirmSignal.reach', { radius: RADIUS_KM })}
</p>
<AlertDialogFooter>
<AlertDialogCancel disabled={isConfirming}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isConfirming}>{t('dialog.confirmSignal.cancel')}</AlertDialogCancel>
<AlertDialogAction
disabled={isDialogDisabled}
onClick={(event) => {
@@ -284,7 +297,7 @@ export default function App() {
handleConfirmSignal().catch(() => undefined)
}}
>
{isConfirming ? 'Sending' : 'Confirm signal'}
{isConfirming ? t('dialog.confirmSignal.sending') : t('dialog.confirmSignal.confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+37 -7
View File
@@ -1,7 +1,9 @@
import { Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { LanguageToggle } from '@/components/layout/LanguageToggle'
import { ThemeToggle } from '@/components/layout/ThemeToggle'
import type { FeedStatus } from '@/types/api'
@@ -32,6 +34,7 @@ export function AppHeader({
disableLocate,
disableMySignal,
}: AppHeaderProps) {
const { t } = useTranslation()
const isError = status === 'error'
return (
@@ -42,8 +45,8 @@ export function AppHeader({
<Flame className="h-5 w-5" />
</span>
<div className="flex flex-col">
<span className="text-lg font-semibold sm:text-xl">SignalMap</span>
<span className="text-xs text-muted-foreground sm:text-sm">Crowd signals around your route</span>
<span className="text-lg font-semibold sm:text-xl">{t('app.name')}</span>
<span className="text-xs text-muted-foreground sm:text-sm">{t('app.tagline')}</span>
</div>
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
@@ -62,20 +65,47 @@ export function AppHeader({
</span>
{statusLabel}
</span>
<span className="text-[10px] uppercase text-muted-foreground">{lastUpdatedLabel}</span>
<span className="text-[10px] uppercase text-muted-foreground">
{t('header.badge.updated', { time: lastUpdatedLabel })}
</span>
</Badge>
<Button variant="ghost" size="icon" onClick={onRefresh} disabled={disableRefresh} aria-label="Refresh now">
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={disableRefresh}
aria-label={t('header.actions.refresh')}
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button variant="secondary" size="icon" onClick={onFocusHeat} disabled={disableHeat} aria-label="Focus heatmap">
<Button
variant="secondary"
size="icon"
onClick={onFocusHeat}
disabled={disableHeat}
aria-label={t('header.actions.focusHeat')}
>
<Focus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onLocateUser} disabled={disableLocate} aria-label="Locate me">
<Button
variant="ghost"
size="icon"
onClick={onLocateUser}
disabled={disableLocate}
aria-label={t('header.actions.locate')}
>
<LocateFixed className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onFocusMySignal} disabled={disableMySignal} aria-label="My last signal">
<Button
variant="ghost"
size="icon"
onClick={onFocusMySignal}
disabled={disableMySignal}
aria-label={t('header.actions.mySignal')}
>
<MapPin className="h-4 w-4" />
</Button>
<LanguageToggle />
<ThemeToggle />
</div>
</div>
@@ -0,0 +1,28 @@
import { Globe } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
export function LanguageToggle() {
const { i18n, t } = useTranslation()
const current = i18n.language === 'fr' ? 'fr' : 'en'
const next = current === 'en' ? 'fr' : 'en'
const nextLabel = t(next === 'en' ? 'language.english' : 'language.french')
return (
<Button
variant="ghost"
size="sm"
onClick={() => {
i18n.changeLanguage(next).catch(() => undefined)
}}
aria-label={t('common.aria.language', { language: nextLabel })}
className="h-9 rounded-full border border-border/60 bg-background/80 px-3 backdrop-blur"
>
<span className="sr-only">{t('language.label')}</span>
<Globe className="h-4 w-4" />
<span className="ml-2 text-xs font-semibold uppercase text-muted-foreground">{current.toUpperCase()}</span>
</Button>
)
}
+4 -1
View File
@@ -1,16 +1,19 @@
import { Moon, Sun } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme'
export function ThemeToggle() {
const { toggleTheme, isDark } = useTheme()
const { t } = useTranslation()
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={isDark ? t('common.aria.theme.light') : t('common.aria.theme.dark')}
className="rounded-full border border-border/60 bg-background/80 backdrop-blur"
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
+5 -2
View File
@@ -1,4 +1,5 @@
import type { MutableRefObject } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
@@ -10,6 +11,8 @@ interface MapViewportProps {
}
export function MapViewport({ containerRef, isPosting, isLoading, confirmationHint }: MapViewportProps) {
const { t } = useTranslation()
return (
<div className="relative min-h-[360px] flex-1 overflow-hidden rounded-3xl border border-border/50 bg-muted/40 shadow-inner">
<div ref={containerRef} className={cn('absolute inset-0 z-0', 'leaflet-wrapper')} />
@@ -18,12 +21,12 @@ export function MapViewport({ containerRef, isPosting, isLoading, confirmationHi
{(isPosting || isLoading) && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="rounded-full border border-border/60 bg-background/80 px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground backdrop-blur">
{isPosting ? 'Sending your signal…' : 'Syncing map…'}
{isPosting ? t('map.posting') : t('map.loading')}
</span>
</div>
)}
{confirmationHint && (
<div className="pointer-events-none absolute bottom-6 left-1/2 z-20 w-[90%] max-w-sm -translate-x-1/2 rounded-full border border-border/70 bg-background/90 px-4 py-2 text-xs text-muted-foreground shadow">
<div className="pointer-events-none absolute bottom-6 left-1/2 z-20 w-[90%] max-w-sm -translate-x-1/2 rounded-full border border-border/70 bg-background/90 px-4 py-2 text-xs text-muted-foreground shadow-sm">
{confirmationHint}
</div>
)}
@@ -1,4 +1,5 @@
import { Activity, ArrowRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -20,14 +21,16 @@ interface ActivityPanelProps {
}
export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) {
const { t } = useTranslation()
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-primary" />
Live community pings
{t('activity.title')}
</CardTitle>
<CardDescription>Latest activity reported by nearby contributors.</CardDescription>
<CardDescription>{t('activity.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{items.length === 0 && <p className="text-sm text-muted-foreground">{emptyMessage}</p>}
@@ -48,7 +51,7 @@ export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) {
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{item.distanceLabel}</span>
<Button variant="ghost" size="sm" className="h-8 gap-2 text-xs" onClick={item.onFocus}>
View
{t('activity.view')}
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</div>
@@ -1,4 +1,5 @@
import { Flame, MapPin } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -21,19 +22,21 @@ interface HotspotStatsPanelProps {
}
export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }: HotspotStatsPanelProps) {
const { t } = useTranslation()
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Flame className="h-5 w-5 text-primary" />
Danger zone intel
{t('hotspots.title')}
</CardTitle>
<CardDescription>Highest intensity heat within {radiusKm}km.</CardDescription>
<CardDescription>{t('hotspots.description', { radius: radiusKm })}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!hasLocation && <p className="text-sm text-muted-foreground">{locationHint}</p>}
{hasLocation && cells.length === 0 && (
<p className="text-sm text-muted-foreground">No active hotspots nearby. Tap the map to log a new signal.</p>
<p className="text-sm text-muted-foreground">{t('hotspots.empty')}</p>
)}
{cells.length > 0 && (
<ScrollArea className="max-h-[280px] pr-2">
@@ -55,7 +58,7 @@ export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }
className="mt-2 w-full justify-center gap-2 text-xs"
onClick={cell.onFocus}
>
<MapPin className="h-3.5 w-3.5" /> Focus
<MapPin className="h-3.5 w-3.5" /> {t('hotspots.focus')}
</Button>
</li>
))}
+20 -11
View File
@@ -1,15 +1,17 @@
import { AlertCircle, Radio } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import type { FeedError } from '@/hooks/useHotspotFeed'
interface OverviewPanelProps {
nearbySignals: number
uniqueContributors: number
lastUpdatedLabel: string
mySignalLabel: string | null
errorMessage: string | null
error: FeedError | null
onReport: () => void
onRetry: () => void
isPosting: boolean
@@ -23,7 +25,7 @@ export function OverviewPanel({
uniqueContributors,
lastUpdatedLabel,
mySignalLabel,
errorMessage,
error,
onReport,
onRetry,
isPosting,
@@ -31,29 +33,32 @@ export function OverviewPanel({
showLocationCta,
disableReport,
}: OverviewPanelProps) {
const { t } = useTranslation()
const errorMessage = error ? t(error.key, error.values) : null
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Radio className="h-5 w-5 text-primary" />
Nearby coverage
{t('overview.title')}
</CardTitle>
<CardDescription>Signals refresh automatically every few seconds.</CardDescription>
<CardDescription>{t('overview.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<span className="text-xs uppercase text-muted-foreground">Signals</span>
<span className="text-xs uppercase text-muted-foreground">{t('overview.stats.signals')}</span>
<p className="text-xl font-semibold">{nearbySignals}</p>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<span className="text-xs uppercase text-muted-foreground">Contributors</span>
<span className="text-xs uppercase text-muted-foreground">{t('overview.stats.contributors')}</span>
<p className="text-xl font-semibold">{uniqueContributors}</p>
</div>
</div>
{mySignalLabel && (
<Badge variant="secondary" className="w-full justify-center rounded-full py-2 text-xs uppercase">
Your last signal {mySignalLabel}
{t('overview.badge', { time: mySignalLabel })}
</Badge>
)}
{errorMessage ? (
@@ -62,7 +67,7 @@ export function OverviewPanel({
<div className="space-y-2">
<p>{errorMessage}</p>
<Button variant="outline" size="sm" className="text-xs" onClick={onRetry}>
Try again
{t('overview.error.action')}
</Button>
</div>
</div>
@@ -70,12 +75,16 @@ export function OverviewPanel({
<p className="text-sm text-muted-foreground">{locationHint}</p>
)}
<Button className="w-full" onClick={onReport} disabled={isPosting || disableReport}>
{isPosting ? 'Sending…' : disableReport ? 'Waiting for location…' : 'Drop a signal manually'}
{isPosting
? t('overview.cta.sending')
: disableReport
? t('overview.cta.waiting')
: t('overview.cta.send')}
</Button>
{showLocationCta && !errorMessage && (
<p className="text-xs text-muted-foreground">Allow location permissions to personalise the feed.</p>
<p className="text-xs text-muted-foreground">{t('overview.locationPermission')}</p>
)}
<p className="text-[11px] uppercase text-muted-foreground">Last synced {lastUpdatedLabel}</p>
<p className="text-[11px] uppercase text-muted-foreground">{t('overview.lastSynced', { time: lastUpdatedLabel })}</p>
</CardContent>
</Card>
)
+1 -1
View File
@@ -33,7 +33,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/70 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/70 bg-background p-6 shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
<div
ref={ref}
className={cn(
'rounded-xl border border-border/60 bg-card/80 text-card-foreground shadow-xl shadow-black/30 backdrop-blur',
'rounded-xl border border-border/60 bg-card/80 text-card-foreground shadow-sm backdrop-blur',
className,
)}
{...props}
+29 -24
View File
@@ -1,5 +1,6 @@
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
@@ -35,30 +36,34 @@ interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetP
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'bottom', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 grid gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out',
side === 'bottom' && 'inset-x-0 bottom-0 rounded-t-2xl border-t',
side === 'top' && 'inset-x-0 top-0 rounded-b-2xl border-b',
side === 'left' && 'inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'right' && 'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
className,
)}
data-side={side}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-6 top-6 rounded-full border border-border/50 bg-background/60 px-3 py-1 text-xs font-medium text-muted-foreground transition hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background">
Close
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
>(({ side = 'bottom', className, children, ...props }, ref) => {
const { t } = useTranslation()
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 grid gap-4 bg-background p-6 shadow-sm transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out',
side === 'bottom' && 'inset-x-0 bottom-0 rounded-t-2xl border-t',
side === 'top' && 'inset-x-0 top-0 rounded-b-2xl border-b',
side === 'left' && 'inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'right' && 'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
className,
)}
data-side={side}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-6 top-6 rounded-full border border-border/50 bg-background/60 px-3 py-1 text-xs font-medium text-muted-foreground transition hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background">
{t('common.actions.close')}
<span className="sr-only">{t('common.aria.sheet.close')}</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
})
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+27 -10
View File
@@ -16,9 +16,14 @@ interface SubmitResult {
success: boolean
}
export interface FeedError {
key: string
values?: Record<string, unknown>
}
export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) {
const [status, setStatus] = useState<FeedStatus>('loading')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [error, setError] = useState<FeedError | null>(null)
const [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
const [rawDensity, setRawDensity] = useState<ApiDensityCell[]>([])
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
@@ -50,7 +55,7 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
try {
const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' })
if (!response.ok) {
throw new Error('Unable to reach the hotspot feed.')
throw new Error('feed-unavailable')
}
const data: ApiSnapshot = await response.json()
@@ -59,14 +64,15 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
setRawLatestByUser(data.latestByUser ?? [])
setClientKey(data.clientKey ?? null)
setLastUpdated(data.updatedAt ?? new Date().toISOString())
setErrorMessage(null)
setError(null)
initialLoadRef.current = false
const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
setStatusSafe(nextStatus)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.'
setErrorMessage(message)
const message = error instanceof Error ? error.message : null
const key = message === 'feed-unavailable' ? 'errors.feedUnavailable' : 'errors.feedUnknown'
setError({ key })
if (initialLoadRef.current) {
setStatusSafe('error')
} else if (previousStatus !== 'posting') {
@@ -104,16 +110,27 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
if (!response.ok) {
const payload = await response.json().catch(() => null)
const message = payload?.message ?? 'Unable to store your signal.'
throw new Error(message)
const message = payload?.message as string | undefined
if (message) {
setError({ key: 'errors.submitWithReason', values: { message } })
} else {
setError({ key: 'errors.submitUnavailable' })
}
setStatusSafe('error')
return { success: false }
}
await fetchSnapshot({ silent: true })
setError(null)
setStatusSafe('idle')
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.'
setErrorMessage(message)
const message = error instanceof Error ? error.message : null
if (message) {
setError({ key: 'errors.submitWithReason', values: { message } })
} else {
setError({ key: 'errors.submitUnknown' })
}
setStatusSafe('error')
return { success: false }
}
@@ -157,7 +174,7 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
return {
status,
errorMessage,
error,
submitPoint,
fetchSnapshot,
rawDensity,
+5 -5
View File
@@ -5,13 +5,13 @@ import type { LatLng } from '@/types/api'
function geolocationErrorMessage(error: GeolocationPositionError): string {
switch (error.code) {
case error.PERMISSION_DENIED:
return 'Location access denied. Enable it to view nearby pings.'
return 'location.error.permissionDenied'
case error.POSITION_UNAVAILABLE:
return 'Unable to determine your position. Try again.'
return 'location.error.unavailable'
case error.TIMEOUT:
return 'Timed out while fetching your location.'
return 'location.error.timeout'
default:
return 'Failed to retrieve your location.'
return 'location.error.generic'
}
}
@@ -33,7 +33,7 @@ export function useUserLocation() {
const start = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
setError('Geolocation is not supported in this browser.')
setError('location.error.unsupported')
setIsRequesting(false)
return
}
+27
View File
@@ -0,0 +1,27 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import enCommon from '@/locales/en/common.json'
import frCommon from '@/locales/fr/common.json'
const browserLanguage =
typeof window !== 'undefined' ? window.navigator.language.split('-')[0]?.toLowerCase() : 'en'
i18n
.use(initReactI18next)
.init({
resources: {
en: { common: enCommon },
fr: { common: frCommon },
},
lng: browserLanguage === 'fr' ? 'fr' : 'en',
fallbackLng: 'en',
defaultNS: 'common',
interpolation: {
escapeValue: false,
},
})
.catch(() => undefined)
export { i18n }
+12 -10
View File
@@ -10,46 +10,48 @@ export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
export function formatCoordinate(value: number): string {
const formatter = new Intl.NumberFormat('en-US', {
export function formatCoordinate(value: number, locale = 'en-US'): string {
const formatter = new Intl.NumberFormat(locale, {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
})
return formatter.format(value)
}
export function formatRelativeTime(dateIso: string): string {
export function formatRelativeTime(dateIso: string, locale = 'en-US'): string {
const date = new Date(dateIso)
const now = new Date()
const diff = Math.max(0, now.getTime() - date.getTime())
const seconds = Math.floor(diff / 1000)
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
if (seconds < 60) {
return `${seconds}s ago`
return rtf.format(-seconds, 'second')
}
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return `${minutes}m ago`
return rtf.format(-minutes, 'minute')
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
return rtf.format(-hours, 'hour')
}
const days = Math.floor(hours / 24)
if (days < 7) {
return `${days}d ago`
return rtf.format(-days, 'day')
}
const weeks = Math.floor(days / 7)
return `${weeks}w ago`
return rtf.format(-weeks, 'week')
}
export function formatTimestamp(dateIso: string): string {
export function formatTimestamp(dateIso: string, locale = 'en-US'): string {
const date = new Date(dateIso)
return new Intl.DateTimeFormat('en-US', {
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
hour: 'numeric',
+123
View File
@@ -0,0 +1,123 @@
{
"app": {
"name": "SignalMap",
"tagline": "Crowd signals around your route"
},
"status": {
"idle": "Live feed",
"loading": "Syncing map",
"posting": "Sending signal",
"refreshing": "Updating heat",
"error": "Offline"
},
"header": {
"badge": {
"updated": "{{time}}"
},
"actions": {
"refresh": "Refresh now",
"focusHeat": "Focus heatmap",
"locate": "Locate me",
"mySignal": "My last signal"
}
},
"overview": {
"title": "Nearby coverage",
"description": "Signals refresh automatically every few seconds.",
"stats": {
"signals": "Signals",
"contributors": "Contributors"
},
"badge": "Your last signal {{time}}",
"error": {
"action": "Try again"
},
"cta": {
"send": "Drop a signal manually",
"sending": "Sending…",
"waiting": "Waiting for location…"
},
"locationPermission": "Allow location permissions to personalise the feed.",
"lastSynced": "Last synced {{time}}"
},
"hotspots": {
"title": "Danger zone intel",
"description": "Highest intensity heat within {{radius}}km.",
"noLocation": "{{hint}}",
"empty": "No active hotspots nearby. Tap the map to log a new signal.",
"focus": "Focus",
"itemTitle": "Hotspot #{{index}}",
"itemSubtitleWithDistance": "{{distance}} · {{coordinates}}",
"itemSubtitle": "{{coordinates}}"
},
"activity": {
"title": "Live community pings",
"description": "Latest activity reported by nearby contributors.",
"empty": "No recent signals within your area yet.",
"view": "View"
},
"map": {
"posting": "Sending your signal…",
"loading": "Syncing map…",
"confirmationHint": "Confirm the new signal in the dialog to send it."
},
"dialog": {
"confirmSignal": {
"title": "Confirm new signal",
"description": "You're about to publish a community alert at these coordinates. Double-check the spot before confirming.",
"latitude": "Latitude",
"longitude": "Longitude",
"reach": "Signals are visible to travellers within {{radius}}km and help the community stay aware of hotspots.",
"cancel": "Cancel",
"confirm": "Confirm signal",
"sending": "Sending…"
}
},
"location": {
"hint": {
"requesting": "Fetching your location…",
"allow": "Allow location access to view nearby reports.",
"showing": "Showing reports within {{radius}}km of you."
},
"error": {
"permissionDenied": "Location access denied. Enable it to view nearby pings.",
"unavailable": "Unable to determine your position. Try again.",
"timeout": "Timed out while fetching your location.",
"generic": "Failed to retrieve your location.",
"unsupported": "Geolocation is not supported in this browser."
}
},
"activityItem": {
"user": "User {{id}}",
"distance": "{{distance}} away"
},
"common": {
"coordinates": "{{lat}}°, {{lng}}°",
"never": "never",
"actions": {
"close": "Close"
},
"aria": {
"theme": {
"light": "Switch to light mode",
"dark": "Switch to dark mode"
},
"language": "Change language to {{language}}",
"sheet": {
"close": "Close panel"
}
}
},
"language": {
"label": "Language",
"english": "English",
"french": "Français"
},
"errors": {
"feedUnavailable": "Unable to reach the hotspot feed.",
"feedUnknown": "Unknown error while loading hotspots.",
"submitUnavailable": "Unable to store your signal.",
"submitUnknown": "Something went wrong while saving your signal.",
"submitWithReason": "{{message}}"
}
}
+123
View File
@@ -0,0 +1,123 @@
{
"app": {
"name": "SignalMap",
"tagline": "Alertes de la communauté sur votre itinéraire"
},
"status": {
"idle": "Flux en direct",
"loading": "Synchronisation de la carte",
"posting": "Envoi du signal",
"refreshing": "Mise à jour de la chaleur",
"error": "Hors ligne"
},
"header": {
"badge": {
"updated": "{{time}}"
},
"actions": {
"refresh": "Actualiser",
"focusHeat": "Centrer la chaleur",
"locate": "Me localiser",
"mySignal": "Mon dernier signal"
}
},
"overview": {
"title": "Couverture à proximité",
"description": "Les signaux se mettent à jour automatiquement toutes les quelques secondes.",
"stats": {
"signals": "Signaux",
"contributors": "Contributeurs"
},
"badge": "Votre dernier signal {{time}}",
"error": {
"action": "Réessayer"
},
"cta": {
"send": "Déposer un signal manuellement",
"sending": "Envoi…",
"waiting": "En attente de la localisation…"
},
"locationPermission": "Autorisez la localisation pour personnaliser le flux.",
"lastSynced": "Dernière synchronisation {{time}}"
},
"hotspots": {
"title": "Infos sur les zones à risque",
"description": "Chaleur la plus intense dans un rayon de {{radius}} km.",
"noLocation": "{{hint}}",
"empty": "Aucune zone active à proximité. Touchez la carte pour enregistrer un nouveau signal.",
"focus": "Centrer",
"itemTitle": "Zone chaude n°{{index}}",
"itemSubtitleWithDistance": "{{distance}} · {{coordinates}}",
"itemSubtitle": "{{coordinates}}"
},
"activity": {
"title": "Alertes de la communauté",
"description": "Dernières activités signalées à proximité.",
"empty": "Aucun signal récent dans votre secteur.",
"view": "Voir"
},
"map": {
"posting": "Envoi de votre signal…",
"loading": "Synchronisation de la carte…",
"confirmationHint": "Confirmez le nouveau signal dans la fenêtre pour l'envoyer."
},
"dialog": {
"confirmSignal": {
"title": "Confirmer le nouveau signal",
"description": "Vous êtes sur le point de publier une alerte communautaire à ces coordonnées. Vérifiez l'emplacement avant de confirmer.",
"latitude": "Latitude",
"longitude": "Longitude",
"reach": "Les signaux sont visibles par les voyageurs dans un rayon de {{radius}} km et aident la communauté à rester informée des zones à risque.",
"cancel": "Annuler",
"confirm": "Confirmer le signal",
"sending": "Envoi…"
}
},
"location": {
"hint": {
"requesting": "Récupération de votre localisation…",
"allow": "Autorisez l'accès à la localisation pour voir les signalements à proximité.",
"showing": "Affichage des signalements dans un rayon de {{radius}} km autour de vous."
},
"error": {
"permissionDenied": "Accès à la localisation refusé. Activez-le pour voir les alertes proches.",
"unavailable": "Impossible de déterminer votre position. Réessayez.",
"timeout": "Délai dépassé lors de la récupération de votre localisation.",
"generic": "Échec de la récupération de votre localisation.",
"unsupported": "La géolocalisation n'est pas prise en charge par ce navigateur."
}
},
"activityItem": {
"user": "Utilisateur {{id}}",
"distance": "À {{distance}}"
},
"common": {
"coordinates": "{{lat}}°, {{lng}}°",
"never": "jamais",
"actions": {
"close": "Fermer"
},
"aria": {
"theme": {
"light": "Passer en mode clair",
"dark": "Passer en mode sombre"
},
"language": "Changer la langue pour {{language}}",
"sheet": {
"close": "Fermer le panneau"
}
}
},
"language": {
"label": "Langue",
"english": "Anglais",
"french": "Français"
},
"errors": {
"feedUnavailable": "Impossible d'atteindre le flux de signaux.",
"feedUnknown": "Erreur inconnue lors du chargement des signaux.",
"submitUnavailable": "Impossible d'enregistrer votre signal.",
"submitUnknown": "Une erreur est survenue lors de l'enregistrement de votre signal.",
"submitWithReason": "{{message}}"
}
}
+5 -1
View File
@@ -1,12 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { I18nextProvider } from 'react-i18next'
import 'leaflet/dist/leaflet.css'
import '@/index.css'
import App from '@/App.tsx'
import { i18n } from '@/lib/i18n'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<I18nextProvider i18n={i18n}>
<App />
</I18nextProvider>
</StrictMode>,
)