Add bilingual i18n UI and lighten component shadows
This commit is contained in:
Generated
+87
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>) => (
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user