Add bilingual i18n UI and lighten component shadows
This commit is contained in:
+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>
|
||||
|
||||
Reference in New Issue
Block a user