Merge pull request #7 from bernard-ng/codex/add-distance-value-object-and-ci-workflows

Add distance value object and CI workflows
This commit is contained in:
Bernard Ngandu
2025-10-10 16:31:09 +02:00
committed by GitHub
53 changed files with 4293 additions and 1400 deletions
+65
View File
@@ -0,0 +1,65 @@
name: Backend Quality
on:
push:
paths:
- "server/**"
- ".github/workflows/backend-quality.yml"
pull_request:
paths:
- "server/**"
- ".github/workflows/backend-quality.yml"
jobs:
static-analysis:
name: PHPStan
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
coverage: none
tools: composer
extensions: mbstring
ini-values: memory_limit=-1
cache: composer
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Run PHPStan
run: vendor/bin/phpstan analyse --memory-limit=-1
coding-standards:
name: ECS
runs-on: ubuntu-latest
needs: static-analysis
defaults:
run:
working-directory: server
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
coverage: none
tools: composer
extensions: mbstring
ini-values: memory_limit=-1
cache: composer
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Check coding standards
run: vendor/bin/ecs check
+40
View File
@@ -0,0 +1,40 @@
name: Backend Tests
on:
push:
paths:
- "server/**"
- ".github/workflows/backend-tests.yml"
pull_request:
paths:
- "server/**"
- ".github/workflows/backend-tests.yml"
jobs:
phpunit:
name: PHPUnit
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
coverage: none
tools: composer
extensions: mbstring
ini-values: memory_limit=-1
cache: composer
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Run PHPUnit
run: vendor/bin/phpunit
+81
View File
@@ -0,0 +1,81 @@
name: Frontend Quality
on:
push:
paths:
- "client/**"
- ".github/workflows/frontend-quality.yml"
pull_request:
paths:
- "client/**"
- ".github/workflows/frontend-quality.yml"
jobs:
eslint:
name: ESLint
runs-on: ubuntu-latest
defaults:
run:
working-directory: client
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: client/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
prettier:
name: Prettier
runs-on: ubuntu-latest
defaults:
run:
working-directory: client
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: client/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check formatting
run: npx prettier --check .
typecheck:
name: Type Check
runs-on: ubuntu-latest
defaults:
run:
working-directory: client
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: client/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run TypeScript type check
run: npm run typecheck
+11
View File
@@ -0,0 +1,11 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"tabWidth": 2,
"useTabs": false,
"printWidth": 120,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}
+94 -17
View File
@@ -1,23 +1,100 @@
import js from '@eslint/js' import { defineConfig } from "eslint/config";
import globals from 'globals' import tsParser from "@typescript-eslint/parser";
import reactHooks from 'eslint-plugin-react-hooks' import tsPlugin from "@typescript-eslint/eslint-plugin";
import reactRefresh from 'eslint-plugin-react-refresh' import reactPlugin from "eslint-plugin-react";
import tseslint from 'typescript-eslint' import reactHooksPlugin from "eslint-plugin-react-hooks";
import { defineConfig, globalIgnores } from 'eslint/config' import importPlugin from "eslint-plugin-import";
import prettierPlugin from "eslint-plugin-prettier";
import unusedImportsPlugin from "eslint-plugin-unused-imports";
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']),
{ {
files: ['**/*.{ts,tsx}'], files: ["src/**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: { languageOptions: {
ecmaVersion: 2020, parser: tsParser,
globals: globals.browser, parserOptions: {
project: "./tsconfig.app.json",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
react: {
version: "detect",
},
"import/resolver": {
typescript: {
alwaysTryTypes: true,
project: ["./tsconfig.json", "./tsconfig.app.json"],
},
node: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
},
},
},
plugins: {
"@typescript-eslint": tsPlugin,
react: reactPlugin,
"react-hooks": reactHooksPlugin,
import: importPlugin,
prettier: prettierPlugin,
"unused-imports": unusedImportsPlugin,
},
rules: {
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"import/default": "off",
"import/named": "off",
"import/namespace": "error",
"import/export": "error",
"import/order": [
"error",
{
groups: ["builtin", "external", "internal"],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
],
pathGroupsExcludedImportTypes: ["react"],
"newlines-between": "always",
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
"import/extensions": [
"error",
"ignorePackages",
{
ts: "never",
tsx: "never",
js: "never",
jsx: "never",
},
],
"prettier/prettier": "error",
}, },
}, },
]) {
ignores: ["dist/*"],
},
]);
+2606
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -31,6 +32,8 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@typescript-eslint/eslint-plugin": "^8.24.0",
"@typescript-eslint/parser": "^8.24.0",
"@types/leaflet": "^1.9.12", "@types/leaflet": "^1.9.12",
"@types/node": "^24.6.0", "@types/node": "^24.6.0",
"@types/react": "^19.1.16", "@types/react": "^19.1.16",
@@ -39,10 +42,16 @@
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"babel-plugin-react-compiler": "^19.1.0-rc.3", "babel-plugin-react-compiler": "^19.1.0-rc.3",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.22",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^16.4.0", "globals": "^16.4.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3", "typescript": "~5.9.3",
+1 -1
View File
@@ -3,4 +3,4 @@ export default {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };
+283 -285
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from "react";
import { Layers, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { AppHeader } from '@/components/layout/AppHeader' import { Layers, Menu, PanelRightClose, PanelRightOpen } from "lucide-react";
import { ActivityPanel } from '@/components/panels/ActivityPanel' import { useTranslation } from "react-i18next";
import { HotspotStatsPanel } from '@/components/panels/HotspotStatsPanel'
import { OverviewPanel } from '@/components/panels/OverviewPanel' import { AppHeader } from "@/components/layout/AppHeader";
import { MapViewport } from '@/components/map/MapViewport' import { MapViewport } from "@/components/map/MapViewport";
import { ActivityPanel } from "@/components/panels/ActivityPanel";
import { HotspotStatsPanel } from "@/components/panels/HotspotStatsPanel";
import { OverviewPanel } from "@/components/panels/OverviewPanel";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -16,61 +17,61 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from "@/components/ui/alert-dialog";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from "@/components/ui/scroll-area";
import { useHotspotFeed } from '@/hooks/useHotspotFeed' import { Toaster } from "@/components/ui/toaster";
import { useLeafletHeatmap, type TileProvider } from '@/hooks/useLeafletHeatmap' import { useToast } from "@/components/ui/use-toast";
import { useUserLocation } from '@/hooks/useUserLocation' import { useHotspotFeed } from "@/hooks/useHotspotFeed";
import { cn, distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils' import { useLeafletHeatmap, type TileProvider } from "@/hooks/useLeafletHeatmap";
import type { Point } from '@/types/api' import { useUserLocation } from "@/hooks/useUserLocation";
import { Toaster } from '@/components/ui/toaster' import { cn, distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from "@/lib/utils";
import { useToast } from '@/components/ui/use-toast' import { useAppStore } from "@/store/useAppStore";
import { useAppStore } from '@/store/useAppStore' import type { Point } from "@/types/api";
const RADIUS_KM = 1 const RADIUS_KM = 1;
export default function App() { export default function App() {
const [pendingSpot, setPendingSpot] = useState<Point | null>(null) const [pendingSpot, setPendingSpot] = useState<Point | null>(null);
const [isConfirmOpen, setIsConfirmOpen] = useState(false) const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [isConfirming, setIsConfirming] = useState(false) const [isConfirming, setIsConfirming] = useState(false);
const [tileProvider, setTileProvider] = useState<TileProvider>('openstreetmap') const [tileProvider, setTileProvider] = useState<TileProvider>("openstreetmap");
const [isDetailsOpen, setIsDetailsOpen] = useState(() => { const [isDetailsOpen, setIsDetailsOpen] = useState(() => {
if (typeof window === 'undefined') { if (typeof window === "undefined") {
return false return false;
} }
return window.innerWidth >= 1024 return window.innerWidth >= 1024;
}) });
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(() => { const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(() => {
if (typeof window === 'undefined') { if (typeof window === "undefined") {
return false return false;
} }
return window.innerWidth < 768 return window.innerWidth < 768;
}) });
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation();
const locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US' const locale = i18n.language === "fr" ? "fr-FR" : "en-US";
const distanceFormatter = useMemo( const distanceFormatter = useMemo(
() => () =>
new Intl.NumberFormat(locale, { new Intl.NumberFormat(locale, {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}), }),
[locale], [locale]
) );
const tileOptions = useMemo( const tileOptions = useMemo(
() => [ () => [
{ value: 'openstreetmap' as TileProvider, label: t('map.tiles.openstreetmap') }, { value: "openstreetmap" as TileProvider, label: t("map.tiles.openstreetmap") },
{ value: 'mapbox' as TileProvider, label: t('map.tiles.mapbox') }, { value: "mapbox" as TileProvider, label: t("map.tiles.mapbox") },
], ],
[t], [t]
) );
const userLocation = useAppStore((state) => state.userLocation) const userLocation = useAppStore(state => state.userLocation);
const locationError = useAppStore((state) => state.locationError) const locationError = useAppStore(state => state.locationError);
const isRequestingLocation = useAppStore((state) => state.isRequestingLocation) const isRequestingLocation = useAppStore(state => state.isRequestingLocation);
const { refresh: refreshLocation } = useUserLocation() const { refresh: refreshLocation } = useUserLocation();
const { const {
status, status,
@@ -82,123 +83,120 @@ export default function App() {
selectVisibleLatestByUser, selectVisibleLatestByUser,
myLatestPoint, myLatestPoint,
lastUpdated, lastUpdated,
} = useHotspotFeed({ userLocation: userLocation ?? null }) } = useHotspotFeed({ userLocation: userLocation ?? null });
const visibleDensity = useMemo( const visibleDensity = useMemo(
() => selectVisibleDensity(userLocation ?? null), () => selectVisibleDensity(userLocation ?? null),
[selectVisibleDensity, userLocation], [selectVisibleDensity, userLocation]
) );
const visiblePoints = useMemo( const visiblePoints = useMemo(() => selectVisiblePoints(userLocation ?? null), [selectVisiblePoints, userLocation]);
() => selectVisiblePoints(userLocation ?? null),
[selectVisiblePoints, userLocation],
)
const visibleLatestByUser = useMemo( const visibleLatestByUser = useMemo(
() => selectVisibleLatestByUser(userLocation ?? null), () => selectVisibleLatestByUser(userLocation ?? null),
[selectVisibleLatestByUser, userLocation], [selectVisibleLatestByUser, userLocation]
) );
const localTotals = useMemo(() => { const localTotals = useMemo(() => {
const uniqueUsers = new Set<string>() const uniqueUsers = new Set<string>();
visibleLatestByUser.forEach((point) => uniqueUsers.add(point.userKey)) visibleLatestByUser.forEach(point => uniqueUsers.add(point.userKey));
return { points: visiblePoints.length, contributors: uniqueUsers.size } return { points: visiblePoints.length, contributors: uniqueUsers.size };
}, [visibleLatestByUser, visiblePoints]) }, [visibleLatestByUser, visiblePoints]);
const myVisibleSignal = useMemo(() => { const myVisibleSignal = useMemo(() => {
if (!myLatestPoint) { if (!myLatestPoint) {
return null return null;
} }
if (userLocation && distanceInKm(userLocation, myLatestPoint.signalLocation) > RADIUS_KM) { if (userLocation && distanceInKm(userLocation, myLatestPoint.signalLocation) > RADIUS_KM) {
return null return null;
} }
return myLatestPoint return myLatestPoint;
}, [myLatestPoint, userLocation]) }, [myLatestPoint, userLocation]);
const statusLabel = t(`status.${status}`) const statusLabel = t(`status.${status}`);
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated, locale) : t('common.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 translatedLocationError = locationError ? t(locationError) : null const translatedLocationError = locationError ? t(locationError) : null;
const locationHint = translatedLocationError const locationHint = translatedLocationError
? translatedLocationError ? translatedLocationError
: hasLocation : hasLocation
? t('location.hint.showing', { radius: RADIUS_KM }) ? t("location.hint.showing", { radius: RADIUS_KM })
: isRequestingLocation : isRequestingLocation
? t('location.hint.requesting') ? t("location.hint.requesting")
: t('location.hint.allow') : t("location.hint.allow");
const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({ const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({
heatCells: visibleDensity, heatCells: visibleDensity,
userLocation: userLocation ?? null, userLocation: userLocation ?? null,
onRequestSpot: (position) => { onRequestSpot: position => {
setPendingSpot(position) setPendingSpot(position);
setIsConfirmOpen(true) setIsConfirmOpen(true);
}, },
tileProvider, tileProvider,
}) });
const { toast } = useToast() const { toast } = useToast();
useEffect(() => { useEffect(() => {
if (!error) { if (!error) {
return return;
} }
const description = error.detail ?? t(error.key, error.values ?? {}) const description = error.detail ?? t(error.key, error.values ?? {});
toast({ toast({
variant: 'destructive', variant: "destructive",
title: t('errors.title'), title: t("errors.title"),
description, description,
duration: 6000, duration: 6000,
}) });
}, [error, t, toast]) }, [error, t, toast]);
const handleConfirmSignal = useCallback(async () => { const handleConfirmSignal = useCallback(async () => {
if (!pendingSpot) { if (!pendingSpot) {
return return;
} }
setIsConfirming(true) setIsConfirming(true);
const result = await submitPoint(pendingSpot) const result = await submitPoint(pendingSpot);
setIsConfirming(false) setIsConfirming(false);
if (result.success) { if (result.success) {
setIsConfirmOpen(false) setIsConfirmOpen(false);
setPendingSpot(null) setPendingSpot(null);
} }
}, [pendingSpot, submitPoint]) }, [pendingSpot, submitPoint]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
fetchSnapshot().catch(() => undefined) fetchSnapshot().catch(() => undefined);
}, [fetchSnapshot]) }, [fetchSnapshot]);
const handleFocusHeat = useCallback(() => { const handleFocusHeat = useCallback(() => {
fitToHeat() fitToHeat();
}, [fitToHeat]) }, [fitToHeat]);
const handleLocateUser = useCallback(() => { const handleLocateUser = useCallback(() => {
if (userLocation) { if (userLocation) {
focusOn(userLocation, 14) focusOn(userLocation, 14);
} else { } else {
refreshLocation() refreshLocation();
} }
}, [focusOn, refreshLocation, userLocation]) }, [focusOn, refreshLocation, userLocation]);
const handleFocusMySignal = useCallback(() => { const handleFocusMySignal = useCallback(() => {
if (myVisibleSignal) { if (myVisibleSignal) {
focusOn({ lat: myVisibleSignal.signalLocation.lat, lng: myVisibleSignal.signalLocation.lng }, 15) focusOn({ lat: myVisibleSignal.signalLocation.lat, lng: myVisibleSignal.signalLocation.lng }, 15);
} }
}, [focusOn, myVisibleSignal]) }, [focusOn, myVisibleSignal]);
const handleManualReport = useCallback(() => { const handleManualReport = useCallback(() => {
if (!userLocation) { if (!userLocation) {
return return;
} }
setPendingSpot(userLocation) setPendingSpot(userLocation);
setIsConfirmOpen(true) setIsConfirmOpen(true);
}, [userLocation]) }, [userLocation]);
const dangerCells = useMemo( const dangerCells = useMemo(
() => () =>
@@ -206,239 +204,239 @@ export default function App() {
.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) => {
const coordinates = t('common.coordinates', { const coordinates = t("common.coordinates", {
lat: formatCoordinate(cell.lat, locale), lat: formatCoordinate(cell.lat, locale),
lng: formatCoordinate(cell.lng, locale), lng: formatCoordinate(cell.lng, locale),
}) });
const distanceLabel = userLocation const distanceLabel = userLocation
? `${distanceFormatter.format(distanceInKm(userLocation, cell))} km` ? `${distanceFormatter.format(distanceInKm(userLocation, cell))} km`
: null : null;
return { return {
id: `${cell.lat}-${cell.lng}-${index}`, id: `${cell.lat}-${cell.lng}-${index}`,
title: t('hotspots.itemTitle', { index: index + 1 }), title: t("hotspots.itemTitle", { index: index + 1 }),
subtitle: subtitle:
distanceLabel !== null distanceLabel !== null
? t('hotspots.itemSubtitleWithDistance', { distance: distanceLabel, coordinates }) ? t("hotspots.itemSubtitleWithDistance", { distance: distanceLabel, coordinates })
: t('hotspots.itemSubtitle', { coordinates }), : t("hotspots.itemSubtitle", { coordinates }),
intensity: cell.intensity, intensity: cell.intensity,
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15), onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15),
} };
}), }),
[visibleDensity, userLocation, focusOn, distanceFormatter, t, locale], [visibleDensity, userLocation, focusOn, distanceFormatter, t, locale]
) );
const recentActivity = useMemo( const recentActivity = useMemo(
() => () =>
[...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 => {
const coordinates = t('common.coordinates', { const coordinates = t("common.coordinates", {
lat: formatCoordinate(point.signalLocation.lat, locale), lat: formatCoordinate(point.signalLocation.lat, locale),
lng: formatCoordinate(point.signalLocation.lng, locale), lng: formatCoordinate(point.signalLocation.lng, locale),
}) });
const distanceLabel = userLocation const distanceLabel = userLocation
? t('activityItem.distance', { ? t("activityItem.distance", {
distance: `${distanceFormatter.format(distanceInKm(userLocation, point.signalLocation))} km`, distance: `${distanceFormatter.format(distanceInKm(userLocation, point.signalLocation))} km`,
}) })
: formatTimestamp(point.createdAt, locale) : formatTimestamp(point.createdAt, locale);
return { return {
id: point.id, id: point.id,
title: coordinates, title: coordinates,
subtitle: t('activityItem.user', { id: point.userKey.slice(0, 4).toUpperCase() }), subtitle: t("activityItem.user", { id: point.userKey.slice(0, 4).toUpperCase() }),
timestampLabel: formatRelativeTime(point.createdAt, locale), timestampLabel: formatRelativeTime(point.createdAt, locale),
distanceLabel, distanceLabel,
onFocus: () => focusOn({ lat: point.signalLocation.lat, lng: point.signalLocation.lng }, 15), onFocus: () => focusOn({ lat: point.signalLocation.lat, lng: point.signalLocation.lng }, 15),
} };
}), }),
[visiblePoints, userLocation, focusOn, distanceFormatter, t, locale], [visiblePoints, userLocation, focusOn, distanceFormatter, t, locale]
) );
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : '--' const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : "--";
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : '--' const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : "--";
const isDialogDisabled = !pendingSpot || isConfirming const isDialogDisabled = !pendingSpot || isConfirming;
const detailsToggleLabel = isDetailsOpen ? t('details.close') : t('details.open') const detailsToggleLabel = isDetailsOpen ? t("details.close") : t("details.open");
const detailsPanelClassName = cn( const detailsPanelClassName = cn(
'pointer-events-auto fixed inset-x-0 bottom-0 z-40 flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-3xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur transition-transform duration-300 sm:left-auto sm:right-6 sm:top-24 sm:bottom-6 sm:max-h-[calc(100vh-8rem)] sm:w-[min(380px,calc(100vw-4rem))] sm:rounded-3xl', "pointer-events-auto fixed inset-x-0 bottom-0 z-40 flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-3xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur transition-transform duration-300 sm:left-auto sm:right-6 sm:top-24 sm:bottom-6 sm:max-h-[calc(100vh-8rem)] sm:w-[min(380px,calc(100vw-4rem))] sm:rounded-3xl",
isDetailsOpen isDetailsOpen ? "translate-y-0 sm:translate-x-0" : "translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]"
? 'translate-y-0 sm:translate-x-0' );
: 'translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]',
)
return ( return (
<> <>
<div className="relative min-h-screen w-full overflow-hidden bg-background text-foreground"> <div className="relative min-h-screen w-full overflow-hidden bg-background text-foreground">
<MapViewport <MapViewport
containerRef={mapContainerRef} containerRef={mapContainerRef}
isPosting={isPosting || isConfirming} isPosting={isPosting || isConfirming}
isLoading={isLoading} isLoading={isLoading}
confirmationHint={isConfirmOpen ? t('map.confirmationHint') : null} confirmationHint={isConfirmOpen ? t("map.confirmationHint") : null}
className="min-h-screen" className="min-h-screen"
/> />
<div className="pointer-events-none absolute inset-0 flex flex-col"> <div className="pointer-events-none absolute inset-0 flex flex-col">
<div className="flex items-start justify-between gap-3 p-4 sm:p-6"> <div className="flex items-start justify-between gap-3 p-4 sm:p-6">
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<AppHeader <AppHeader
status={status} status={status}
statusLabel={statusLabel} statusLabel={statusLabel}
lastUpdatedLabel={lastUpdatedLabel} lastUpdatedLabel={lastUpdatedLabel}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onFocusHeat={handleFocusHeat} onFocusHeat={handleFocusHeat}
onLocateUser={handleLocateUser} onLocateUser={handleLocateUser}
onFocusMySignal={handleFocusMySignal} onFocusMySignal={handleFocusMySignal}
disableRefresh={isLoading || isRefreshing || isPosting} disableRefresh={isLoading || isRefreshing || isPosting}
disableHeat={visibleDensity.length === 0} disableHeat={visibleDensity.length === 0}
disableLocate={!hasLocation} disableLocate={!hasLocation}
disableMySignal={!myVisibleSignal} disableMySignal={!myVisibleSignal}
collapsed={isHeaderCollapsed} collapsed={isHeaderCollapsed}
onToggleCollapse={() => setIsHeaderCollapsed((prev) => !prev)} onToggleCollapse={() => setIsHeaderCollapsed(prev => !prev)}
/> />
</div>
<div className="pointer-events-auto hidden sm:flex">
<Button
variant="secondary"
size="icon"
onClick={() => setIsDetailsOpen(prev => !prev)}
aria-label={detailsToggleLabel}
aria-expanded={isDetailsOpen}
className="h-11 w-11 rounded-full border border-border/60 bg-background/80 shadow-lg backdrop-blur hover:bg-background"
>
{isDetailsOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
</div>
</div> </div>
<div className="pointer-events-auto hidden sm:flex"> </div>
{!isDetailsOpen && (
<div className="pointer-events-auto fixed bottom-4 right-4 z-30 sm:hidden">
<Button <Button
variant="secondary" variant="secondary"
size="icon" size="sm"
onClick={() => setIsDetailsOpen((prev) => !prev)} onClick={() => setIsDetailsOpen(true)}
aria-label={detailsToggleLabel} aria-label={detailsToggleLabel}
aria-expanded={isDetailsOpen} className="flex items-center gap-2 rounded-full border border-border/60 bg-background/85 px-4 py-2 text-xs font-semibold uppercase tracking-wide shadow-lg backdrop-blur"
className="h-11 w-11 rounded-full border border-border/60 bg-background/80 shadow-lg backdrop-blur hover:bg-background"
> >
{isDetailsOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />} <Menu className="h-4 w-4" />
<span>{t("details.open")}</span>
</Button> </Button>
</div> </div>
</div> )}
</div>
{!isDetailsOpen && ( <aside
<div className="pointer-events-auto fixed bottom-4 right-4 z-30 sm:hidden"> className={detailsPanelClassName}
<Button role="complementary"
variant="secondary" aria-label={t("details.title")}
size="sm" aria-hidden={!isDetailsOpen}
onClick={() => setIsDetailsOpen(true)} >
aria-label={detailsToggleLabel} <header className="flex items-center justify-between gap-2 border-b border-border/60 bg-background/80 px-4 py-3 backdrop-blur">
className="flex items-center gap-2 rounded-full border border-border/60 bg-background/85 px-4 py-2 text-xs font-semibold uppercase tracking-wide shadow-lg backdrop-blur" <div className="flex flex-col">
> <span className="text-sm font-semibold">{t("details.title")}</span>
<Menu className="h-4 w-4" /> <span className="text-xs text-muted-foreground">{t("details.description")}</span>
<span>{t('details.open')}</span> </div>
</Button> <Button
</div> variant="ghost"
)} size="icon"
onClick={() => setIsDetailsOpen(false)}
<aside aria-label={t("details.close")}
className={detailsPanelClassName} className="h-9 w-9 rounded-full border border-border/60 bg-muted/60 backdrop-blur hover:bg-muted"
role="complementary" >
aria-label={t('details.title')} <PanelRightClose className="h-4 w-4" />
aria-hidden={!isDetailsOpen} </Button>
> </header>
<header className="flex items-center justify-between gap-2 border-b border-border/60 bg-background/80 px-4 py-3 backdrop-blur"> <ScrollArea className="flex-1">
<div className="flex flex-col"> <div className="flex flex-col gap-4 p-4 pb-6">
<span className="text-sm font-semibold">{t('details.title')}</span> <div className="rounded-2xl border border-border/60 bg-muted/40 p-4 shadow-sm">
<span className="text-xs text-muted-foreground">{t('details.description')}</span> <div className="flex items-start gap-3">
</div> <span className="flex h-10 w-10 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-primary">
<Button <Layers className="h-5 w-5" />
variant="ghost" </span>
size="icon" <div className="flex flex-1 flex-col">
onClick={() => setIsDetailsOpen(false)} <span className="text-sm font-semibold">{t("map.tiles.title")}</span>
aria-label={t('details.close')} <span className="text-xs text-muted-foreground">{t("map.tiles.subtitle")}</span>
className="h-9 w-9 rounded-full border border-border/60 bg-muted/60 backdrop-blur hover:bg-muted" </div>
> </div>
<PanelRightClose className="h-4 w-4" /> <div className="mt-3 flex flex-wrap gap-2">
</Button> {tileOptions.map(option => (
</header> <Button
<ScrollArea className="flex-1"> key={option.value}
<div className="flex flex-col gap-4 p-4 pb-6"> type="button"
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 shadow-sm"> variant={tileProvider === option.value ? "default" : "outline"}
<div className="flex items-start gap-3"> size="sm"
<span className="flex h-10 w-10 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-primary"> onClick={() => setTileProvider(option.value)}
<Layers className="h-5 w-5" /> className={cn(
</span> "rounded-full border border-border/60 px-4 py-1 text-xs font-medium transition-colors",
<div className="flex flex-1 flex-col"> tileProvider === option.value
<span className="text-sm font-semibold">{t('map.tiles.title')}</span> ? "shadow-sm"
<span className="text-xs text-muted-foreground">{t('map.tiles.subtitle')}</span> : "bg-background/80 text-muted-foreground hover:bg-background"
)}
>
{option.label}
</Button>
))}
</div> </div>
</div> </div>
<div className="mt-3 flex flex-wrap gap-2">
{tileOptions.map((option) => ( <OverviewPanel
<Button nearbySignals={localTotals.points}
key={option.value} uniqueContributors={localTotals.contributors}
type="button" lastUpdatedLabel={lastUpdatedLabel}
variant={tileProvider === option.value ? 'default' : 'outline'} mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt, locale) : null}
size="sm" error={error}
onClick={() => setTileProvider(option.value)} onReport={handleManualReport}
className={cn( onRetry={handleRefresh}
'rounded-full border border-border/60 px-4 py-1 text-xs font-medium transition-colors', isPosting={isPosting || isConfirming}
tileProvider === option.value locationHint={locationHint}
? 'shadow-sm' showLocationCta={showLocationCta}
: 'bg-background/80 text-muted-foreground hover:bg-background', disableReport={!hasLocation}
)} />
> <HotspotStatsPanel
{option.label} hasLocation={hasLocation}
</Button> radiusKm={RADIUS_KM}
))} locationHint={locationHint}
cells={dangerCells}
/>
<ActivityPanel items={recentActivity} emptyMessage={t("activity.empty")} />
</div>
</ScrollArea>
</aside>
<AlertDialog
open={isConfirmOpen}
onOpenChange={nextOpen => {
setIsConfirmOpen(nextOpen);
if (!nextOpen) {
setPendingSpot(null);
setIsConfirming(false);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<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">{t("dialog.confirmSignal.latitude")}</span>
<span className="font-medium text-foreground">{confirmationLat}°</span>
</div> </div>
<div className="mt-3 flex items-center justify-between">
<span className="text-muted-foreground">{t("dialog.confirmSignal.longitude")}</span>
<span className="font-medium text-foreground">{confirmationLng}°</span>
</div>
<p className="mt-4 text-xs text-muted-foreground">
{t("dialog.confirmSignal.reach", { radius: RADIUS_KM })}
</p>
</div> </div>
<AlertDialogFooter>
<OverviewPanel <AlertDialogCancel disabled={isConfirming}>{t("dialog.confirmSignal.cancel")}</AlertDialogCancel>
nearbySignals={localTotals.points} <AlertDialogAction onClick={handleConfirmSignal} disabled={isDialogDisabled}>
uniqueContributors={localTotals.contributors} {isConfirming ? t("dialog.confirmSignal.sending") : t("dialog.confirmSignal.confirm")}
lastUpdatedLabel={lastUpdatedLabel} </AlertDialogAction>
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt, locale) : null} </AlertDialogFooter>
error={error} </AlertDialogContent>
onReport={handleManualReport} </AlertDialog>
onRetry={handleRefresh}
isPosting={isPosting || isConfirming}
locationHint={locationHint}
showLocationCta={showLocationCta}
disableReport={!hasLocation}
/>
<HotspotStatsPanel
hasLocation={hasLocation}
radiusKm={RADIUS_KM}
locationHint={locationHint}
cells={dangerCells}
/>
<ActivityPanel items={recentActivity} emptyMessage={t('activity.empty')} />
</div>
</ScrollArea>
</aside>
<AlertDialog
open={isConfirmOpen}
onOpenChange={(nextOpen) => {
setIsConfirmOpen(nextOpen)
if (!nextOpen) {
setPendingSpot(null)
setIsConfirming(false)
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<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">{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">{t('dialog.confirmSignal.longitude')}</span>
<span className="font-medium text-foreground">{confirmationLng}°</span>
</div>
<p className="mt-4 text-xs text-muted-foreground">{t('dialog.confirmSignal.reach', { radius: RADIUS_KM })}</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isConfirming}>{t('dialog.confirmSignal.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSignal} disabled={isDialogDisabled}>
{isConfirming ? t('dialog.confirmSignal.sending') : t('dialog.confirmSignal.confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
<Toaster /> <Toaster />
</> </>
) );
} }
+40 -43
View File
@@ -1,28 +1,28 @@
import { ChevronDown, ChevronUp, Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react' import { ChevronDown, ChevronUp, Flame, Focus, LocateFixed, MapPin, RefreshCw } from "lucide-react";
import { useTranslation } from 'react-i18next' import { useTranslation } from "react-i18next";
import { Badge } from '@/components/ui/badge' import { LanguageToggle } from "@/components/layout/LanguageToggle";
import { Button } from '@/components/ui/button' import { ThemeToggle } from "@/components/layout/ThemeToggle";
import { LanguageToggle } from '@/components/layout/LanguageToggle' import { Badge } from "@/components/ui/badge";
import { ThemeToggle } from '@/components/layout/ThemeToggle' import { Button } from "@/components/ui/button";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import type { FeedStatus } from '@/types/api' import type { FeedStatus } from "@/types/api";
interface AppHeaderProps { interface AppHeaderProps {
status: FeedStatus status: FeedStatus;
statusLabel: string statusLabel: string;
lastUpdatedLabel: string lastUpdatedLabel: string;
onRefresh: () => void onRefresh: () => void;
onFocusHeat: () => void onFocusHeat: () => void;
onLocateUser: () => void onLocateUser: () => void;
onFocusMySignal: () => void onFocusMySignal: () => void;
disableRefresh: boolean disableRefresh: boolean;
disableHeat: boolean disableHeat: boolean;
disableLocate: boolean disableLocate: boolean;
disableMySignal: boolean disableMySignal: boolean;
collapsed: boolean collapsed: boolean;
onToggleCollapse: () => void onToggleCollapse: () => void;
className?: string className?: string;
} }
export function AppHeader({ export function AppHeader({
@@ -41,21 +41,18 @@ export function AppHeader({
onToggleCollapse, onToggleCollapse,
className, className,
}: AppHeaderProps) { }: AppHeaderProps) {
const { t } = useTranslation() const { t } = useTranslation();
const isError = status === 'error' const isError = status === "error";
const statusBadge = ( const statusBadge = (
<Badge <Badge
variant="secondary" variant="secondary"
className={cn( className={cn(
'inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-1 text-xs font-medium uppercase tracking-wide', "inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-1 text-xs font-medium uppercase tracking-wide",
collapsed && 'px-2 py-1 text-[11px] font-semibold', collapsed && "px-2 py-1 text-[11px] font-semibold"
)} )}
> >
<span <span className={`flex items-center gap-2 ${isError ? "text-destructive" : "text-primary"}`} aria-live="polite">
className={`flex items-center gap-2 ${isError ? 'text-destructive' : 'text-primary'}`}
aria-live="polite"
>
<span className="relative block h-2.5 w-2.5 rounded-full bg-current"> <span className="relative block h-2.5 w-2.5 rounded-full bg-current">
<span className="absolute inset-[-0.35rem] rounded-full border border-current opacity-40 animate-status-pulse" /> <span className="absolute inset-[-0.35rem] rounded-full border border-current opacity-40 animate-status-pulse" />
</span> </span>
@@ -63,18 +60,18 @@ export function AppHeader({
</span> </span>
{!collapsed && ( {!collapsed && (
<span className="text-[10px] uppercase text-muted-foreground"> <span className="text-[10px] uppercase text-muted-foreground">
{t('header.badge.updated', { time: lastUpdatedLabel })} {t("header.badge.updated", { time: lastUpdatedLabel })}
</span> </span>
)} )}
</Badge> </Badge>
) );
return ( return (
<header <header
className={cn( className={cn(
'flex w-full max-w-[420px] flex-col gap-3 rounded-3xl border border-border/60 bg-background/90 p-4 text-sm shadow-xl backdrop-blur transition-all', "flex w-full max-w-[420px] flex-col gap-3 rounded-3xl border border-border/60 bg-background/90 p-4 text-sm shadow-xl backdrop-blur transition-all",
collapsed && 'max-w-[240px] bg-background/80 p-3', collapsed && "max-w-[240px] bg-background/80 p-3",
className, className
)} )}
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -83,15 +80,15 @@ 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-base font-semibold sm:text-lg">{t('app.name')}</span> <span className="text-base font-semibold sm:text-lg">{t("app.name")}</span>
{!collapsed && <span className="text-xs text-muted-foreground sm:text-sm">{t('app.tagline')}</span>} {!collapsed && <span className="text-xs text-muted-foreground sm:text-sm">{t("app.tagline")}</span>}
</div> </div>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleCollapse} onClick={onToggleCollapse}
aria-label={collapsed ? t('header.actions.expand') : t('header.actions.collapse')} aria-label={collapsed ? t("header.actions.expand") : t("header.actions.collapse")}
className="h-9 w-9 rounded-full border border-border/50 bg-muted/60 backdrop-blur hover:bg-muted" className="h-9 w-9 rounded-full border border-border/50 bg-muted/60 backdrop-blur hover:bg-muted"
> >
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />} {collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
@@ -105,7 +102,7 @@ export function AppHeader({
size="icon" size="icon"
onClick={onRefresh} onClick={onRefresh}
disabled={disableRefresh} disabled={disableRefresh}
aria-label={t('header.actions.refresh')} aria-label={t("header.actions.refresh")}
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
@@ -114,7 +111,7 @@ export function AppHeader({
size="icon" size="icon"
onClick={onFocusHeat} onClick={onFocusHeat}
disabled={disableHeat} disabled={disableHeat}
aria-label={t('header.actions.focusHeat')} aria-label={t("header.actions.focusHeat")}
> >
<Focus className="h-4 w-4" /> <Focus className="h-4 w-4" />
</Button> </Button>
@@ -123,7 +120,7 @@ export function AppHeader({
size="icon" size="icon"
onClick={onLocateUser} onClick={onLocateUser}
disabled={disableLocate} disabled={disableLocate}
aria-label={t('header.actions.locate')} aria-label={t("header.actions.locate")}
> >
<LocateFixed className="h-4 w-4" /> <LocateFixed className="h-4 w-4" />
</Button> </Button>
@@ -132,7 +129,7 @@ export function AppHeader({
size="icon" size="icon"
onClick={onFocusMySignal} onClick={onFocusMySignal}
disabled={disableMySignal} disabled={disableMySignal}
aria-label={t('header.actions.mySignal')} aria-label={t("header.actions.mySignal")}
> >
<MapPin className="h-4 w-4" /> <MapPin className="h-4 w-4" />
</Button> </Button>
@@ -141,5 +138,5 @@ export function AppHeader({
</div> </div>
)} )}
</header> </header>
) );
} }
+11 -12
View File
@@ -1,28 +1,27 @@
import { Globe } from 'lucide-react' import { Globe } from "lucide-react";
import { useTranslation } from 'react-i18next' import { useTranslation } from "react-i18next";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
export function LanguageToggle() { export function LanguageToggle() {
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation();
const current = i18n.language === 'fr' ? 'fr' : 'en' const current = i18n.language === "fr" ? "fr" : "en";
const next = current === 'en' ? 'fr' : 'en' const next = current === "en" ? "fr" : "en";
const nextLabel = t(next === 'en' ? 'language.english' : 'language.french') const nextLabel = t(next === "en" ? "language.english" : "language.french");
return ( return (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
i18n.changeLanguage(next).catch(() => undefined) i18n.changeLanguage(next).catch(() => undefined);
}} }}
aria-label={t('common.aria.language', { language: nextLabel })} aria-label={t("common.aria.language", { language: nextLabel })}
className="h-9 rounded-full border border-border/60 bg-background/80 px-3 backdrop-blur" className="h-9 rounded-full border border-border/60 bg-background/80 px-3 backdrop-blur"
> >
<span className="sr-only">{t('language.label')}</span> <span className="sr-only">{t("language.label")}</span>
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
<span className="ml-2 text-xs font-semibold uppercase text-muted-foreground">{current.toUpperCase()}</span> <span className="ml-2 text-xs font-semibold uppercase text-muted-foreground">{current.toUpperCase()}</span>
</Button> </Button>
) );
} }
+8 -8
View File
@@ -1,22 +1,22 @@
import { Moon, Sun } from 'lucide-react' import { Moon, Sun } from "lucide-react";
import { useTranslation } from 'react-i18next' 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() const { t } = useTranslation();
return ( return (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={toggleTheme} onClick={toggleTheme}
aria-label={isDark ? t('common.aria.theme.light') : t('common.aria.theme.dark')} 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" />}
</Button> </Button>
) );
} }
+15 -25
View File
@@ -1,39 +1,29 @@
import type { MutableRefObject } from 'react' import type { MutableRefObject } from "react";
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface MapViewportProps { interface MapViewportProps {
containerRef: MutableRefObject<HTMLDivElement | null> containerRef: MutableRefObject<HTMLDivElement | null>;
isPosting: boolean isPosting: boolean;
isLoading: boolean isLoading: boolean;
confirmationHint?: string | null confirmationHint?: string | null;
className?: string className?: string;
} }
export function MapViewport({ export function MapViewport({ containerRef, isPosting, isLoading, confirmationHint, className }: MapViewportProps) {
containerRef, const { t } = useTranslation();
isPosting,
isLoading,
confirmationHint,
className,
}: MapViewportProps) {
const { t } = useTranslation()
return ( return (
<div <div className={cn("relative h-full min-h-screen w-full overflow-hidden bg-muted/40 shadow-inner", className)}>
className={cn( <div ref={containerRef} className={cn("absolute inset-0 z-0", "leaflet-wrapper")} />
'relative h-full min-h-screen w-full overflow-hidden bg-muted/40 shadow-inner',
className,
)}
>
<div ref={containerRef} className={cn('absolute inset-0 z-0', 'leaflet-wrapper')} />
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background/70 to-transparent" /> <div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background/70 to-transparent" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-24 bg-gradient-to-t from-background/70 to-transparent" /> <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-24 bg-gradient-to-t from-background/70 to-transparent" />
{(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 ? t('map.posting') : t('map.loading')} {isPosting ? t("map.posting") : t("map.loading")}
</span> </span>
</div> </div>
)} )}
@@ -43,5 +33,5 @@ export function MapViewport({
</div> </div>
)} )}
</div> </div>
) );
} }
+20 -20
View File
@@ -1,43 +1,43 @@
import { Activity, ArrowRight } from 'lucide-react' import { Activity, ArrowRight } from "lucide-react";
import { useTranslation } from 'react-i18next' 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 { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from "@/components/ui/scroll-area";
interface ActivityItem { interface ActivityItem {
id: string | number id: string | number;
title: string title: string;
subtitle: string subtitle: string;
timestampLabel: string timestampLabel: string;
distanceLabel: string distanceLabel: string;
onFocus: () => void onFocus: () => void;
} }
interface ActivityPanelProps { interface ActivityPanelProps {
items: ActivityItem[] items: ActivityItem[];
emptyMessage: string emptyMessage: string;
} }
export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) { export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) {
const { t } = useTranslation() 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" />
{t('activity.title')} {t("activity.title")}
</CardTitle> </CardTitle>
<CardDescription>{t('activity.description')}</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>}
{items.length > 0 && ( {items.length > 0 && (
<ScrollArea className="max-h-[280px] pr-2"> <ScrollArea className="max-h-[280px] pr-2">
<ul className="space-y-3"> <ul className="space-y-3">
{items.map((item) => ( {items.map(item => (
<li key={item.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3"> <li key={item.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-col"> <div className="flex flex-col">
@@ -51,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}>
{t('activity.view')} {t("activity.view")}
<ArrowRight className="h-3.5 w-3.5" /> <ArrowRight className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -62,5 +62,5 @@ export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
) );
} }
@@ -1,47 +1,45 @@
import { Flame, MapPin } from 'lucide-react' import { Flame, MapPin } from "lucide-react";
import { useTranslation } from 'react-i18next' 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 { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from "@/components/ui/scroll-area";
interface HotspotCellInfo { interface HotspotCellInfo {
id: string id: string;
title: string title: string;
subtitle: string subtitle: string;
intensity: number intensity: number;
onFocus: () => void onFocus: () => void;
} }
interface HotspotStatsPanelProps { interface HotspotStatsPanelProps {
hasLocation: boolean hasLocation: boolean;
radiusKm: number radiusKm: number;
locationHint: string locationHint: string;
cells: HotspotCellInfo[] cells: HotspotCellInfo[];
} }
export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }: HotspotStatsPanelProps) { export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }: HotspotStatsPanelProps) {
const { t } = useTranslation() 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" />
{t('hotspots.title')} {t("hotspots.title")}
</CardTitle> </CardTitle>
<CardDescription>{t('hotspots.description', { radius: radiusKm })}</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">{t("hotspots.empty")}</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">
<ul className="space-y-3"> <ul className="space-y-3">
{cells.map((cell) => ( {cells.map(cell => (
<li key={cell.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3"> <li key={cell.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-col"> <div className="flex flex-col">
@@ -58,7 +56,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" /> {t('hotspots.focus')} <MapPin className="h-3.5 w-3.5" /> {t("hotspots.focus")}
</Button> </Button>
</li> </li>
))} ))}
@@ -67,5 +65,5 @@ export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }
)} )}
</CardContent> </CardContent>
</Card> </Card>
) );
} }
+31 -33
View File
@@ -1,23 +1,23 @@
import { AlertCircle, Radio } from 'lucide-react' import { AlertCircle, Radio } from "lucide-react";
import { useTranslation } from 'react-i18next' 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' 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;
error: FeedError | null error: FeedError | null;
onReport: () => void onReport: () => void;
onRetry: () => void onRetry: () => void;
isPosting: boolean isPosting: boolean;
locationHint: string locationHint: string;
showLocationCta: boolean showLocationCta: boolean;
disableReport: boolean disableReport: boolean;
} }
export function OverviewPanel({ export function OverviewPanel({
@@ -33,32 +33,32 @@ export function OverviewPanel({
showLocationCta, showLocationCta,
disableReport, disableReport,
}: OverviewPanelProps) { }: OverviewPanelProps) {
const { t } = useTranslation() const { t } = useTranslation();
const errorMessage = error ? t(error.key, error.values) : null 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" />
{t('overview.title')} {t("overview.title")}
</CardTitle> </CardTitle>
<CardDescription>{t('overview.description')}</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">{t('overview.stats.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">{t('overview.stats.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">
{t('overview.badge', { time: mySignalLabel })} {t("overview.badge", { time: mySignalLabel })}
</Badge> </Badge>
)} )}
{errorMessage ? ( {errorMessage ? (
@@ -67,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}>
{t('overview.error.action')} {t("overview.error.action")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -75,17 +75,15 @@ 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 {isPosting ? t("overview.cta.sending") : disableReport ? t("overview.cta.waiting") : t("overview.cta.send")}
? 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">{t('overview.locationPermission')}</p> <p className="text-xs text-muted-foreground">{t("overview.locationPermission")}</p>
)} )}
<p className="text-[11px] uppercase text-muted-foreground">{t('overview.lastSynced', { time: lastUpdatedLabel })}</p> <p className="text-[11px] uppercase text-muted-foreground">
{t("overview.lastSynced", { time: lastUpdatedLabel })}
</p>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
+40 -37
View File
@@ -1,13 +1,14 @@
import * as React from 'react' import * as React from "react";
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils' import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
const AlertDialog = AlertDialogPrimitive.Root import { cn } from "@/lib/utils";
const AlertDialogTrigger = AlertDialogPrimitive.Trigger const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogPortal = AlertDialogPrimitive.Portal const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef< const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>, React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
@@ -16,13 +17,13 @@ const AlertDialogOverlay = React.forwardRef<
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className, className
)} )}
{...props} {...props}
/> />
)) ));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef< const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>, React.ElementRef<typeof AlertDialogPrimitive.Content>,
@@ -33,44 +34,40 @@ 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-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', "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}
/> />
</AlertDialogPortal> </AlertDialogPortal>
)) ));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} /> <div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
) );
AlertDialogHeader.displayName = 'AlertDialogHeader' AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} /> <div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
) );
AlertDialogFooter.displayName = 'AlertDialogFooter' AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef< const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>, React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} /> <AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
)) ));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef< const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>, React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
ref={ref} ));
className={cn('text-sm text-muted-foreground', className)} AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef< const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>, React.ElementRef<typeof AlertDialogPrimitive.Action>,
@@ -78,11 +75,14 @@ const AlertDialogAction = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action <AlertDialogPrimitive.Action
ref={ref} ref={ref}
className={cn('inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50', className)} className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props} {...props}
/> />
)) ));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef< const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>, React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
@@ -90,11 +90,14 @@ const AlertDialogCancel = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
ref={ref} ref={ref}
className={cn('inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50 sm:mr-2', className)} className={cn(
"inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50 sm:mr-2",
className
)}
{...props} {...props}
/> />
)) ));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export { export {
AlertDialog, AlertDialog,
@@ -106,4 +109,4 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };
+20 -23
View File
@@ -1,36 +1,33 @@
import * as React from 'react' import * as React from "react";
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{ {
variants: { variants: {
variant: { variant: {
default: 'border-transparent bg-primary/15 text-primary', default: "border-transparent bg-primary/15 text-primary",
secondary: 'border-transparent bg-secondary text-secondary-foreground', secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: 'border-transparent bg-destructive/15 text-destructive', destructive: "border-transparent bg-destructive/15 text-destructive",
outline: 'text-foreground', outline: "text-foreground",
success: 'border-transparent bg-emerald-500/15 text-emerald-300', success: "border-transparent bg-emerald-500/15 text-emerald-300",
muted: 'border-transparent bg-muted/60 text-muted-foreground', muted: "border-transparent bg-muted/60 text-muted-foreground",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, }
) );
export interface BadgeProps export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>( const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(({ className, variant, ...props }, ref) => (
({ className, variant, ...props }, ref) => ( <div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} /> ));
), Badge.displayName = "Badge";
)
Badge.displayName = 'Badge'
export { Badge } export { Badge };
+25 -28
View File
@@ -1,48 +1,45 @@
import * as React from 'react' import * as React from "react";
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-offset-background', "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-offset-background",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm', default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm",
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: "hover:bg-accent hover:text-accent-foreground",
outline: outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground shadow-sm",
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground shadow-sm', destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: "h-10 px-4 py-2",
sm: 'h-9 rounded-md px-3', sm: "h-9 rounded-md px-3",
lg: 'h-11 rounded-md px-5 text-base', lg: "h-11 rounded-md px-5 text-base",
icon: 'h-10 w-10', icon: "h-10 w-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, }
) );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button' const Comp = asChild ? Slot : "button";
return ( return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />;
<Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} /> }
) );
}, Button.displayName = "Button";
)
Button.displayName = 'Button'
+33 -37
View File
@@ -1,54 +1,50 @@
import * as React from 'react' import * as React from "react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
({ className, ...props }, ref) => ( <div
<div ref={ref}
ref={ref} className={cn(
className={cn( "rounded-xl border border-border/60 bg-card/80 text-card-foreground shadow-sm backdrop-blur",
'rounded-xl border border-border/60 bg-card/80 text-card-foreground shadow-sm backdrop-blur', className
className, )}
)} {...props}
{...props} />
/> ));
), Card.displayName = "Card";
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} /> <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
), )
) );
CardHeader.displayName = 'CardHeader' CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>( const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} /> <h3 ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
), )
) );
CardTitle.displayName = 'CardTitle' CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>( const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> <p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
), )
) );
CardDescription.displayName = 'CardDescription' CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> );
), CardContent.displayName = "CardContent";
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0 text-sm text-muted-foreground', className)} {...props} /> <div ref={ref} className={cn("flex items-center p-6 pt-0 text-sm text-muted-foreground", className)} {...props} />
), )
) );
CardFooter.displayName = 'CardFooter' CardFooter.displayName = "CardFooter";
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
+16 -17
View File
@@ -1,40 +1,39 @@
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' import * as React from "react";
import * as React from 'react'
import { cn } from '@/lib/utils' import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef< const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>, React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}> <ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar /> <ScrollBar />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
)) ));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef< const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => ( >(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar <ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref} ref={ref}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'flex touch-none select-none transition-colors', "flex touch-none select-none transition-colors",
orientation === 'vertical' && 'h-full w-2 border-l border-l-transparent p-[1px]', orientation === "vertical" && "h-full w-2 border-l border-l-transparent p-[1px]",
orientation === 'horizontal' && 'h-2 border-t border-t-transparent p-[1px]', orientation === "horizontal" && "h-2 border-t border-t-transparent p-[1px]",
className, className
)} )}
{...props} {...props}
> >
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border/60" /> <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border/60" />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
)) ));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar } export { ScrollArea, ScrollBar };
+8 -12
View File
@@ -1,22 +1,18 @@
import * as React from 'react' import * as React from "react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const Separator = React.forwardRef< const Separator = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { orientation?: 'horizontal' | 'vertical' } React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }
>(({ className, orientation = 'horizontal', ...props }, ref) => ( >(({ className, orientation = "horizontal", ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn("shrink-0 bg-border/60", orientation === "horizontal" ? "h-px w-full" : "h-full w-px", className)}
'shrink-0 bg-border/60',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
role="none" role="none"
{...props} {...props}
/> />
)) ));
Separator.displayName = 'Separator' Separator.displayName = "Separator";
export { Separator } export { Separator };
+58 -58
View File
@@ -1,16 +1,17 @@
import * as React from 'react' import * as React from "react";
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import * as SheetPrimitive from "@radix-ui/react-dialog";
import { useTranslation } from "react-i18next";
const Sheet = SheetPrimitive.Root import { cn } from "@/lib/utils";
const SheetTrigger = SheetPrimitive.Trigger const Sheet = SheetPrimitive.Root;
const SheetClose = SheetPrimitive.Close const SheetTrigger = SheetPrimitive.Trigger;
const SheetPortal = SheetPrimitive.Portal const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef< const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>, React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -18,79 +19,78 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
'fixed inset-0 z-40 bg-background/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', "fixed inset-0 z-40 bg-background/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className, className
)} )}
{...props} {...props}
ref={ref} ref={ref}
/> />
)) ));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
type SheetSide = 'top' | 'bottom' | 'left' | 'right' type SheetSide = "top" | "bottom" | "left" | "right";
interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> { interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> {
side?: SheetSide side?: SheetSide;
} }
const SheetContent = React.forwardRef< const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
React.ElementRef<typeof SheetPrimitive.Content>, ({ side = "bottom", className, children, ...props }, ref) => {
SheetContentProps const { t } = useTranslation();
>(({ side = 'bottom', className, children, ...props }, ref) => {
const { t } = useTranslation()
return ( return (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content
ref={ref} ref={ref}
className={cn( 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', "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 === "bottom" && "inset-x-0 bottom-0 rounded-t-2xl border-t",
side === 'top' && 'inset-x-0 top-0 rounded-b-2xl border-b', 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 === "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', side === "right" && "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
className, className
)} )}
data-side={side} data-side={side}
{...props} {...props}
> >
{children} {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"> <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')} {t("common.actions.close")}
<span className="sr-only">{t('common.aria.sheet.close')}</span> <span className="sr-only">{t("common.aria.sheet.close")}</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </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>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} /> <div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
) );
SheetHeader.displayName = 'SheetHeader' SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} /> <div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
) );
SheetFooter.displayName = 'SheetFooter' SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef< const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>, React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} /> <SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
)) ));
SheetTitle.displayName = SheetPrimitive.Title.displayName SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef< const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>, React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> <SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)) ));
SheetDescription.displayName = SheetPrimitive.Description.displayName SheetDescription.displayName = SheetPrimitive.Description.displayName;
export { export {
Sheet, Sheet,
@@ -103,4 +103,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };
+20 -16
View File
@@ -1,9 +1,10 @@
import * as TabsPrimitive from '@radix-ui/react-tabs' import * as React from "react";
import * as React from 'react'
import { cn } from '@/lib/utils' import * as TabsPrimitive from "@radix-ui/react-tabs";
const Tabs = TabsPrimitive.Root import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef< const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>, React.ElementRef<typeof TabsPrimitive.List>,
@@ -11,11 +12,14 @@ const TabsList = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn('inline-flex h-10 items-center justify-center rounded-full bg-muted p-1 text-muted-foreground', className)} className={cn(
"inline-flex h-10 items-center justify-center rounded-full bg-muted p-1 text-muted-foreground",
className
)}
{...props} {...props}
/> />
)) ));
TabsList.displayName = TabsPrimitive.List.displayName TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef< const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>, React.ElementRef<typeof TabsPrimitive.Trigger>,
@@ -24,13 +28,13 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex min-w-[120px] items-center justify-center whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=inactive]:opacity-70', "inline-flex min-w-[120px] items-center justify-center whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=inactive]:opacity-70",
className, className
)} )}
{...props} {...props}
/> />
)) ));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef< const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>, React.ElementRef<typeof TabsPrimitive.Content>,
@@ -39,12 +43,12 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', "mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className, className
)} )}
{...props} {...props}
/> />
)) ));
TabsContent.displayName = TabsPrimitive.Content.displayName TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };
+41 -48
View File
@@ -1,11 +1,12 @@
import * as React from 'react' import * as React from "react";
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils' import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
const ToastProvider = ToastPrimitives.Provider import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
@@ -14,28 +15,28 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-x-0 top-4 z-[1000] mx-auto flex w-full max-w-sm flex-col gap-2 px-4 sm:right-4 sm:left-auto sm:mx-0 sm:w-96', "fixed inset-x-0 top-4 z-[1000] mx-auto flex w-full max-w-sm flex-col gap-2 px-4 sm:right-4 sm:left-auto sm:mx-0 sm:w-96",
className, className
)} )}
{...props} {...props}
/> />
)) ));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-2xl border border-border bg-background p-4 shadow-lg transition-all', "group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-2xl border border-border bg-background p-4 shadow-lg transition-all",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-background text-foreground', default: "bg-background text-foreground",
destructive: 'border-destructive/60 bg-destructive text-destructive-foreground', destructive: "border-destructive/60 bg-destructive text-destructive-foreground",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, }
) );
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
@@ -45,9 +46,9 @@ const Toast = React.forwardRef<
<ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props}> <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props}>
{props.children} {props.children}
</ToastPrimitives.Root> </ToastPrimitives.Root>
) );
}) });
Toast.displayName = ToastPrimitives.Root.displayName Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
@@ -56,13 +57,13 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-background px-3 text-xs font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', "inline-flex h-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-background px-3 text-xs font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
className, className
)} )}
{...props} {...props}
/> />
)) ));
ToastAction.displayName = ToastPrimitives.Action.displayName ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>, React.ElementRef<typeof ToastPrimitives.Close>,
@@ -71,46 +72,38 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( className={cn(
'absolute right-3 top-3 rounded-full p-1 text-foreground/70 transition-colors hover:bg-foreground/10 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', "absolute right-3 top-3 rounded-full p-1 text-foreground/70 transition-colors hover:bg-foreground/10 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
className, className
)} )}
toast-close="" toast-close=""
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)) ));
ToastClose.displayName = ToastPrimitives.Close.displayName ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold leading-none tracking-tight', className)} {...props} /> <ToastPrimitives.Title
)) ref={ref}
ToastTitle.displayName = ToastPrimitives.Title.displayName className={cn("text-sm font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Description <ToastPrimitives.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
ref={ref} ));
className={cn('text-sm text-muted-foreground', className)} ToastDescription.displayName = ToastPrimitives.Description.displayName;
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
export type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> export type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
export type ToastActionElement = React.ReactElement<typeof ToastAction> export type ToastActionElement = React.ReactElement<typeof ToastAction>;
export { export { ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction };
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+6 -13
View File
@@ -1,15 +1,8 @@
import { import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast";
Toast, import { useToast } from "@/components/ui/use-toast";
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast'
export function Toaster() { export function Toaster() {
const { toasts, dismiss } = useToast() const { toasts, dismiss } = useToast();
return ( return (
<ToastProvider> <ToastProvider>
@@ -17,9 +10,9 @@ export function Toaster() {
<Toast <Toast
key={id} key={id}
{...props} {...props}
onOpenChange={(open) => { onOpenChange={open => {
if (!open) { if (!open) {
dismiss(id) dismiss(id);
} }
}} }}
> >
@@ -33,5 +26,5 @@ export function Toaster() {
))} ))}
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
) );
} }
+63 -67
View File
@@ -1,129 +1,125 @@
import * as React from 'react' import * as React from "react";
import type { ToastActionElement, ToastProps } from '@/components/ui/toast' import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
} };
type ToastState = { type ToastState = {
toasts: ToasterToast[] toasts: ToasterToast[];
} };
type ToastAction = type ToastAction =
| { type: 'ADD_TOAST'; toast: ToasterToast } | { type: "ADD_TOAST"; toast: ToasterToast }
| { type: 'UPDATE_TOAST'; toast: Partial<ToasterToast> & { id: string } } | { type: "UPDATE_TOAST"; toast: Partial<ToasterToast> & { id: string } }
| { type: 'DISMISS_TOAST'; toastId?: string } | { type: "DISMISS_TOAST"; toastId?: string }
| { type: 'REMOVE_TOAST'; toastId?: string } | { type: "REMOVE_TOAST"; toastId?: string };
const TOAST_LIMIT = 5 const TOAST_LIMIT = 5;
const TOAST_REMOVE_DELAY = 1000 const TOAST_REMOVE_DELAY = 1000;
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const listeners = new Set<(state: ToastState) => void>() const listeners = new Set<(state: ToastState) => void>();
let memoryState: ToastState = { toasts: [] } let memoryState: ToastState = { toasts: [] };
function dispatch(action: ToastAction) { function dispatch(action: ToastAction) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach(listener => {
listener(memoryState) listener(memoryState);
}) });
} }
function reducer(state: ToastState, action: ToastAction): ToastState { function reducer(state: ToastState, action: ToastAction): ToastState {
switch (action.type) { switch (action.type) {
case 'ADD_TOAST': { case "ADD_TOAST": {
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
} }
case 'UPDATE_TOAST': { case "UPDATE_TOAST": {
return { return {
...state, ...state,
toasts: state.toasts.map((toast) => toasts: state.toasts.map(toast => (toast.id === action.toast.id ? { ...toast, ...action.toast } : toast)),
toast.id === action.toast.id ? { ...toast, ...action.toast } : toast, };
),
}
} }
case 'DISMISS_TOAST': { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action;
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach(toast => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
...state, ...state,
toasts: state.toasts.map((toast) => toasts: state.toasts.map(toast =>
toast.id === toastId || toastId === undefined toast.id === toastId || toastId === undefined ? { ...toast, open: false } : toast
? { ...toast, open: false }
: toast,
), ),
} };
} }
case 'REMOVE_TOAST': { case "REMOVE_TOAST": {
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { ...state, toasts: [] } return { ...state, toasts: [] };
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((toast) => toast.id !== action.toastId), toasts: state.toasts.filter(toast => toast.id !== action.toastId),
} };
} }
default: default:
return state return state;
} }
} }
function addToRemoveQueue(toastId: string) { function addToRemoveQueue(toastId: string) {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ type: 'REMOVE_TOAST', toastId }) dispatch({ type: "REMOVE_TOAST", toastId });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} }
function genId() { function genId() {
return Math.random().toString(36).slice(2, 10) return Math.random().toString(36).slice(2, 10);
} }
export function useToast() { export function useToast() {
const [state, setState] = React.useState<ToastState>(memoryState) const [state, setState] = React.useState<ToastState>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.add(setState) listeners.add(setState);
return () => { return () => {
listeners.delete(setState) listeners.delete(setState);
} };
}, []) }, []);
return { return {
...state, ...state,
toast: (props: Omit<ToasterToast, 'id'>) => { toast: (props: Omit<ToasterToast, "id">) => {
const id = genId() const id = genId();
dispatch({ type: 'ADD_TOAST', toast: { ...props, id, open: true } }) dispatch({ type: "ADD_TOAST", toast: { ...props, id, open: true } });
return id return id;
}, },
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
} };
} }
export const toast = (props: Omit<ToasterToast, 'id'>) => { export const toast = (props: Omit<ToasterToast, "id">) => {
const id = genId() const id = genId();
dispatch({ type: 'ADD_TOAST', toast: { ...props, id, open: true } }) dispatch({ type: "ADD_TOAST", toast: { ...props, id, open: true } });
return id return id;
} };
+146 -159
View File
@@ -1,50 +1,43 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { distanceInKm } from '@/lib/utils' import { distanceInKm } from "@/lib/utils";
import type { import type { ApiDensityCell, ApiPoint, ApiSnapshot, FeedStatus, Point, SnapshotEventPayload } from "@/types/api";
ApiDensityCell,
ApiPoint,
ApiSnapshot,
FeedStatus,
Point,
SnapshotEventPayload,
} from '@/types/api'
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals' const API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8000/api/signals";
const SNAPSHOT_LIMIT = 750 const SNAPSHOT_LIMIT = 750;
const MERCURE_HUB = import.meta.env.VITE_MERCURE_HUB ?? 'http://localhost:3000/.well-known/mercure' const MERCURE_HUB = import.meta.env.VITE_MERCURE_HUB ?? "http://localhost:3000/.well-known/mercure";
const MERCURE_TOPIC = import.meta.env.VITE_MERCURE_TOPIC ?? 'https://points-of-interest.local/signals' const MERCURE_TOPIC = import.meta.env.VITE_MERCURE_TOPIC ?? "https://points-of-interest.local/signals";
const VISIBLE_RADIUS_KM = 1 const VISIBLE_RADIUS_KM = 1;
type ProblemDetails = { type ProblemDetails = {
detail?: unknown detail?: unknown;
} };
function extractProblemDetail(payload: unknown): string | null { function extractProblemDetail(payload: unknown): string | null {
if (payload && typeof payload === 'object') { if (payload && typeof payload === "object") {
const { detail } = payload as ProblemDetails const { detail } = payload as ProblemDetails;
if (typeof detail === 'string' && detail.trim().length > 0) { if (typeof detail === "string" && detail.trim().length > 0) {
return detail return detail;
} }
} }
return null return null;
} }
interface UseHotspotFeedOptions { interface UseHotspotFeedOptions {
userLocation: Point | null userLocation: Point | null;
snapshotLimit?: number snapshotLimit?: number;
mercureHub?: string mercureHub?: string;
mercureTopic?: string mercureTopic?: string;
} }
interface SubmitResult { interface SubmitResult {
success: boolean success: boolean;
} }
export interface FeedError { export interface FeedError {
key: string key: string;
values?: Record<string, unknown> values?: Record<string, unknown>;
detail?: string detail?: string;
} }
export function useHotspotFeed({ export function useHotspotFeed({
@@ -53,228 +46,222 @@ export function useHotspotFeed({
mercureHub = MERCURE_HUB, mercureHub = MERCURE_HUB,
mercureTopic = MERCURE_TOPIC, mercureTopic = MERCURE_TOPIC,
}: UseHotspotFeedOptions) { }: UseHotspotFeedOptions) {
const [status, setStatus] = useState<FeedStatus>('loading') const [status, setStatus] = useState<FeedStatus>("loading");
const [error, setError] = useState<FeedError | 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[]>([]);
const [clientKey, setClientKey] = useState<string | null>(null) const [clientKey, setClientKey] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null) const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const statusRef = useRef<FeedStatus>('loading') const statusRef = useRef<FeedStatus>("loading");
const initialLoadRef = useRef(true) const initialLoadRef = useRef(true);
const eventSourceRef = useRef<EventSource | null>(null) const eventSourceRef = useRef<EventSource | null>(null);
const setStatusSafe = useCallback((next: FeedStatus) => { const setStatusSafe = useCallback((next: FeedStatus) => {
statusRef.current = next statusRef.current = next;
setStatus(next) setStatus(next);
}, []) }, []);
const applySnapshot = useCallback( const applySnapshot = useCallback(
(snapshot: ApiSnapshot, options?: { preserveClientKey?: boolean }) => { (snapshot: ApiSnapshot, options?: { preserveClientKey?: boolean }) => {
setRawPoints(snapshot.points) setRawPoints(snapshot.points);
setRawDensity(snapshot.density) setRawDensity(snapshot.density);
setRawLatestByUser(snapshot.latestByUser) setRawLatestByUser(snapshot.latestByUser);
if (!options?.preserveClientKey) { if (!options?.preserveClientKey) {
setClientKey(snapshot.clientKey ?? null) setClientKey(snapshot.clientKey ?? null);
} }
setLastUpdated(snapshot.updatedAt ?? new Date().toISOString()) setLastUpdated(snapshot.updatedAt ?? new Date().toISOString());
setError(null) setError(null);
initialLoadRef.current = false initialLoadRef.current = false;
if (statusRef.current !== 'posting') { if (statusRef.current !== "posting") {
setStatusSafe('idle') setStatusSafe("idle");
} }
}, },
[setStatusSafe], [setStatusSafe]
) );
const fetchSnapshot = useCallback( const fetchSnapshot = useCallback(
async (options?: { silent?: boolean }) => { async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false const silent = options?.silent ?? false;
const previousStatus = statusRef.current const previousStatus = statusRef.current;
const isInitial = initialLoadRef.current const isInitial = initialLoadRef.current;
if (previousStatus !== 'posting') { if (previousStatus !== "posting") {
if (isInitial) { if (isInitial) {
setStatusSafe('loading') setStatusSafe("loading");
} else if (!silent) { } else if (!silent) {
setStatusSafe('refreshing') setStatusSafe("refreshing");
} }
} }
try { try {
const response = await fetch(`${API_BASE}?limit=${snapshotLimit}`, { cache: 'no-store' }) const response = await fetch(`${API_BASE}?limit=${snapshotLimit}`, { cache: "no-store" });
if (!response.ok) { if (!response.ok) {
const payload = await response.json().catch(() => null) const payload = await response.json().catch(() => null);
const detail = extractProblemDetail(payload) const detail = extractProblemDetail(payload);
setError({ key: 'errors.feedUnavailable', detail: detail ?? undefined }) setError({ key: "errors.feedUnavailable", detail: detail ?? undefined });
if (initialLoadRef.current) { if (initialLoadRef.current) {
setStatusSafe('error') setStatusSafe("error");
} else if (previousStatus !== 'posting') { } else if (previousStatus !== "posting") {
setStatusSafe('idle') setStatusSafe("idle");
} }
return return;
} }
const data: ApiSnapshot = await response.json() const data: ApiSnapshot = await response.json();
applySnapshot(data) applySnapshot(data);
} catch (error) { } catch (error) {
const detail = error instanceof Error && error.message ? error.message : null const detail = error instanceof Error && error.message ? error.message : null;
setError({ key: 'errors.feedUnknown', detail: detail ?? undefined }) setError({ key: "errors.feedUnknown", detail: detail ?? undefined });
if (initialLoadRef.current) { if (initialLoadRef.current) {
setStatusSafe('error') setStatusSafe("error");
} else if (previousStatus !== 'posting') { } else if (previousStatus !== "posting") {
setStatusSafe('idle') setStatusSafe("idle");
} }
} }
}, },
[snapshotLimit, setStatusSafe, applySnapshot], [snapshotLimit, setStatusSafe, applySnapshot]
) );
const connectToStream = useCallback(() => { const connectToStream = useCallback(() => {
try { try {
eventSourceRef.current?.close() eventSourceRef.current?.close();
eventSourceRef.current = null eventSourceRef.current = null;
const url = new URL(mercureHub) const url = new URL(mercureHub);
url.searchParams.append('topic', mercureTopic) url.searchParams.append("topic", mercureTopic);
const eventSource = new EventSource(url.toString()) const eventSource = new EventSource(url.toString());
eventSource.onmessage = (event) => { eventSource.onmessage = event => {
try { try {
const payload: SnapshotEventPayload = JSON.parse(event.data) const payload: SnapshotEventPayload = JSON.parse(event.data);
if (payload?.type === 'snapshot' && payload.payload) { if (payload?.type === "snapshot" && payload.payload) {
applySnapshot(payload.payload, { preserveClientKey: true }) applySnapshot(payload.payload, { preserveClientKey: true });
} }
} catch (parseError) { } catch (parseError) {
console.error('Failed to parse stream payload', parseError) console.error("Failed to parse stream payload", parseError);
} }
} };
eventSource.onerror = () => { eventSource.onerror = () => {
if (statusRef.current !== 'loading') { if (statusRef.current !== "loading") {
setError({ key: 'errors.feedUnknown' }) setError({ key: "errors.feedUnknown" });
setStatusSafe('error') setStatusSafe("error");
} }
} };
eventSourceRef.current = eventSource eventSourceRef.current = eventSource;
} catch (connectionError) { } catch (connectionError) {
console.error('Unable to subscribe to live updates', connectionError) console.error("Unable to subscribe to live updates", connectionError);
setError({ key: 'errors.feedUnavailable' }) setError({ key: "errors.feedUnavailable" });
setStatusSafe('error') setStatusSafe("error");
} }
}, [applySnapshot, mercureHub, mercureTopic, setStatusSafe]) }, [applySnapshot, mercureHub, mercureTopic, setStatusSafe]);
useEffect(() => { useEffect(() => {
fetchSnapshot().catch(() => undefined) fetchSnapshot().catch(() => undefined);
connectToStream() connectToStream();
return () => { return () => {
eventSourceRef.current?.close() eventSourceRef.current?.close();
eventSourceRef.current = null eventSourceRef.current = null;
} };
}, [connectToStream, fetchSnapshot]) }, [connectToStream, fetchSnapshot]);
const submitPoint = useCallback( const submitPoint = useCallback(
async (target: Point): Promise<SubmitResult> => { async (target: Point): Promise<SubmitResult> => {
if (!userLocation) { if (!userLocation) {
setError({ setError({
key: 'errors.submitWithReason', key: "errors.submitWithReason",
values: { message: 'Location required before submitting.' }, values: { message: "Location required before submitting." },
detail: 'Location required before submitting.', detail: "Location required before submitting.",
}) });
setStatusSafe('error') setStatusSafe("error");
return { success: false } return { success: false };
} }
setStatusSafe('posting') setStatusSafe("posting");
try { try {
const response = await fetch(API_BASE, { const response = await fetch(API_BASE, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
signalLocation: target, signalLocation: target,
userLocation, userLocation,
}), }),
}) });
if (!response.ok) { if (!response.ok) {
const payload = await response.json().catch(() => null) const payload = await response.json().catch(() => null);
const detail = extractProblemDetail(payload) const detail = extractProblemDetail(payload);
if (detail) { if (detail) {
setError({ setError({
key: 'errors.submitWithReason', key: "errors.submitWithReason",
values: { message: detail }, values: { message: detail },
detail, detail,
}) });
} else { } else {
setError({ key: 'errors.submitUnavailable' }) setError({ key: "errors.submitUnavailable" });
} }
setStatusSafe('error') setStatusSafe("error");
return { success: false } return { success: false };
} }
setError(null) setError(null);
setStatusSafe('idle') setStatusSafe("idle");
return { success: true } return { success: true };
} catch (error) { } catch (error) {
const detail = error instanceof Error && error.message ? error.message : null const detail = error instanceof Error && error.message ? error.message : null;
if (detail) { if (detail) {
setError({ key: 'errors.submitWithReason', values: { message: detail }, detail }) setError({ key: "errors.submitWithReason", values: { message: detail }, detail });
} else { } else {
setError({ key: 'errors.submitUnknown' }) setError({ key: "errors.submitUnknown" });
} }
setStatusSafe('error') setStatusSafe("error");
return { success: false } return { success: false };
} }
}, },
[setStatusSafe, userLocation], [setStatusSafe, userLocation]
) );
const hasClientKey = Boolean(clientKey) const hasClientKey = Boolean(clientKey);
const filterDensityWithinRadius = useCallback( const filterDensityWithinRadius = useCallback((collection: ApiDensityCell[], origin: Point | null) => {
(collection: ApiDensityCell[], origin: Point | null) => { if (!origin) {
if (!origin) { return collection;
return collection }
} return collection.filter(item => distanceInKm(origin, item) <= VISIBLE_RADIUS_KM);
return collection.filter((item) => distanceInKm(origin, item) <= VISIBLE_RADIUS_KM) }, []);
},
[],
)
const filterPointsWithinRadius = useCallback( const filterPointsWithinRadius = useCallback((collection: ApiPoint[], origin: Point | null) => {
(collection: ApiPoint[], origin: Point | null) => { if (!origin) {
if (!origin) { return collection;
return collection }
} return collection.filter(item => distanceInKm(origin, item.signalLocation) <= VISIBLE_RADIUS_KM);
return collection.filter((item) => distanceInKm(origin, item.signalLocation) <= VISIBLE_RADIUS_KM) }, []);
},
[],
)
const selectVisibleDensity = useCallback( const selectVisibleDensity = useCallback(
(origin: Point | null) => filterDensityWithinRadius(rawDensity, origin), (origin: Point | null) => filterDensityWithinRadius(rawDensity, origin),
[filterDensityWithinRadius, rawDensity], [filterDensityWithinRadius, rawDensity]
) );
const selectVisiblePoints = useCallback( const selectVisiblePoints = useCallback(
(origin: Point | null) => filterPointsWithinRadius(rawPoints, origin), (origin: Point | null) => filterPointsWithinRadius(rawPoints, origin),
[filterPointsWithinRadius, rawPoints], [filterPointsWithinRadius, rawPoints]
) );
const selectVisibleLatestByUser = useCallback( const selectVisibleLatestByUser = useCallback(
(origin: Point | null) => filterPointsWithinRadius(rawLatestByUser, origin), (origin: Point | null) => filterPointsWithinRadius(rawLatestByUser, origin),
[filterPointsWithinRadius, rawLatestByUser], [filterPointsWithinRadius, rawLatestByUser]
) );
const myLatestPoint = useMemo(() => { const myLatestPoint = useMemo(() => {
if (!hasClientKey) { if (!hasClientKey) {
return null return null;
} }
return rawLatestByUser.find((point) => point.userKey === clientKey) ?? null return rawLatestByUser.find(point => point.userKey === clientKey) ?? null;
}, [clientKey, hasClientKey, rawLatestByUser]) }, [clientKey, hasClientKey, rawLatestByUser]);
return { return {
status, status,
@@ -291,5 +278,5 @@ export function useHotspotFeed({
selectVisiblePoints, selectVisiblePoints,
selectVisibleLatestByUser, selectVisibleLatestByUser,
myLatestPoint, myLatestPoint,
} };
} }
+140 -142
View File
@@ -1,66 +1,67 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from "react";
import type { MutableRefObject } from 'react' import type { MutableRefObject } from "react";
import L, { import L, {
type LeafletMouseEvent, type LeafletMouseEvent,
type Map as LeafletMap, type Map as LeafletMap,
type LayerGroup, type LayerGroup,
type TileLayer, type TileLayer,
type TileLayerOptions, type TileLayerOptions,
} from 'leaflet' } from "leaflet";
import 'leaflet.heat' import "leaflet.heat";
import type { ApiDensityCell, Point } from '@/types/api' import type { ApiDensityCell, Point } from "@/types/api";
type HeatPoint = [number, number, number?] type HeatPoint = [number, number, number?];
type LeafletHeatLayer = L.Layer & { type LeafletHeatLayer = L.Layer & {
setLatLngs(points: HeatPoint[]): LeafletHeatLayer setLatLngs(points: HeatPoint[]): LeafletHeatLayer;
getBounds?: () => L.LatLngBounds getBounds?: () => L.LatLngBounds;
} };
type LeafletWithHeat = typeof L & { type LeafletWithHeat = typeof L & {
heatLayer?: (points: HeatPoint[], options?: Record<string, unknown>) => LeafletHeatLayer heatLayer?: (points: HeatPoint[], options?: Record<string, unknown>) => LeafletHeatLayer;
} };
interface UseLeafletHeatmapParams { interface UseLeafletHeatmapParams {
heatCells: ApiDensityCell[] heatCells: ApiDensityCell[];
userLocation: Point | null userLocation: Point | null;
onRequestSpot?: (position: Point) => void onRequestSpot?: (position: Point) => void;
tileProvider: TileProvider tileProvider: TileProvider;
} }
interface UseLeafletHeatmapResult { interface UseLeafletHeatmapResult {
mapContainerRef: MutableRefObject<HTMLDivElement | null> mapContainerRef: MutableRefObject<HTMLDivElement | null>;
focusOn: (position: Point, zoom?: number) => void focusOn: (position: Point, zoom?: number) => void;
fitToHeat: () => void fitToHeat: () => void;
map: LeafletMap | null map: LeafletMap | null;
} }
type TileSource = { type TileSource = {
readonly url: string readonly url: string;
readonly attribution: string readonly attribution: string;
readonly options?: TileLayerOptions readonly options?: TileLayerOptions;
} };
const TILE_SOURCES = { const TILE_SOURCES = {
openstreetmap: { openstreetmap: {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: '© OpenStreetMap contributors', attribution: "© OpenStreetMap contributors",
}, },
mapbox: { mapbox: {
url: 'https://api.mapbox.com/styles/v1/mapbox/navigation-day-v1/tiles/{z}/{x}/{y}?access_token=pk.eyJ1IjoiMWNhbnNhIiwiYSI6ImNsdzZ5cHp3bTFheWUydHJ6dHA4empteWEifQ.a3bODguIOY5HqhsVIvW48Q', url: "https://api.mapbox.com/styles/v1/mapbox/navigation-day-v1/tiles/{z}/{x}/{y}?access_token=pk.eyJ1IjoiMWNhbnNhIiwiYSI6ImNsdzZ5cHp3bTFheWUydHJ6dHA4empteWEifQ.a3bODguIOY5HqhsVIvW48Q",
attribution: '© Mapbox, © OpenStreetMap contributors', attribution: "© Mapbox, © OpenStreetMap contributors",
options: { options: {
tileSize: 512, tileSize: 512,
zoomOffset: -1, zoomOffset: -1,
} satisfies TileLayerOptions, } satisfies TileLayerOptions,
}, },
} as const satisfies Record<string, TileSource> } as const satisfies Record<string, TileSource>;
export type TileProvider = keyof typeof TILE_SOURCES export type TileProvider = keyof typeof TILE_SOURCES;
const INITIAL_VIEW: Point = { lat: 20, lng: 0 } const INITIAL_VIEW: Point = { lat: 20, lng: 0 };
const DEFAULT_ZOOM = 3 const DEFAULT_ZOOM = 3;
export function useLeafletHeatmap({ export function useLeafletHeatmap({
heatCells, heatCells,
@@ -68,37 +69,34 @@ export function useLeafletHeatmap({
onRequestSpot, onRequestSpot,
tileProvider, tileProvider,
}: UseLeafletHeatmapParams): UseLeafletHeatmapResult { }: UseLeafletHeatmapParams): UseLeafletHeatmapResult {
const mapRef = useRef<LeafletMap | null>(null) const mapRef = useRef<LeafletMap | null>(null);
const heatLayerRef = useRef<LeafletHeatLayer | null>(null) const heatLayerRef = useRef<LeafletHeatLayer | null>(null);
const userLayerRef = useRef<LayerGroup | null>(null) const userLayerRef = useRef<LayerGroup | null>(null);
const tileLayerRef = useRef<TileLayer | null>(null) const tileLayerRef = useRef<TileLayer | null>(null);
const hasCenteredOnUserRef = useRef(false) const hasCenteredOnUserRef = useRef(false);
const mapContainerRef = useRef<HTMLDivElement | null>(null) const mapContainerRef = useRef<HTMLDivElement | null>(null);
const onRequestSpotRef = useRef(onRequestSpot) const onRequestSpotRef = useRef(onRequestSpot);
const tileProviderRef = useRef<TileProvider>(tileProvider) const tileProviderRef = useRef<TileProvider>(tileProvider);
const createTileLayer = useCallback( const createTileLayer = useCallback((provider: TileProvider) => {
(provider: TileProvider) => { const leaflet = L as LeafletWithHeat;
const leaflet = L as LeafletWithHeat const source: TileSource = TILE_SOURCES[provider];
const source: TileSource = TILE_SOURCES[provider] const options = source.options ?? {};
const options = source.options ?? {} return leaflet.tileLayer(source.url, {
return leaflet.tileLayer(source.url, { attribution: source.attribution,
attribution: source.attribution, crossOrigin: true,
crossOrigin: true, maxZoom: 19,
maxZoom: 19, ...options,
...options, });
}) }, []);
},
[],
)
const initialiseMap = useCallback(() => { const initialiseMap = useCallback(() => {
if (mapRef.current || !mapContainerRef.current) { if (mapRef.current || !mapContainerRef.current) {
return return;
} }
const leaflet = L as LeafletWithHeat const leaflet = L as LeafletWithHeat;
const container = mapContainerRef.current const container = mapContainerRef.current;
const map = leaflet const map = leaflet
.map(container, { .map(container, {
@@ -107,179 +105,179 @@ export function useLeafletHeatmap({
zoomControl: false, zoomControl: false,
attributionControl: false, attributionControl: false,
}) })
.setView([INITIAL_VIEW.lat, INITIAL_VIEW.lng], DEFAULT_ZOOM) .setView([INITIAL_VIEW.lat, INITIAL_VIEW.lng], DEFAULT_ZOOM);
const tileLayer = createTileLayer(tileProviderRef.current) const tileLayer = createTileLayer(tileProviderRef.current);
tileLayer.addTo(map) tileLayer.addTo(map);
const heatLayer = const heatLayer =
typeof leaflet.heatLayer === 'function' typeof leaflet.heatLayer === "function"
? leaflet.heatLayer([], { ? leaflet.heatLayer([], {
radius: 32, radius: 32,
blur: 24, blur: 24,
maxZoom: 12, maxZoom: 12,
gradient: { gradient: {
0.2: '#38bdf8', 0.2: "#38bdf8",
0.4: '#0ea5e9', 0.4: "#0ea5e9",
0.6: '#fbbf24', 0.6: "#fbbf24",
0.8: '#fb923c', 0.8: "#fb923c",
1: '#ef4444', 1: "#ef4444",
}, },
}) })
: null : null;
const userLayer = leaflet.layerGroup().addTo(map) const userLayer = leaflet.layerGroup().addTo(map);
if (heatLayer) { if (heatLayer) {
heatLayer.addTo(map) heatLayer.addTo(map);
} }
const handleClick = (event: LeafletMouseEvent) => { const handleClick = (event: LeafletMouseEvent) => {
const { lat, lng } = event.latlng const { lat, lng } = event.latlng;
if (typeof lat === 'number' && typeof lng === 'number') { if (typeof lat === "number" && typeof lng === "number") {
onRequestSpotRef.current?.({ lat, lng }) onRequestSpotRef.current?.({ lat, lng });
} }
} };
map.on('click', handleClick) map.on("click", handleClick);
map.whenReady(() => { map.whenReady(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
map.invalidateSize() map.invalidateSize();
}) });
}) });
const onResize = () => { const onResize = () => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
map.invalidateSize() map.invalidateSize();
}) });
} };
window.addEventListener('resize', onResize) window.addEventListener("resize", onResize);
mapRef.current = map mapRef.current = map;
heatLayerRef.current = heatLayer heatLayerRef.current = heatLayer;
userLayerRef.current = userLayer userLayerRef.current = userLayer;
tileLayerRef.current = tileLayer tileLayerRef.current = tileLayer;
return () => { return () => {
map.off('click', handleClick) map.off("click", handleClick);
window.removeEventListener('resize', onResize) window.removeEventListener("resize", onResize);
map.remove() map.remove();
mapRef.current = null mapRef.current = null;
heatLayerRef.current = null heatLayerRef.current = null;
userLayerRef.current = null userLayerRef.current = null;
tileLayerRef.current = null tileLayerRef.current = null;
hasCenteredOnUserRef.current = false hasCenteredOnUserRef.current = false;
} };
}, [createTileLayer]) }, [createTileLayer]);
useEffect(() => { useEffect(() => {
onRequestSpotRef.current = onRequestSpot onRequestSpotRef.current = onRequestSpot;
}, [onRequestSpot]) }, [onRequestSpot]);
useEffect(() => { useEffect(() => {
const cleanup = initialiseMap() const cleanup = initialiseMap();
return () => { return () => {
if (typeof cleanup === 'function') { if (typeof cleanup === "function") {
cleanup() cleanup();
} }
} };
}, [initialiseMap]) }, [initialiseMap]);
useEffect(() => { useEffect(() => {
tileProviderRef.current = tileProvider tileProviderRef.current = tileProvider;
const map = mapRef.current const map = mapRef.current;
if (!map) { if (!map) {
return return;
} }
const nextLayer = createTileLayer(tileProvider) const nextLayer = createTileLayer(tileProvider);
const currentLayer = tileLayerRef.current const currentLayer = tileLayerRef.current;
if (currentLayer) { if (currentLayer) {
currentLayer.removeFrom(map) currentLayer.removeFrom(map);
} }
nextLayer.addTo(map) nextLayer.addTo(map);
tileLayerRef.current = nextLayer tileLayerRef.current = nextLayer;
}, [createTileLayer, tileProvider]) }, [createTileLayer, tileProvider]);
useEffect(() => { useEffect(() => {
const heatLayer = heatLayerRef.current const heatLayer = heatLayerRef.current;
if (!heatLayer) { if (!heatLayer) {
return return;
} }
if (!heatCells.length) { if (!heatCells.length) {
heatLayer.setLatLngs([]) heatLayer.setLatLngs([]);
return return;
} }
const maxIntensity = Math.max(...heatCells.map((cell) => cell.intensity)) || 1 const maxIntensity = Math.max(...heatCells.map(cell => cell.intensity)) || 1;
const heatPoints: HeatPoint[] = heatCells.map((cell) => [ const heatPoints: HeatPoint[] = heatCells.map(cell => [
cell.lat, cell.lat,
cell.lng, cell.lng,
Math.max(0.2, cell.intensity / maxIntensity), Math.max(0.2, cell.intensity / maxIntensity),
]) ]);
heatLayer.setLatLngs(heatPoints) heatLayer.setLatLngs(heatPoints);
}, [heatCells]) }, [heatCells]);
useEffect(() => { useEffect(() => {
const userLayer = userLayerRef.current const userLayer = userLayerRef.current;
const map = mapRef.current const map = mapRef.current;
if (!userLayer || !map) { if (!userLayer || !map) {
return return;
} }
userLayer.clearLayers() userLayer.clearLayers();
if (!userLocation) { if (!userLocation) {
hasCenteredOnUserRef.current = false hasCenteredOnUserRef.current = false;
return return;
} }
L.circleMarker([userLocation.lat, userLocation.lng], { L.circleMarker([userLocation.lat, userLocation.lng], {
radius: 7, radius: 7,
color: '#38bdf8', color: "#38bdf8",
weight: 3, weight: 3,
opacity: 0.9, opacity: 0.9,
fillColor: '#38bdf8', fillColor: "#38bdf8",
fillOpacity: 0.35, fillOpacity: 0.35,
}).addTo(userLayer) }).addTo(userLayer);
if (!hasCenteredOnUserRef.current) { if (!hasCenteredOnUserRef.current) {
map.setView([userLocation.lat, userLocation.lng], 13, { animate: true }) map.setView([userLocation.lat, userLocation.lng], 13, { animate: true });
hasCenteredOnUserRef.current = true hasCenteredOnUserRef.current = true;
} }
}, [userLocation]) }, [userLocation]);
const focusOn = useCallback((position: Point, zoom = 14) => { const focusOn = useCallback((position: Point, zoom = 14) => {
const map = mapRef.current const map = mapRef.current;
if (!map) { if (!map) {
return return;
} }
map.setView([position.lat, position.lng], zoom, { animate: true }) map.setView([position.lat, position.lng], zoom, { animate: true });
}, []) }, []);
const fitToHeat = useCallback(() => { const fitToHeat = useCallback(() => {
const map = mapRef.current const map = mapRef.current;
const heatLayer = heatLayerRef.current const heatLayer = heatLayerRef.current;
if (!map || !heatLayer) { if (!map || !heatLayer) {
return return;
} }
const bounds = heatLayer.getBounds?.() const bounds = heatLayer.getBounds?.();
if (bounds && bounds.isValid()) { if (bounds && bounds.isValid()) {
map.fitBounds(bounds.pad(0.25), { animate: true }) map.fitBounds(bounds.pad(0.25), { animate: true });
} }
}, []) }, []);
return { return {
mapContainerRef, mapContainerRef,
focusOn, focusOn,
fitToHeat, fitToHeat,
map: mapRef.current, map: mapRef.current,
} };
} }
+22 -22
View File
@@ -1,46 +1,46 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from "react";
type Theme = 'light' | 'dark' type Theme = "light" | "dark";
const STORAGE_KEY = 'signalmap-theme' const STORAGE_KEY = "signalmap-theme";
function applyTheme(theme: Theme) { function applyTheme(theme: Theme) {
const root = document.documentElement const root = document.documentElement;
if (theme === 'dark') { if (theme === "dark") {
root.classList.add('dark') root.classList.add("dark");
} else { } else {
root.classList.remove('dark') root.classList.remove("dark");
} }
} }
function getPreferredTheme(): Theme { function getPreferredTheme(): Theme {
if (typeof window === 'undefined') { if (typeof window === "undefined") {
return 'dark' return "dark";
} }
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') { if (stored === "light" || stored === "dark") {
return stored return stored;
} }
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark ? 'dark' : 'light' return prefersDark ? "dark" : "light";
} }
export function useTheme() { export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => getPreferredTheme()) const [theme, setTheme] = useState<Theme>(() => getPreferredTheme());
useEffect(() => { useEffect(() => {
applyTheme(theme) applyTheme(theme);
window.localStorage.setItem(STORAGE_KEY, theme) window.localStorage.setItem(STORAGE_KEY, theme);
}, [theme]) }, [theme]);
const toggleTheme = useCallback(() => { const toggleTheme = useCallback(() => {
setTheme((current) => (current === 'dark' ? 'light' : 'dark')) setTheme(current => (current === "dark" ? "light" : "dark"));
}, []) }, []);
return { return {
theme, theme,
setTheme, setTheme,
toggleTheme, toggleTheme,
isDark: theme === 'dark', isDark: theme === "dark",
} };
} }
+52 -52
View File
@@ -1,90 +1,90 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from "react";
import type { Point } from '@/types/api' import { useAppStore } from "@/store/useAppStore";
import { useAppStore } from '@/store/useAppStore' import type { Point } 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.error.permissionDenied' return "location.error.permissionDenied";
case error.POSITION_UNAVAILABLE: case error.POSITION_UNAVAILABLE:
return 'location.error.unavailable' return "location.error.unavailable";
case error.TIMEOUT: case error.TIMEOUT:
return 'location.error.timeout' return "location.error.timeout";
default: default:
return 'location.error.generic' return "location.error.generic";
} }
} }
export function useUserLocation() { export function useUserLocation() {
const setUserLocation = useAppStore((state) => state.setUserLocation) const setUserLocation = useAppStore(state => state.setUserLocation);
const setLocationError = useAppStore((state) => state.setLocationError) const setLocationError = useAppStore(state => state.setLocationError);
const setIsRequestingLocation = useAppStore((state) => state.setIsRequestingLocation) const setIsRequestingLocation = useAppStore(state => state.setIsRequestingLocation);
const watchIdRef = useRef<number | null>(null) const watchIdRef = useRef<number | null>(null);
const clearWatch = useCallback(() => { const clearWatch = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) { if (typeof navigator === "undefined" || !navigator.geolocation) {
return return;
} }
if (watchIdRef.current !== null) { if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current) navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null watchIdRef.current = null;
} }
}, []) }, []);
const start = useCallback(() => { const start = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) { if (typeof navigator === "undefined" || !navigator.geolocation) {
setLocationError('location.error.unsupported') setLocationError("location.error.unsupported");
setIsRequestingLocation(false) setIsRequestingLocation(false);
return return;
} }
clearWatch() clearWatch();
setIsRequestingLocation(true) setIsRequestingLocation(true);
setLocationError(null) setLocationError(null);
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { position => {
const coords: Point = { lat: position.coords.latitude, lng: position.coords.longitude } const coords: Point = { lat: position.coords.latitude, lng: position.coords.longitude };
setUserLocation(coords) setUserLocation(coords);
setIsRequestingLocation(false) setIsRequestingLocation(false);
setLocationError(null) setLocationError(null);
}, },
(geoError) => { geoError => {
setUserLocation(null) setUserLocation(null);
setLocationError(geolocationErrorMessage(geoError)) setLocationError(geolocationErrorMessage(geoError));
setIsRequestingLocation(false) setIsRequestingLocation(false);
}, },
{ enableHighAccuracy: true, timeout: 10000 }, { enableHighAccuracy: true, timeout: 10000 }
) );
const watchId = navigator.geolocation.watchPosition( const watchId = navigator.geolocation.watchPosition(
(position) => { position => {
const coords: Point = { lat: position.coords.latitude, lng: position.coords.longitude } const coords: Point = { lat: position.coords.latitude, lng: position.coords.longitude };
setUserLocation(coords) setUserLocation(coords);
setLocationError(null) setLocationError(null);
setIsRequestingLocation(false) setIsRequestingLocation(false);
}, },
(geoError) => { geoError => {
setUserLocation(null) setUserLocation(null);
setLocationError(geolocationErrorMessage(geoError)) setLocationError(geolocationErrorMessage(geoError));
setIsRequestingLocation(false) setIsRequestingLocation(false);
}, },
{ enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 }, { enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 }
) );
watchIdRef.current = watchId watchIdRef.current = watchId;
}, [clearWatch, setIsRequestingLocation, setLocationError, setUserLocation]) }, [clearWatch, setIsRequestingLocation, setLocationError, setUserLocation]);
useEffect(() => { useEffect(() => {
start() start();
return () => { return () => {
clearWatch() clearWatch();
} };
}, [clearWatch, start]) }, [clearWatch, start]);
return { return {
refresh: start, refresh: start,
} };
} }
+18 -5
View File
@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -58,9 +58,16 @@
} }
body { body {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family:
"Inter",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
@apply min-h-screen bg-background text-foreground antialiased; @apply min-h-screen bg-background text-foreground antialiased;
background-image: radial-gradient(circle at top, hsla(var(--primary) / 0.1), transparent 45%), background-image:
radial-gradient(circle at top, hsla(var(--primary) / 0.1), transparent 45%),
radial-gradient(circle at bottom, hsla(var(--destructive) / 0.08), transparent 55%); radial-gradient(circle at bottom, hsla(var(--destructive) / 0.08), transparent 55%);
} }
@@ -77,7 +84,13 @@
} }
.leaflet-container { .leaflet-container {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family:
"Inter",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
z-index: 0; z-index: 0;
} }
@@ -91,7 +104,7 @@
@layer utilities { @layer utilities {
.status-dot::after { .status-dot::after {
content: ''; content: "";
position: absolute; position: absolute;
inset: -0.35rem; inset: -0.35rem;
border-radius: 9999px; border-radius: 9999px;
+10 -12
View File
@@ -1,11 +1,10 @@
import i18n from 'i18next' import i18n from "i18next";
import { initReactI18next } from 'react-i18next' import { initReactI18next } from "react-i18next";
import enCommon from '@/locales/en/common.json' import enCommon from "@/locales/en/common.json";
import frCommon from '@/locales/fr/common.json' import frCommon from "@/locales/fr/common.json";
const browserLanguage = const browserLanguage = typeof window !== "undefined" ? window.navigator.language.split("-")[0]?.toLowerCase() : "en";
typeof window !== 'undefined' ? window.navigator.language.split('-')[0]?.toLowerCase() : 'en'
i18n i18n
.use(initReactI18next) .use(initReactI18next)
@@ -14,14 +13,13 @@ i18n
en: { common: enCommon }, en: { common: enCommon },
fr: { common: frCommon }, fr: { common: frCommon },
}, },
lng: browserLanguage === 'fr' ? 'fr' : 'en', lng: browserLanguage === "fr" ? "fr" : "en",
fallbackLng: 'en', fallbackLng: "en",
defaultNS: 'common', defaultNS: "common",
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },
}) })
.catch(() => undefined) .catch(() => undefined);
export { i18n }
export { i18n };
+40 -40
View File
@@ -1,80 +1,80 @@
import { type ClassValue, clsx } from 'clsx' import { type ClassValue, clsx } from "clsx";
import { twMerge } from 'tailwind-merge' import { twMerge } from "tailwind-merge";
export type Coordinates = { export type Coordinates = {
lat: number lat: number;
lng: number lng: number;
} };
export function cn(...inputs: ClassValue[]): string { export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
export function formatCoordinate(value: number, locale = 'en-US'): string { export function formatCoordinate(value: number, locale = "en-US"): string {
const formatter = new Intl.NumberFormat(locale, { 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, locale = 'en-US'): 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' }) const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (seconds < 60) { if (seconds < 60) {
return rtf.format(-seconds, 'second') return rtf.format(-seconds, "second");
} }
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60);
if (minutes < 60) { if (minutes < 60) {
return rtf.format(-minutes, 'minute') return rtf.format(-minutes, "minute");
} }
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60);
if (hours < 24) { if (hours < 24) {
return rtf.format(-hours, 'hour') return rtf.format(-hours, "hour");
} }
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24);
if (days < 7) { if (days < 7) {
return rtf.format(-days, 'day') return rtf.format(-days, "day");
} }
const weeks = Math.floor(days / 7) const weeks = Math.floor(days / 7);
return rtf.format(-weeks, 'week') return rtf.format(-weeks, "week");
} }
export function formatTimestamp(dateIso: string, locale = 'en-US'): string { export function formatTimestamp(dateIso: string, locale = "en-US"): string {
const date = new Date(dateIso) const date = new Date(dateIso);
return new Intl.DateTimeFormat(locale, { return new Intl.DateTimeFormat(locale, {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
hour: 'numeric', hour: "numeric",
minute: '2-digit', minute: "2-digit",
}).format(date) }).format(date);
} }
const EARTH_RADIUS_KM = 6371 const EARTH_RADIUS_KM = 6371;
export function distanceInKm(a: Coordinates, b: Coordinates): number { export function distanceInKm(a: Coordinates, b: Coordinates): number {
const lat1 = toRadians(a.lat) const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat) const lat2 = toRadians(b.lat);
const deltaLat = toRadians(b.lat - a.lat) const deltaLat = toRadians(b.lat - a.lat);
const deltaLng = toRadians(b.lng - a.lng) const deltaLng = toRadians(b.lng - a.lng);
const haversine = const haversine =
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2) Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2);
const c = 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)) const c = 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
return EARTH_RADIUS_KM * c return EARTH_RADIUS_KM * c;
} }
function toRadians(value: number): number { function toRadians(value: number): number {
return (value * Math.PI) / 180 return (value * Math.PI) / 180;
} }
+11 -10
View File
@@ -1,16 +1,17 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client'
import { I18nextProvider } from 'react-i18next'
import 'leaflet/dist/leaflet.css'
import '@/index.css' import { createRoot } from "react-dom/client";
import App from '@/App.tsx' import { I18nextProvider } from "react-i18next";
import { i18n } from '@/lib/i18n' import "leaflet/dist/leaflet.css";
createRoot(document.getElementById('root')!).render( import "@/index.css";
import App from "@/App";
import { i18n } from "@/lib/i18n";
createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<App /> <App />
</I18nextProvider> </I18nextProvider>
</StrictMode>, </StrictMode>
) );
+14 -14
View File
@@ -1,23 +1,23 @@
import { create } from 'zustand' import { create } from "zustand";
import type { Point } from '@/types/api' import type { Point } from "@/types/api";
type Nullable<T> = T | null type Nullable<T> = T | null;
interface AppState { interface AppState {
userLocation: Nullable<Point> userLocation: Nullable<Point>;
locationError: Nullable<string> locationError: Nullable<string>;
isRequestingLocation: boolean isRequestingLocation: boolean;
setUserLocation: (location: Nullable<Point>) => void setUserLocation: (location: Nullable<Point>) => void;
setLocationError: (error: Nullable<string>) => void setLocationError: (error: Nullable<string>) => void;
setIsRequestingLocation: (isRequesting: boolean) => void setIsRequestingLocation: (isRequesting: boolean) => void;
} }
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>(set => ({
userLocation: null, userLocation: null,
locationError: null, locationError: null,
isRequestingLocation: false, isRequestingLocation: false,
setUserLocation: (userLocation) => set({ userLocation }), setUserLocation: userLocation => set({ userLocation }),
setLocationError: (locationError) => set({ locationError }), setLocationError: locationError => set({ locationError }),
setIsRequestingLocation: (isRequestingLocation) => set({ isRequestingLocation }), setIsRequestingLocation: isRequestingLocation => set({ isRequestingLocation }),
})) }));
+20 -20
View File
@@ -1,36 +1,36 @@
export type FeedStatus = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing' export type FeedStatus = "loading" | "idle" | "error" | "posting" | "refreshing";
export interface Point { export interface Point {
lat: number lat: number;
lng: number lng: number;
} }
export interface ApiPoint { export interface ApiPoint {
id: number id: number;
signalLocation: Point signalLocation: Point;
createdAt: string createdAt: string;
userKey: string userKey: string;
} }
export interface ApiDensityCell { export interface ApiDensityCell {
lat: number lat: number;
lng: number lng: number;
intensity: number intensity: number;
} }
export interface ApiSnapshot { export interface ApiSnapshot {
clientKey?: string clientKey?: string;
points: ApiPoint[] points: ApiPoint[];
density: ApiDensityCell[] density: ApiDensityCell[];
latestByUser: ApiPoint[] latestByUser: ApiPoint[];
totals: { totals: {
points: number points: number;
contributors: number contributors: number;
} };
updatedAt: string updatedAt: string;
} }
export interface SnapshotEventPayload { export interface SnapshotEventPayload {
type: 'snapshot' type: "snapshot";
payload: ApiSnapshot payload: ApiSnapshot;
} }
+11 -14
View File
@@ -1,22 +1,19 @@
declare module 'leaflet.heat' { declare module "leaflet.heat" {
import type { Layer } from 'leaflet' import type { Layer } from "leaflet";
export interface HeatLayer extends Layer { export interface HeatLayer extends Layer {
setLatLngs(latlngs: Array<[number, number, number?]>): HeatLayer setLatLngs(latlngs: Array<[number, number, number?]>): HeatLayer;
addLatLng(latlng: [number, number, number?]): HeatLayer addLatLng(latlng: [number, number, number?]): HeatLayer;
} }
export interface HeatLayerOptions { export interface HeatLayerOptions {
radius?: number radius?: number;
blur?: number blur?: number;
maxZoom?: number maxZoom?: number;
max?: number max?: number;
minOpacity?: number minOpacity?: number;
gradient?: Record<number, string> gradient?: Record<number, string>;
} }
export default function heatLayer( export default function heatLayer(latlngs: Array<[number, number, number?]>, options?: HeatLayerOptions): HeatLayer;
latlngs: Array<[number, number, number?]>,
options?: HeatLayerOptions,
): HeatLayer
} }
+33 -32
View File
@@ -1,63 +1,64 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: ['class'], darkMode: ["class"],
content: ['./index.html', './src/**/*.{ts,tsx}'], content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { theme: {
extend: { extend: {
backgroundImage: { backgroundImage: {
'radial-signal': 'radial-gradient(circle at top, rgba(56,189,248,0.15), transparent 45%), radial-gradient(circle at bottom, rgba(248,113,113,0.12), transparent 55%)', "radial-signal":
"radial-gradient(circle at top, rgba(56,189,248,0.15), transparent 45%), radial-gradient(circle at bottom, rgba(248,113,113,0.12), transparent 55%)",
}, },
colors: { colors: {
border: 'hsl(var(--border))', border: "hsl(var(--border))",
input: 'hsl(var(--input))', input: "hsl(var(--input))",
ring: 'hsl(var(--ring))', ring: "hsl(var(--ring))",
background: 'hsl(var(--background))', background: "hsl(var(--background))",
foreground: 'hsl(var(--foreground))', foreground: "hsl(var(--foreground))",
primary: { primary: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: "hsl(var(--primary))",
foreground: 'hsl(var(--primary-foreground))', foreground: "hsl(var(--primary-foreground))",
}, },
secondary: { secondary: {
DEFAULT: 'hsl(var(--secondary))', DEFAULT: "hsl(var(--secondary))",
foreground: 'hsl(var(--secondary-foreground))', foreground: "hsl(var(--secondary-foreground))",
}, },
destructive: { destructive: {
DEFAULT: 'hsl(var(--destructive))', DEFAULT: "hsl(var(--destructive))",
foreground: 'hsl(var(--destructive-foreground))', foreground: "hsl(var(--destructive-foreground))",
}, },
muted: { muted: {
DEFAULT: 'hsl(var(--muted))', DEFAULT: "hsl(var(--muted))",
foreground: 'hsl(var(--muted-foreground))', foreground: "hsl(var(--muted-foreground))",
}, },
accent: { accent: {
DEFAULT: 'hsl(var(--accent))', DEFAULT: "hsl(var(--accent))",
foreground: 'hsl(var(--accent-foreground))', foreground: "hsl(var(--accent-foreground))",
}, },
popover: { popover: {
DEFAULT: 'hsl(var(--popover))', DEFAULT: "hsl(var(--popover))",
foreground: 'hsl(var(--popover-foreground))', foreground: "hsl(var(--popover-foreground))",
}, },
card: { card: {
DEFAULT: 'hsl(var(--card))', DEFAULT: "hsl(var(--card))",
foreground: 'hsl(var(--card-foreground))', foreground: "hsl(var(--card-foreground))",
}, },
}, },
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: "var(--radius)",
md: 'calc(var(--radius) - 2px)', md: "calc(var(--radius) - 2px)",
sm: 'calc(var(--radius) - 4px)', sm: "calc(var(--radius) - 4px)",
}, },
keyframes: { keyframes: {
'status-pulse': { "status-pulse": {
'0%': { transform: 'scale(0.7)', opacity: '0.6' }, "0%": { transform: "scale(0.7)", opacity: "0.6" },
'70%': { transform: 'scale(1.4)', opacity: '0' }, "70%": { transform: "scale(1.4)", opacity: "0" },
'100%': { transform: 'scale(0.7)', opacity: '0' }, "100%": { transform: "scale(0.7)", opacity: "0" },
}, },
}, },
animation: { animation: {
'status-pulse': 'status-pulse 2.4s ease-out infinite', "status-pulse": "status-pulse 2.4s ease-out infinite",
}, },
}, },
}, },
plugins: [require('tailwindcss-animate')], plugins: [require("tailwindcss-animate")],
} };
+1 -4
View File
@@ -1,7 +1,4 @@
{ {
"files": [], "files": [],
"references": [ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
} }
+6 -6
View File
@@ -1,19 +1,19 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from "node:url";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react({ react({
babel: { babel: {
plugins: [['babel-plugin-react-compiler']], plugins: [["babel-plugin-react-compiler"]],
}, },
}), }),
], ],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),
}, },
}, },
}) });
+1 -1
View File
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\DataFixtures; namespace App\DataFixtures;
use App\ValueObject\Point;
use App\Entity\Signal; use App\Entity\Signal;
use App\ValueObject\Point;
use DateInterval; use DateInterval;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
@@ -8,7 +8,9 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus; use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
use function sprintf; use function sprintf;
#[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: ['x-error-code' => 'invalid_coordinates'])] #[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: [
'x-error-code' => 'invalid_coordinates',
])]
final class InvalidPointException extends \RuntimeException final class InvalidPointException extends \RuntimeException
{ {
private function __construct(string $message) private function __construct(string $message)
@@ -7,7 +7,9 @@ namespace App\Exception;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus; use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
#[WithHttpStatus(Response::HTTP_BAD_REQUEST, headers: ['x-error-code' => 'missing_client_key'])] #[WithHttpStatus(Response::HTTP_BAD_REQUEST, headers: [
'x-error-code' => 'missing_client_key',
])]
final class MissingClientKeyException extends \RuntimeException final class MissingClientKeyException extends \RuntimeException
{ {
public function __construct() public function __construct()
@@ -8,7 +8,9 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus; use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
use function sprintf; use function sprintf;
#[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: ['x-error-code' => 'point_too_far'])] #[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: [
'x-error-code' => 'point_too_far',
])]
final class PointTooFarException extends \RuntimeException final class PointTooFarException extends \RuntimeException
{ {
public function __construct(float $maximumDistanceKm) public function __construct(float $maximumDistanceKm)
@@ -9,7 +9,9 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus; use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
use function sprintf; use function sprintf;
#[WithHttpStatus(Response::HTTP_TOO_MANY_REQUESTS, headers: ['x-error-code' => 'submission_rate_limited'])] #[WithHttpStatus(Response::HTTP_TOO_MANY_REQUESTS, headers: [
'x-error-code' => 'submission_rate_limited',
])]
final class SubmissionRateLimitedException extends \RuntimeException final class SubmissionRateLimitedException extends \RuntimeException
{ {
public function __construct(?DateTimeInterface $retryAfter) public function __construct(?DateTimeInterface $retryAfter)
+3 -2
View File
@@ -6,7 +6,8 @@ namespace App\Message;
final class SignalCreatedMessage final class SignalCreatedMessage
{ {
public function __construct(public readonly int $signalId) public function __construct(
{ public readonly int $signalId
) {
} }
} }
@@ -9,14 +9,14 @@ use App\Repository\SignalRepository;
use App\Service\SignalSnapshotBuilder; use App\Service\SignalSnapshotBuilder;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
use function json_encode; use function json_encode;
use const JSON_THROW_ON_ERROR; use const JSON_THROW_ON_ERROR;
use Throwable;
#[AsMessageHandler] #[AsMessageHandler]
final class SignalCreatedMessageHandler final class SignalCreatedMessageHandler
@@ -25,10 +25,8 @@ final class SignalCreatedMessageHandler
private readonly SignalRepository $signals, private readonly SignalRepository $signals,
private readonly SignalSnapshotBuilder $snapshotBuilder, private readonly SignalSnapshotBuilder $snapshotBuilder,
private readonly HubInterface $hub, private readonly HubInterface $hub,
#[Autowire('%app.signal_stream_topic%')] #[Autowire('%app.signal_stream_topic%')] private readonly string $topic,
private readonly string $topic, #[Autowire('%app.signal_snapshot_limit%')] private readonly int $snapshotLimit,
#[Autowire('%app.signal_snapshot_limit%')]
private readonly int $snapshotLimit,
private readonly ?LoggerInterface $logger = null, private readonly ?LoggerInterface $logger = null,
) { ) {
} }
+5 -19
View File
@@ -4,24 +4,23 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\ValueObject\Point;
use App\Exception\PointTooFarException; use App\Exception\PointTooFarException;
use App\ValueObject\Distance;
use App\ValueObject\Point;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class PointProximityValidator final class PointProximityValidator
{ {
public function __construct( public function __construct(
#[Autowire('%app.max_signal_distance_km%')] #[Autowire('%app.max_signal_distance_km%')] private readonly float $maximumDistanceKm,
private readonly float $maximumDistanceKm, #[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger,
#[Autowire(service: 'monolog.logger.signals')]
private readonly LoggerInterface $logger,
) { ) {
} }
public function assertWithinRange(Point $userLocation, Point $signalLocation): void public function assertWithinRange(Point $userLocation, Point $signalLocation): void
{ {
$distance = $this->distanceInKm($userLocation, $signalLocation); $distance = Distance::betweenPoints($userLocation, $signalLocation)->inKilometers();
$this->logger->debug('Calculated proximity between user and signal.', [ $this->logger->debug('Calculated proximity between user and signal.', [
'distance_km' => $distance, 'distance_km' => $distance,
@@ -36,17 +35,4 @@ final class PointProximityValidator
throw new PointTooFarException($this->maximumDistanceKm); throw new PointTooFarException($this->maximumDistanceKm);
} }
} }
private function distanceInKm(Point $a, Point $b): float
{
$lat1 = deg2rad($a->getLat());
$lat2 = deg2rad($b->getLat());
$deltaLat = deg2rad($b->getLat() - $a->getLat());
$deltaLng = deg2rad($b->getLng() - $a->getLng());
$haversine = sin($deltaLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($deltaLng / 2) ** 2;
$c = 2 * atan2(sqrt($haversine), sqrt(1 - $haversine));
return 6371 * $c;
}
} }
+2 -4
View File
@@ -15,10 +15,8 @@ final class SignalSnapshotService
public function __construct( public function __construct(
private readonly SignalRepository $signals, private readonly SignalRepository $signals,
private readonly SignalSnapshotBuilder $snapshotBuilder, private readonly SignalSnapshotBuilder $snapshotBuilder,
#[Autowire('%app.signal_snapshot_limit%')] #[Autowire('%app.signal_snapshot_limit%')] private readonly int $snapshotLimit,
private readonly int $snapshotLimit, #[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger,
#[Autowire(service: 'monolog.logger.signals')]
private readonly LoggerInterface $logger,
) { ) {
} }
@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Payload\SignalPayload;
use App\Entity\Signal; use App\Entity\Signal;
use App\Exception\SubmissionRateLimitedException; use App\Exception\SubmissionRateLimitedException;
use App\Message\SignalCreatedMessage; use App\Message\SignalCreatedMessage;
use App\Payload\SignalPayload;
use App\Repository\SignalRepository; use App\Repository\SignalRepository;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
@@ -20,12 +20,10 @@ final class SignalSubmissionService
{ {
public function __construct( public function __construct(
private readonly SignalRepository $signals, private readonly SignalRepository $signals,
#[Autowire(service: 'limiter.signal_submission')] #[Autowire(service: 'limiter.signal_submission')] private readonly RateLimiterFactory $submissionLimiter,
private readonly RateLimiterFactory $submissionLimiter,
private readonly PointProximityValidator $proximityValidator, private readonly PointProximityValidator $proximityValidator,
private readonly MessageBusInterface $bus, private readonly MessageBusInterface $bus,
#[Autowire(service: 'monolog.logger.signals')] #[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger,
private readonly LoggerInterface $logger,
) { ) {
} }
@@ -81,7 +79,7 @@ final class SignalSubmissionService
$retryAfter = $limit->getRetryAfter(); $retryAfter = $limit->getRetryAfter();
$this->logger->warning('Signal submission rejected due to rate limiting.', [ $this->logger->warning('Signal submission rejected due to rate limiting.', [
'client_key' => $clientKey, 'client_key' => $clientKey,
'retry_after' => $retryAfter?->format(DATE_ATOM), 'retry_after' => $retryAfter->format(DATE_ATOM),
]); ]);
throw new SubmissionRateLimitedException($limit->getRetryAfter()); throw new SubmissionRateLimitedException($limit->getRetryAfter());
} }
@@ -91,5 +89,4 @@ final class SignalSubmissionService
'remaining_tokens' => $limit->getRemainingTokens(), 'remaining_tokens' => $limit->getRemainingTokens(),
]); ]);
} }
} }
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\ValueObject;
final class Distance
{
private const EARTH_RADIUS_KM = 6371;
private function __construct(
private readonly float $kilometers,
) {
}
public static function betweenPoints(Point $from, Point $to): self
{
$lat1 = deg2rad($from->getLat());
$lat2 = deg2rad($to->getLat());
$deltaLat = deg2rad($to->getLat() - $from->getLat());
$deltaLng = deg2rad($to->getLng() - $from->getLng());
$haversine = sin($deltaLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($deltaLng / 2) ** 2;
$centralAngle = 2 * atan2(sqrt($haversine), sqrt(1 - $haversine));
return new self(self::EARTH_RADIUS_KM * $centralAngle);
}
public function inKilometers(): float
{
return $this->kilometers;
}
public function inMeters(): float
{
return $this->kilometers * 1000;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\ValueObject;
use App\ValueObject\Distance;
use App\ValueObject\Point;
use PHPUnit\Framework\TestCase;
final class DistanceTest extends TestCase
{
public function testCalculatesDistanceBetweenTwoPointsInKilometers(): void
{
$origin = Point::fromLatLng(48.8566, 2.3522); // Paris
$destination = Point::fromLatLng(51.5074, -0.1278); // London
$distance = Distance::betweenPoints($origin, $destination);
self::assertEqualsWithDelta(343.4, $distance->inKilometers(), 0.5);
}
public function testCanConvertDistanceToMeters(): void
{
$origin = Point::fromLatLng(0.0, 0.0);
$destination = Point::fromLatLng(0.0, 0.009); // ~1km east on equator
$distance = Distance::betweenPoints($origin, $destination);
self::assertEqualsWithDelta(1000, $distance->inMeters(), 10);
}
}