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", "@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^25.5.3",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0", "leaflet.heat": "^0.2.0",
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-i18next": "^16.0.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
@@ -291,6 +293,15 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -2922,6 +2933,46 @@
"node": ">= 0.4" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4007,6 +4058,32 @@
"react": "^19.2.0" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4629,7 +4706,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -4869,6 +4946,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "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", "@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^25.5.3",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0", "leaflet.heat": "^0.2.0",
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-i18next": "^16.0.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
+73 -60
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AppHeader } from '@/components/layout/AppHeader' import { AppHeader } from '@/components/layout/AppHeader'
import { ActivityPanel } from '@/components/panels/ActivityPanel' import { ActivityPanel } from '@/components/panels/ActivityPanel'
@@ -19,33 +20,28 @@ import { useHotspotFeed } from '@/hooks/useHotspotFeed'
import { useLeafletHeatmap } from '@/hooks/useLeafletHeatmap' import { useLeafletHeatmap } from '@/hooks/useLeafletHeatmap'
import { useUserLocation } from '@/hooks/useUserLocation' import { useUserLocation } from '@/hooks/useUserLocation'
import { distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils' 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 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() { export default function App() {
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null) const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
const [isConfirmOpen, setIsConfirmOpen] = useState(false) const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isConfirming, setIsConfirming] = 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 { const {
status, status,
errorMessage, error,
submitPoint, submitPoint,
fetchSnapshot, fetchSnapshot,
selectVisibleDensity, selectVisibleDensity,
@@ -88,21 +84,22 @@ export default function App() {
return myLatestPoint return myLatestPoint
}, [myLatestPoint, userLocation]) }, [myLatestPoint, userLocation])
const statusLabel = getStatusLabel(status) const statusLabel = t(`status.${status}`)
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated) : 'never' const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated, locale) : t('common.never')
const isLoading = status === 'loading' const isLoading = status === 'loading'
const isPosting = status === 'posting' const isPosting = status === 'posting'
const isRefreshing = status === 'refreshing' const isRefreshing = status === 'refreshing'
const hasLocation = Boolean(userLocation) const hasLocation = Boolean(userLocation)
const showLocationCta = !hasLocation || Boolean(locationError) const showLocationCta = !hasLocation || Boolean(locationError)
const locationHint = locationError const translatedLocationError = locationError ? t(locationError) : null
? locationError const locationHint = translatedLocationError
? translatedLocationError
: hasLocation : hasLocation
? `Showing reports within ${RADIUS_KM}km of you.` ? t('location.hint.showing', { radius: RADIUS_KM })
: isRequestingLocation : isRequestingLocation
? 'Fetching your location…' ? t('location.hint.requesting')
: 'Allow location access to view nearby reports.' : t('location.hint.allow')
const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({ const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({
heatCells: visibleDensity, heatCells: visibleDensity,
@@ -159,16 +156,25 @@ export default function App() {
[...visibleDensity] [...visibleDensity]
.sort((a, b) => b.intensity - a.intensity) .sort((a, b) => b.intensity - a.intensity)
.slice(0, 5) .slice(0, 5)
.map((cell, index) => ({ .map((cell, index) => {
id: `${cell.lat}-${cell.lng}-${index}`, const coordinates = t('common.coordinates', {
title: `Hotspot #${index + 1}`, lat: formatCoordinate(cell.lat, locale),
subtitle: hasLocation lng: formatCoordinate(cell.lng, locale),
? `${distanceInKm(userLocation!, cell).toFixed(2)}km away · ${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°` })
: `${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°`, return {
intensity: cell.intensity, id: `${cell.lat}-${cell.lng}-${index}`,
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15), title: t('hotspots.itemTitle', { index: index + 1 }),
})), subtitle: hasLocation
[visibleDensity, hasLocation, userLocation, focusOn], ? 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( const recentActivity = useMemo(
@@ -176,21 +182,30 @@ export default function App() {
[...visiblePoints] [...visiblePoints]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 8) .slice(0, 8)
.map((point) => ({ .map((point) => {
id: point.id, const coordinates = t('common.coordinates', {
title: `${formatCoordinate(point.lat)}°, ${formatCoordinate(point.lng)}°`, lat: formatCoordinate(point.lat, locale),
subtitle: `User ${point.userKey.slice(0, 4).toUpperCase()}`, lng: formatCoordinate(point.lng, locale),
timestampLabel: formatRelativeTime(point.createdAt), })
distanceLabel: hasLocation const distanceLabel = userLocation
? `${distanceInKm(userLocation!, point).toFixed(2)}km away` ? t('activityItem.distance', {
: formatTimestamp(point.createdAt), distance: `${distanceFormatter.format(distanceInKm(userLocation, point))} km`,
onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15), })
})), : formatTimestamp(point.createdAt, locale)
[visiblePoints, hasLocation, userLocation, focusOn], 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 confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : '--'
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng) : '--' const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : '--'
const isDialogDisabled = !pendingSpot || isConfirming const isDialogDisabled = !pendingSpot || isConfirming
return ( return (
@@ -216,8 +231,8 @@ export default function App() {
nearbySignals={localTotals.points} nearbySignals={localTotals.points}
uniqueContributors={localTotals.contributors} uniqueContributors={localTotals.contributors}
lastUpdatedLabel={lastUpdatedLabel} lastUpdatedLabel={lastUpdatedLabel}
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt) : null} mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt, locale) : null}
errorMessage={errorMessage} error={error}
onReport={handleManualReport} onReport={handleManualReport}
onRetry={handleRefresh} onRetry={handleRefresh}
isPosting={isPosting || isConfirming} isPosting={isPosting || isConfirming}
@@ -231,7 +246,7 @@ export default function App() {
locationHint={locationHint} locationHint={locationHint}
cells={dangerCells} cells={dangerCells}
/> />
<ActivityPanel items={recentActivity} emptyMessage="No recent signals within your area yet." /> <ActivityPanel items={recentActivity} emptyMessage={t('activity.empty')} />
</div> </div>
<div className="order-1 flex w-full flex-1 lg:order-2"> <div className="order-1 flex w-full flex-1 lg:order-2">
@@ -239,7 +254,7 @@ export default function App() {
containerRef={mapContainerRef} containerRef={mapContainerRef}
isPosting={isPosting || isConfirming} isPosting={isPosting || isConfirming}
isLoading={isLoading} isLoading={isLoading}
confirmationHint={isConfirmOpen ? 'Confirm the new signal in the dialog to send it.' : null} confirmationHint={isConfirmOpen ? t('map.confirmationHint') : null}
/> />
</div> </div>
</section> </section>
@@ -257,26 +272,24 @@ export default function App() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm new signal</AlertDialogTitle> <AlertDialogTitle>{t('dialog.confirmSignal.title')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>{t('dialog.confirmSignal.description')}</AlertDialogDescription>
You&apos;re about to publish a community alert at these coordinates. Double-check the spot before confirming.
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 text-sm"> <div className="rounded-2xl border border-border/60 bg-muted/40 p-4 text-sm">
<div className="flex items-center justify-between"> <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> <span className="font-medium text-foreground">{confirmationLat}°</span>
</div> </div>
<div className="mt-3 flex items-center justify-between"> <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> <span className="font-medium text-foreground">{confirmationLng}°</span>
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground"> <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> </p>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isConfirming}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={isConfirming}>{t('dialog.confirmSignal.cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
disabled={isDialogDisabled} disabled={isDialogDisabled}
onClick={(event) => { onClick={(event) => {
@@ -284,7 +297,7 @@ export default function App() {
handleConfirmSignal().catch(() => undefined) handleConfirmSignal().catch(() => undefined)
}} }}
> >
{isConfirming ? 'Sending' : 'Confirm signal'} {isConfirming ? t('dialog.confirmSignal.sending') : t('dialog.confirmSignal.confirm')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+37 -7
View File
@@ -1,7 +1,9 @@
import { Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react' import { Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { LanguageToggle } from '@/components/layout/LanguageToggle'
import { ThemeToggle } from '@/components/layout/ThemeToggle' import { ThemeToggle } from '@/components/layout/ThemeToggle'
import type { FeedStatus } from '@/types/api' import type { FeedStatus } from '@/types/api'
@@ -32,6 +34,7 @@ export function AppHeader({
disableLocate, disableLocate,
disableMySignal, disableMySignal,
}: AppHeaderProps) { }: AppHeaderProps) {
const { t } = useTranslation()
const isError = status === 'error' const isError = status === 'error'
return ( return (
@@ -42,8 +45,8 @@ export function AppHeader({
<Flame className="h-5 w-5" /> <Flame className="h-5 w-5" />
</span> </span>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-lg font-semibold sm:text-xl">SignalMap</span> <span className="text-lg font-semibold sm:text-xl">{t('app.name')}</span>
<span className="text-xs text-muted-foreground sm:text-sm">Crowd signals around your route</span> <span className="text-xs text-muted-foreground sm:text-sm">{t('app.tagline')}</span>
</div> </div>
</div> </div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2"> <div className="flex flex-1 flex-wrap items-center justify-end gap-2">
@@ -62,20 +65,47 @@ export function AppHeader({
</span> </span>
{statusLabel} {statusLabel}
</span> </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> </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" /> <RefreshCw className="h-4 w-4" />
</Button> </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" /> <Focus className="h-4 w-4" />
</Button> </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" /> <LocateFixed className="h-4 w-4" />
</Button> </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" /> <MapPin className="h-4 w-4" />
</Button> </Button>
<LanguageToggle />
<ThemeToggle /> <ThemeToggle />
</div> </div>
</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 { Moon, Sun } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
export function ThemeToggle() { export function ThemeToggle() {
const { toggleTheme, isDark } = useTheme() const { toggleTheme, isDark } = useTheme()
const { t } = useTranslation()
return ( return (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={toggleTheme} 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" 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" />} {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 type { MutableRefObject } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -10,6 +11,8 @@ interface MapViewportProps {
} }
export function MapViewport({ containerRef, isPosting, isLoading, confirmationHint }: MapViewportProps) { export function MapViewport({ containerRef, isPosting, isLoading, confirmationHint }: MapViewportProps) {
const { t } = useTranslation()
return ( return (
<div className="relative min-h-[360px] flex-1 overflow-hidden rounded-3xl border border-border/50 bg-muted/40 shadow-inner"> <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')} /> <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) && ( {(isPosting || isLoading) && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"> <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"> <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> </span>
</div> </div>
)} )}
{confirmationHint && ( {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} {confirmationHint}
</div> </div>
)} )}
@@ -1,4 +1,5 @@
import { Activity, ArrowRight } from 'lucide-react' import { Activity, ArrowRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -20,14 +21,16 @@ interface ActivityPanelProps {
} }
export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) { export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) {
const { t } = useTranslation()
return ( return (
<Card className="h-full"> <Card className="h-full">
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-primary" /> <Activity className="h-5 w-5 text-primary" />
Live community pings {t('activity.title')}
</CardTitle> </CardTitle>
<CardDescription>Latest activity reported by nearby contributors.</CardDescription> <CardDescription>{t('activity.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{items.length === 0 && <p className="text-sm text-muted-foreground">{emptyMessage}</p>} {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"> <div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{item.distanceLabel}</span> <span>{item.distanceLabel}</span>
<Button variant="ghost" size="sm" className="h-8 gap-2 text-xs" onClick={item.onFocus}> <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" /> <ArrowRight className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -1,4 +1,5 @@
import { Flame, MapPin } from 'lucide-react' import { Flame, MapPin } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -21,19 +22,21 @@ interface HotspotStatsPanelProps {
} }
export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }: HotspotStatsPanelProps) { export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }: HotspotStatsPanelProps) {
const { t } = useTranslation()
return ( return (
<Card className="h-full"> <Card className="h-full">
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Flame className="h-5 w-5 text-primary" /> <Flame className="h-5 w-5 text-primary" />
Danger zone intel {t('hotspots.title')}
</CardTitle> </CardTitle>
<CardDescription>Highest intensity heat within {radiusKm}km.</CardDescription> <CardDescription>{t('hotspots.description', { radius: radiusKm })}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{!hasLocation && <p className="text-sm text-muted-foreground">{locationHint}</p>} {!hasLocation && <p className="text-sm text-muted-foreground">{locationHint}</p>}
{hasLocation && cells.length === 0 && ( {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 && ( {cells.length > 0 && (
<ScrollArea className="max-h-[280px] pr-2"> <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" className="mt-2 w-full justify-center gap-2 text-xs"
onClick={cell.onFocus} onClick={cell.onFocus}
> >
<MapPin className="h-3.5 w-3.5" /> Focus <MapPin className="h-3.5 w-3.5" /> {t('hotspots.focus')}
</Button> </Button>
</li> </li>
))} ))}
+20 -11
View File
@@ -1,15 +1,17 @@
import { AlertCircle, Radio } from 'lucide-react' import { AlertCircle, Radio } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import type { FeedError } from '@/hooks/useHotspotFeed'
interface OverviewPanelProps { interface OverviewPanelProps {
nearbySignals: number nearbySignals: number
uniqueContributors: number uniqueContributors: number
lastUpdatedLabel: string lastUpdatedLabel: string
mySignalLabel: string | null mySignalLabel: string | null
errorMessage: string | null error: FeedError | null
onReport: () => void onReport: () => void
onRetry: () => void onRetry: () => void
isPosting: boolean isPosting: boolean
@@ -23,7 +25,7 @@ export function OverviewPanel({
uniqueContributors, uniqueContributors,
lastUpdatedLabel, lastUpdatedLabel,
mySignalLabel, mySignalLabel,
errorMessage, error,
onReport, onReport,
onRetry, onRetry,
isPosting, isPosting,
@@ -31,29 +33,32 @@ export function OverviewPanel({
showLocationCta, showLocationCta,
disableReport, disableReport,
}: OverviewPanelProps) { }: OverviewPanelProps) {
const { t } = useTranslation()
const errorMessage = error ? t(error.key, error.values) : null
return ( return (
<Card> <Card>
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Radio className="h-5 w-5 text-primary" /> <Radio className="h-5 w-5 text-primary" />
Nearby coverage {t('overview.title')}
</CardTitle> </CardTitle>
<CardDescription>Signals refresh automatically every few seconds.</CardDescription> <CardDescription>{t('overview.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-border/60 bg-muted/50 p-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> <p className="text-xl font-semibold">{nearbySignals}</p>
</div> </div>
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3"> <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> <p className="text-xl font-semibold">{uniqueContributors}</p>
</div> </div>
</div> </div>
{mySignalLabel && ( {mySignalLabel && (
<Badge variant="secondary" className="w-full justify-center rounded-full py-2 text-xs uppercase"> <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> </Badge>
)} )}
{errorMessage ? ( {errorMessage ? (
@@ -62,7 +67,7 @@ export function OverviewPanel({
<div className="space-y-2"> <div className="space-y-2">
<p>{errorMessage}</p> <p>{errorMessage}</p>
<Button variant="outline" size="sm" className="text-xs" onClick={onRetry}> <Button variant="outline" size="sm" className="text-xs" onClick={onRetry}>
Try again {t('overview.error.action')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -70,12 +75,16 @@ export function OverviewPanel({
<p className="text-sm text-muted-foreground">{locationHint}</p> <p className="text-sm text-muted-foreground">{locationHint}</p>
)} )}
<Button className="w-full" onClick={onReport} disabled={isPosting || disableReport}> <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> </Button>
{showLocationCta && !errorMessage && ( {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> </CardContent>
</Card> </Card>
) )
+1 -1
View File
@@ -33,7 +33,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
<div <div
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
+29 -24
View File
@@ -1,5 +1,6 @@
import * as React from 'react' import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog' import * as SheetPrimitive from '@radix-ui/react-dialog'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -35,30 +36,34 @@ interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetP
const SheetContent = React.forwardRef< const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>, React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps SheetContentProps
>(({ side = 'bottom', className, children, ...props }, ref) => ( >(({ side = 'bottom', className, children, ...props }, ref) => {
<SheetPortal> const { t } = useTranslation()
<SheetOverlay />
<SheetPrimitive.Content return (
ref={ref} <SheetPortal>
className={cn( <SheetOverlay />
'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', <SheetPrimitive.Content
side === 'bottom' && 'inset-x-0 bottom-0 rounded-t-2xl border-t', ref={ref}
side === 'top' && 'inset-x-0 top-0 rounded-b-2xl border-b', className={cn(
side === 'left' && 'inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', '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 === 'right' && 'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', side === 'bottom' && 'inset-x-0 bottom-0 rounded-t-2xl border-t',
className, 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',
data-side={side} side === 'right' && 'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
{...props} className,
> )}
{children} data-side={side}
<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"> {...props}
Close >
<span className="sr-only">Close</span> {children}
</SheetPrimitive.Close> <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">
</SheetPrimitive.Content> {t('common.actions.close')}
</SheetPortal> <span className="sr-only">{t('common.aria.sheet.close')}</span>
)) </SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
})
SheetContent.displayName = SheetPrimitive.Content.displayName SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+27 -10
View File
@@ -16,9 +16,14 @@ interface SubmitResult {
success: boolean success: boolean
} }
export interface FeedError {
key: string
values?: Record<string, unknown>
}
export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) { export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) {
const [status, setStatus] = useState<FeedStatus>('loading') 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 [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
const [rawDensity, setRawDensity] = useState<ApiDensityCell[]>([]) const [rawDensity, setRawDensity] = useState<ApiDensityCell[]>([])
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([]) const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
@@ -50,7 +55,7 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
try { try {
const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' }) const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' })
if (!response.ok) { if (!response.ok) {
throw new Error('Unable to reach the hotspot feed.') throw new Error('feed-unavailable')
} }
const data: ApiSnapshot = await response.json() const data: ApiSnapshot = await response.json()
@@ -59,14 +64,15 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
setRawLatestByUser(data.latestByUser ?? []) setRawLatestByUser(data.latestByUser ?? [])
setClientKey(data.clientKey ?? null) setClientKey(data.clientKey ?? null)
setLastUpdated(data.updatedAt ?? new Date().toISOString()) setLastUpdated(data.updatedAt ?? new Date().toISOString())
setErrorMessage(null) setError(null)
initialLoadRef.current = false initialLoadRef.current = false
const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle' const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
setStatusSafe(nextStatus) setStatusSafe(nextStatus)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.' const message = error instanceof Error ? error.message : null
setErrorMessage(message) const key = message === 'feed-unavailable' ? 'errors.feedUnavailable' : 'errors.feedUnknown'
setError({ key })
if (initialLoadRef.current) { if (initialLoadRef.current) {
setStatusSafe('error') setStatusSafe('error')
} else if (previousStatus !== 'posting') { } else if (previousStatus !== 'posting') {
@@ -104,16 +110,27 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
if (!response.ok) { if (!response.ok) {
const payload = await response.json().catch(() => null) const payload = await response.json().catch(() => null)
const message = payload?.message ?? 'Unable to store your signal.' const message = payload?.message as string | undefined
throw new Error(message) if (message) {
setError({ key: 'errors.submitWithReason', values: { message } })
} else {
setError({ key: 'errors.submitUnavailable' })
}
setStatusSafe('error')
return { success: false }
} }
await fetchSnapshot({ silent: true }) await fetchSnapshot({ silent: true })
setError(null)
setStatusSafe('idle') setStatusSafe('idle')
return { success: true } return { success: true }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.' const message = error instanceof Error ? error.message : null
setErrorMessage(message) if (message) {
setError({ key: 'errors.submitWithReason', values: { message } })
} else {
setError({ key: 'errors.submitUnknown' })
}
setStatusSafe('error') setStatusSafe('error')
return { success: false } return { success: false }
} }
@@ -157,7 +174,7 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo
return { return {
status, status,
errorMessage, error,
submitPoint, submitPoint,
fetchSnapshot, fetchSnapshot,
rawDensity, rawDensity,
+5 -5
View File
@@ -5,13 +5,13 @@ import type { LatLng } from '@/types/api'
function geolocationErrorMessage(error: GeolocationPositionError): string { function geolocationErrorMessage(error: GeolocationPositionError): string {
switch (error.code) { switch (error.code) {
case error.PERMISSION_DENIED: case error.PERMISSION_DENIED:
return 'Location access denied. Enable it to view nearby pings.' return 'location.error.permissionDenied'
case error.POSITION_UNAVAILABLE: case error.POSITION_UNAVAILABLE:
return 'Unable to determine your position. Try again.' return 'location.error.unavailable'
case error.TIMEOUT: case error.TIMEOUT:
return 'Timed out while fetching your location.' return 'location.error.timeout'
default: default:
return 'Failed to retrieve your location.' return 'location.error.generic'
} }
} }
@@ -33,7 +33,7 @@ export function useUserLocation() {
const start = useCallback(() => { const start = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) { if (typeof navigator === 'undefined' || !navigator.geolocation) {
setError('Geolocation is not supported in this browser.') setError('location.error.unsupported')
setIsRequesting(false) setIsRequesting(false)
return 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)) return twMerge(clsx(inputs))
} }
export function formatCoordinate(value: number): string { export function formatCoordinate(value: number, locale = 'en-US'): string {
const formatter = new Intl.NumberFormat('en-US', { const formatter = new Intl.NumberFormat(locale, {
minimumFractionDigits: 3, minimumFractionDigits: 3,
maximumFractionDigits: 3, maximumFractionDigits: 3,
}) })
return formatter.format(value) 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 date = new Date(dateIso)
const now = new Date() const now = new Date()
const diff = Math.max(0, now.getTime() - date.getTime()) const diff = Math.max(0, now.getTime() - date.getTime())
const seconds = Math.floor(diff / 1000) const seconds = Math.floor(diff / 1000)
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
if (seconds < 60) { if (seconds < 60) {
return `${seconds}s ago` return rtf.format(-seconds, 'second')
} }
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60)
if (minutes < 60) { if (minutes < 60) {
return `${minutes}m ago` return rtf.format(-minutes, 'minute')
} }
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60)
if (hours < 24) { if (hours < 24) {
return `${hours}h ago` return rtf.format(-hours, 'hour')
} }
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24)
if (days < 7) { if (days < 7) {
return `${days}d ago` return rtf.format(-days, 'day')
} }
const weeks = Math.floor(days / 7) 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) const date = new Date(dateIso)
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat(locale, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '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 { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { I18nextProvider } from 'react-i18next'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import '@/index.css' import '@/index.css'
import App from '@/App.tsx' import App from '@/App.tsx'
import { i18n } from '@/lib/i18n'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <I18nextProvider i18n={i18n}>
<App />
</I18nextProvider>
</StrictMode>, </StrictMode>,
) )