diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..9e40dba0 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +{ + "name": "openSenseMap frontend", + "image": "node:22", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker": "latest" + }, + "customizations": { + "vscode": { + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "extensions": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss" + ] + } + }, + "mounts": [ + "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" + ], + "forwardPorts": [3000], + "portsAttributes": { + "3000": { + "label": "frontend", + "onAutoForward": "openBrowserOnce" + } + }, + "postStartCommand": "npm install && npm run build" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 6d4bf5c7..e34a988e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ // List of extensions which should be recommended for users of this workspace. "recommendations": [ "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", "bradlc.vscode-tailwindcss" ] } \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..0784bf01 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node-terminal", + "request": "launch", + "name": "Debug", + "command": "npm start dev", + "cwd": "${workspaceFolder}" + }, + { + "name": "Attach", + "processId": "${command:PickProcess}", + "request": "attach", + "skipFiles": ["/**"], + "type": "node" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..a4570ff1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true +} diff --git a/README.md b/README.md index 8def95fd..7f7d8824 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,47 @@ ![openSenseMap](https://github.com/openSenseMap/frontend/blob/dev/public/openSenseMap.png) -This repository contains the code of the new *openSenseMap* frontend running at [https://beta.opensensemap.org](https://beta.opensensemap.org). +This repository contains the code of the new _openSenseMap_ frontend running at +[https://beta.opensensemap.org](https://beta.opensensemap.org). -Originally, the *openSenseMap* was built as part of the bachelor thesis of [@mpfeil](https://github.com/mpfeil) at the ifgi (Institute for Geoinformatics, University of Münster). Between 2016 and 2022 development was partly funded by the German Ministry of Education and Research (BMBF) in the projets senseBox and senseBox Pro. This version has been developed by [@mpfeil](https://github.com/mpfeil) and [@freds-dev](https://github.com/freds-dev). +Originally, the _openSenseMap_ was built as part of the bachelor thesis of +[@mpfeil](https://github.com/mpfeil) at the ifgi (Institute for Geoinformatics, +University of Münster). Between 2016 and 2022 development was partly funded by +the German Ministry of Education and Research (BMBF) in the projects senseBox +and senseBox Pro. This version has been developed by +[@mpfeil](https://github.com/mpfeil) and +[@freds-dev](https://github.com/freds-dev). Screenshot OSeM - ## Project setup -If you do need to set the project up locally yourself, feel free to follow these instructions: +If you do need to set the project up locally yourself, feel free to follow these +instructions: ### System Requirements + - [Node.js](https://nodejs.org/) >= 22.0.0 - [npm](https://npmjs.com/) >= 8.18.0 - [git](https://git-scm.com/) >= 2.38.0 - [Docker](https://www.docker.com) >= 27.0.0 +#### Developing inside a DevContainer + +- [Visual Studio Code](https://code.visualstudio.com/) + - the + [DevContainers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +- [Docker](https://www.docker.com) >= 27.0.0 + ### Variables -You can configure the API endpoint and/or map tiles using the following environmental variables: +You can configure the API endpoint and/or map tiles using the following +environmental variables: -| ENV | Default value | -| --------- | ----------------- | -| OSEM_API_URL | https://api.testing.opensensemap.org | -| DATABASE_URL | | -| MAPBOX_ACCESS_TOKEN | | +| ENV | Default value | +| ------------------- | ------------------------------------ | +| OSEM_API_URL | https://api.testing.opensensemap.org | +| DATABASE_URL | | +| MAPBOX_ACCESS_TOKEN | | You can create a copy of `.env.example`, rename it to `.env` and set the values. @@ -34,13 +50,48 @@ You can create a copy of `.env.example`, rename it to `.env` and set the values. 1. Clone the repo: `git clone https://github.com/openSenseMap/frontend` 2. Copy `.env.example` into `.env` 3. Run `npm install` to install dependencies -4. Run `npm run docker` to start the docker container running your local postgres DB +4. Run `npm run docker` to start the docker container running your local + postgres DB 5. Run `npm run build` 6. Run `npm run dev` to start the local server -### Contributing +#### Using a DevContainer + +Simply open the project in Visual Studio Code. It should prompt and ask you if +you would like to open the project in a DevContainer. + +If you are not prompted hit Ctrl + Shift + P +(Shift+Command+P on Mac) and select +`>DevContainers: Open Workspace in Container...`. + +The DevContainer will now build (which will take a bit the first time your are +doing this) and the window will reload. For convenience `npm install` and +`npm run build` are executed for you on start. + +### Debugging + +For users of [Visual Studio Code](https://code.visualstudio.com/) there is a +[`launch.json`](.vscode/launch.json) containing two options to debug the +application: + +1. Debug + - Starts the development task (`npm run dev`) and attaches to process +1. Attach + - Use when you have started the development task elsewhere already + - Attaches to an existing task + - You will be prompted with running tasks. Depending on your setup you may + need to try a few until you find the correct one. Hint: Look out for the + arguments of the tasks and choose the one that contains `tsx watch` + +Debugging the application will allow you to set breakpoints and step through the +code. Additionally, you may set +[logpoints](https://code.visualstudio.com/docs/debugtest/debugging#_logpoints) +to eliminate the need for `console.log` in your code for debugging purposes. + +## Contributing -We welcome all kind of constructive contributions to this project. Please have a look at [CONTRIBUTING](.github/CONTRIBUTING.md) if you want to do so. +We welcome all kind of constructive contributions to this project. Please have a +look at [CONTRIBUTING](.github/CONTRIBUTING.md) if you want to do so. ## License diff --git a/app/components/daterange-filter.tsx b/app/components/daterange-filter.tsx index ba8d12f6..6a199cea 100644 --- a/app/components/daterange-filter.tsx +++ b/app/components/daterange-filter.tsx @@ -1,171 +1,171 @@ -import { PopoverClose } from "@radix-ui/react-popover"; -import { format } from "date-fns"; -import { Clock } from "lucide-react"; -import { useEffect, useState } from "react"; -import { type DateRange } from "react-day-picker"; -import { useLoaderData, useSearchParams, useSubmit } from "react-router"; -import { Badge } from "./ui/badge"; -import { Button } from "./ui/button"; -import { Calendar } from "./ui/calendar"; +import { PopoverClose } from '@radix-ui/react-popover' +import { format } from 'date-fns' +import { Clock } from 'lucide-react' +import { useEffect, useState } from 'react' +import { type DateRange } from 'react-day-picker' +import { useLoaderData, useSearchParams, useSubmit } from 'react-router' +import { Badge } from './ui/badge' +import { Button } from './ui/button' +import { Calendar } from './ui/calendar' import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "./ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -import { Separator } from "./ui/separator"; -import dateTimeRanges from "~/lib/date-ranges"; -import { type loader } from "~/routes/explore.$deviceId.$sensorId.$"; + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from './ui/command' +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover' +import { Separator } from './ui/separator' +import dateTimeRanges from '~/lib/date-ranges' +import { type loader } from '~/routes/explore.$deviceId.$sensorId.$' export function DateRangeFilter() { - // Get data from the loader - const loaderData = useLoaderData(); + // Get data from the loader + const loaderData = useLoaderData() - // Form submission handler - const submit = useSubmit(); - const [searchParams] = useSearchParams(); + // Form submission handler + const submit = useSubmit() + const [searchParams] = useSearchParams() - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false) - // State for selected date range and aggregation - const [date, setDate] = useState({ - from: loaderData.startDate ? new Date(loaderData.startDate) : undefined, - to: loaderData.endDate ? new Date(loaderData.endDate) : undefined, - }); + // State for selected date range and aggregation + const [date, setDate] = useState({ + from: loaderData.startDate ? new Date(loaderData.startDate) : undefined, + to: loaderData.endDate ? new Date(loaderData.endDate) : undefined, + }) - if ( - !date?.from && - !date?.to && - loaderData.sensors && - loaderData.sensors.length > 0 && - loaderData.sensors[0].data && - loaderData.sensors[0].data.length > 0 - ) { - const firstDate = loaderData.sensors[0].data[0]?.time; - const lastDate = - loaderData.sensors[0].data[loaderData.sensors[0].data.length - 1]?.time; + if ( + !date?.from && + !date?.to && + loaderData.sensors && + loaderData.sensors.length > 0 && + loaderData.sensors[0]?.data && + loaderData.sensors[0].data.length > 0 + ) { + const firstDate = loaderData.sensors[0].data[0]?.time + const lastDate = + loaderData.sensors[0].data[loaderData.sensors[0].data.length - 1]?.time - setDate({ - from: lastDate ? new Date(lastDate) : undefined, - to: firstDate ? new Date(firstDate) : undefined, - }); - } + setDate({ + from: lastDate ? new Date(lastDate) : undefined, + to: firstDate ? new Date(firstDate) : undefined, + }) + } - // Shortcut to open date range selection - useEffect(() => { - const down = (e: KeyboardEvent) => { - if (e.key === "d" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setOpen((open) => !open); - } - }; + // Shortcut to open date range selection + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === 'd' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + setOpen((open) => !open) + } + } - document.addEventListener("keydown", down); + document.addEventListener('keydown', down) - return () => { - document.removeEventListener("keydown", down); - }; - }, []); + return () => { + document.removeEventListener('keydown', down) + } + }, []) - // Update search params when date or aggregation changes - useEffect(() => { - if (date?.from) { - searchParams.set("date_from", date?.from?.toISOString() ?? ""); - } - if (date?.to) { - searchParams.set("date_to", date?.to?.toISOString() ?? ""); - } - }, [date, searchParams]); + // Update search params when date or aggregation changes + useEffect(() => { + if (date?.from) { + searchParams.set('date_from', date?.from?.toISOString() ?? '') + } + if (date?.to) { + searchParams.set('date_to', date?.to?.toISOString() ?? '') + } + }, [date, searchParams]) - return ( - - - - - -
-
-
-
- Absolute time range -
- { - setDate(dates); - }} - initialFocus - /> -
- - - - - No range found. - - {dateTimeRanges.map((dateTimeRange) => ( - { - const selectedDateTimeRange = dateTimeRanges.find( - (range) => range.value === value, - ); + return ( + + + + + +
+
+
+
+ Absolute time range +
+ { + setDate(dates) + }} + initialFocus + /> +
+ + + + + No range found. + + {dateTimeRanges.map((dateTimeRange) => ( + { + const selectedDateTimeRange = dateTimeRanges.find( + (range) => range.value === value, + ) - const timeRange = selectedDateTimeRange?.convert(); + const timeRange = selectedDateTimeRange?.convert() - setDate({ - from: timeRange?.from, - to: timeRange?.to, - }); - }} - > - {dateTimeRange.label} - - ))} - - - -
-
- { - void submit(searchParams); - }} - > - Apply - -
-
-
-
- ); + setDate({ + from: timeRange?.from, + to: timeRange?.to, + }) + }} + > + {dateTimeRange.label} +
+ ))} +
+
+
+
+
+ { + void submit(searchParams) + }} + > + Apply + +
+
+
+
+ ) } diff --git a/app/components/device-detail/entry-logs.tsx b/app/components/device-detail/entry-logs.tsx index 5eabf15f..bbe3d596 100644 --- a/app/components/device-detail/entry-logs.tsx +++ b/app/components/device-detail/entry-logs.tsx @@ -1,165 +1,173 @@ -import { useMediaQuery } from "@mantine/hooks"; -import { Activity, Clock, ExternalLink } from "lucide-react"; -import { useState } from "react"; -import { Button } from "../ui/button"; +import { useMediaQuery } from '@mantine/hooks' +import { Activity, Clock, ExternalLink } from 'lucide-react' +import { useState } from 'react' +import { Button } from '../ui/button' import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../ui/dialog"; + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '../ui/dialog' import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "../ui/drawer"; + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '../ui/drawer' import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../ui/tooltip"; -import { Card } from "@/components/ui/card"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { type LogEntry } from "~/schema/log-entry"; + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '../ui/tooltip' +import { Card } from '@/components/ui/card' +import { ScrollArea } from '@/components/ui/scroll-area' +import { type LogEntry } from '~/schema/log-entry' export default function EntryLogs({ - entryLogs = [], + entryLogs = [], }: { - entryLogs: LogEntry[]; + entryLogs: LogEntry[] }) { - const [open, setOpen] = useState(false); - const isDesktop = useMediaQuery("(min-width: 768px)"); + const [open, setOpen] = useState(false) + const isDesktop = useMediaQuery('(min-width: 768px)') - if (isDesktop) { - return ( -
-

Logs

-
-
-
- -
-
-

{entryLogs[entryLogs.length -1].content}

-
- - {new Date(entryLogs[0].createdAt).toLocaleString()} -
-
-
-
- - - - - - - Device Logs - - If this is your device, you can make changes in your device - settings. - - - - - -
-
-
- ); - } + if (isDesktop) { + return ( +
+

Logs

+
+
+
+ +
+
+

+ {entryLogs.at(-1)?.content} +

+
+ + {entryLogs.at(0) + ? new Date(entryLogs.at(0)!.createdAt).toLocaleString() + : ''} +
+
+
+
+ + + + + + + Device Logs + + If this is your device, you can make changes in your device + settings. + + + + + +
+
+
+ ) + } - return ( -
-

Logs

-
-
-
- -
-
-

{entryLogs[0].content}

-
- - {new Date(entryLogs[0].createdAt).toLocaleString()} -
-
-
-
- - - - - - - Device Logs - - If this is your device, you can make changes in your device - settings. - - - - - - - - - - -
-
- ); + return ( +
+

Logs

+
+
+
+ +
+
+

+ {entryLogs.at(0)?.content} +

+
+ + {entryLogs.at(0) + ? new Date(entryLogs.at(0)!.createdAt).toLocaleString() + : ''} +
+
+
+
+ + + + + + + Device Logs + + If this is your device, you can make changes in your device + settings. + + + + + + + + + + +
+
+ ) } function LogList({ entryLogs = [] }: { entryLogs: LogEntry[] }) { - return ( - -
- {entryLogs.map((log, index) => ( -
-
- -
-
- -

{log.content}

-
- - {new Date(log.createdAt).toLocaleString()} -
-
-
- {index < entryLogs.length - 1 && ( - - ))} -
- - ); + return ( + +
+ {entryLogs.map((log, index) => ( +
+
+ +
+
+ +

{log.content}

+
+ + {new Date(log.createdAt).toLocaleString()} +
+
+
+ {index < entryLogs.length - 1 && ( + + ))} +
+ + ) } diff --git a/app/components/device-detail/graph.tsx b/app/components/device-detail/graph.tsx index fb189264..f6e56135 100644 --- a/app/components/device-detail/graph.tsx +++ b/app/components/device-detail/graph.tsx @@ -13,7 +13,14 @@ import { import 'chartjs-adapter-date-fns' // import { de, enGB } from "date-fns/locale"; import { Download, RefreshCcw, X } from 'lucide-react' -import { useMemo, useRef, useState, useEffect, useContext, RefObject } from 'react' +import { + useMemo, + useRef, + useState, + useEffect, + useContext, + RefObject, +} from 'react' import { Scatter } from 'react-chartjs-2' import { isBrowser, isTablet } from 'react-device-detect' import Draggable, { type DraggableData } from 'react-draggable' @@ -356,8 +363,9 @@ export default function Graph({ label: (context: any) => { const dataIndex = context.dataIndex const datasetIndex = context.datasetIndex - const point = chartData.datasets[datasetIndex].data[dataIndex] - const locationId = point.locationId + const point = + chartData.datasets.at(datasetIndex)?.data.at(dataIndex) ?? null + const locationId = point?.locationId ?? null setHoveredPoint(locationId) return `${context.dataset.label}: ${context.raw.y}` }, @@ -374,11 +382,15 @@ export default function Graph({ mode: 'x', onZoom: ({ chart }) => { const xScale = chart.scales['x'] - const xMin = xScale.min - const xMax = xScale.max + const xMin = xScale?.min + const xMax = xScale?.max // Track the zoom level - setCurrentZoom({ xMin, xMax }) + setCurrentZoom( + xMin !== undefined && xMax !== undefined + ? { xMin, xMax } + : null, + ) }, }, }, @@ -405,7 +417,8 @@ export default function Graph({ setColorPickerState({ open: !colorPickerState.open, index, - color: chartData.datasets[index].borderColor as string, + color: (chartData.datasets.at(index)?.borderColor ?? + '#000000') as string, }) }, labels: { @@ -428,8 +441,13 @@ export default function Graph({ function handleColorChange(newColor: string) { const updatedDatasets = [...chartData.datasets] - updatedDatasets[colorPickerState.index].borderColor = newColor - updatedDatasets[colorPickerState.index].backgroundColor = newColor + if ( + colorPickerState.index >= 0 && + colorPickerState.index < updatedDatasets.length + ) { + updatedDatasets[colorPickerState.index]!.borderColor = newColor + updatedDatasets[colorPickerState.index]!.backgroundColor = newColor + } // Update the chartData state with the new dataset colors setChartData((prevData) => ({ @@ -459,7 +477,9 @@ export default function Graph({ } function handleCsvDownloadClick() { - const labels = chartData.datasets[0].data.map((point: any) => point.x) + if (chartData.datasets.length === 0) return + + const labels = chartData.datasets[0]!.data.map((point: any) => point.x) let csvContent = 'timestamp,deviceId,sensorId,value,unit,phenomena\n' diff --git a/app/components/device-detail/profile-box-selection.tsx b/app/components/device-detail/profile-box-selection.tsx index 10aadbc3..4bc6cc00 100644 --- a/app/components/device-detail/profile-box-selection.tsx +++ b/app/components/device-detail/profile-box-selection.tsx @@ -1,70 +1,70 @@ // import { useState } from "react"; import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "../ui/card"; + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '../ui/card' import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../ui/select"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select' const dummyBoxes = [ - { - name: "Box at IFGI", - id: "1", - image: "/sensebox_outdoor.jpg", - }, - { - name: "senseBox at Aasee", - id: "2", - image: "https://picsum.photos/200/300", - }, - { - name: "Box at Schlossgarten", - id: "3", - image: "https://picsum.photos/200/300", - }, -]; + { + name: 'Box at IFGI', + id: '1', + image: '/sensebox_outdoor.jpg', + }, + { + name: 'senseBox at Aasee', + id: '2', + image: 'https://picsum.photos/200/300', + }, + { + name: 'Box at Schlossgarten', + id: '3', + image: 'https://picsum.photos/200/300', + }, +] export default function ProfileBoxSelection() { - // const [selectedBox, setSelectedBox] = useState(dummyBoxes[0]); - return ( -
- {/* this is all jsut dummy data - the real data will be fetched from the API as soon as the route is implemented */} - - - {dummyBoxes[0].name} - Last activity: 13min ago - - -
- -
-
- - - -
-
- ); + // const [selectedBox, setSelectedBox] = useState(dummyBoxes[0]); + return ( +
+ {/* this is all jsut dummy data - the real data will be fetched from the API as soon as the route is implemented */} + + + {dummyBoxes[0]!.name} + Last activity: 13min ago + + +
+ +
+
+ + + +
+
+ ) } diff --git a/app/components/device/new/general-info.tsx b/app/components/device/new/general-info.tsx index 3f0e518f..8b6a11fa 100644 --- a/app/components/device/new/general-info.tsx +++ b/app/components/device/new/general-info.tsx @@ -1,200 +1,200 @@ -import { Plus, Cloud, Home, HelpCircle, Bike, X, Info } from "lucide-react"; -import React, { useState } from "react"; -import { useFormContext, useFieldArray } from "react-hook-form"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Badge } from "~/components/ui/badge"; -import { Checkbox } from "~/components/ui/checkbox"; -import { Label } from "~/components/ui/label"; +import { Plus, Cloud, Home, HelpCircle, Bike, X, Info } from 'lucide-react' +import React, { useState } from 'react' +import { useFormContext, useFieldArray } from 'react-hook-form' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '~/components/ui/badge' +import { Checkbox } from '~/components/ui/checkbox' +import { Label } from '~/components/ui/label' import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "~/components/ui/tooltip"; + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '~/components/ui/tooltip' -type ExposureOption = "outdoor" | "indoor" | "mobile" | "unknown"; +type ExposureOption = 'outdoor' | 'indoor' | 'mobile' | 'unknown' export function GeneralInfoStep() { - const { register, control, setValue, getValues, watch } = useFormContext(); - const { fields, append, remove } = useFieldArray({ - control, - name: "tags", // Tags array - }); + const { register, control, setValue, getValues, watch } = useFormContext() + const { fields, append, remove } = useFieldArray({ + control, + name: 'tags', // Tags array + }) - const currentExposure = watch("exposure"); // Watch exposure value + const currentExposure = watch('exposure') // Watch exposure value - // State for temporary expiration date - const [temporaryExpirationDate, setTemporaryExpirationDate] = useState< - string | null - >(watch("temporaryExpirationDate") || null); + // State for temporary expiration date + const [temporaryExpirationDate, setTemporaryExpirationDate] = useState< + string | null + >(watch('temporaryExpirationDate') || null) - const maxExpirationDate = new Date(); - maxExpirationDate.setMonth(maxExpirationDate.getMonth() + 1); + const maxExpirationDate = new Date() + maxExpirationDate.setMonth(maxExpirationDate.getMonth() + 1) - const handleTemporaryChange = (checked: boolean) => { - if (checked) { - const newDate = maxExpirationDate.toISOString().split("T")[0]; - setTemporaryExpirationDate(newDate); // Update local state - setValue("temporaryExpirationDate", newDate); // Update form value - } else { - setTemporaryExpirationDate(null); // Clear local state - setValue("temporaryExpirationDate", ""); // Clear form value - } - }; + const handleTemporaryChange = (checked: boolean) => { + if (checked) { + const newDate = maxExpirationDate.toISOString().slice(0, 10) + setTemporaryExpirationDate(newDate) // Update local state + setValue('temporaryExpirationDate', newDate) // Update form value + } else { + setTemporaryExpirationDate(null) // Clear local state + setValue('temporaryExpirationDate', '') // Clear form value + } + } - const handleExpirationDateChange = (date: string) => { - setTemporaryExpirationDate(date); // Update local state - setValue("temporaryExpirationDate", date); // Update form value - }; + const handleExpirationDateChange = (date: string) => { + setTemporaryExpirationDate(date) // Update local state + setValue('temporaryExpirationDate', date) // Update form value + } - const addTag = (event: React.FormEvent) => { - event.preventDefault(); - const tagInput = document.getElementById("tag-input") as HTMLInputElement; - if (tagInput?.value.trim()) { - append({ value: tagInput.value.trim() }); // Append a new tag object - tagInput.value = ""; // Clear input - } - }; + const addTag = (event: React.FormEvent) => { + event.preventDefault() + const tagInput = document.getElementById('tag-input') as HTMLInputElement + if (tagInput?.value.trim()) { + append({ value: tagInput.value.trim() }) // Append a new tag object + tagInput.value = '' // Clear input + } + } - const exposureOptions: { - value: ExposureOption; - icon: React.ReactNode; - label: string; - }[] = [ - { value: "outdoor", icon: , label: "Outdoor" }, - { value: "indoor", icon: , label: "Indoor" }, - { - value: "mobile", - icon: , - label: "Mobile", - }, - { - value: "unknown", - icon: , - label: "Unknown", - }, - ]; + const exposureOptions: { + value: ExposureOption + icon: React.ReactNode + label: string + }[] = [ + { value: 'outdoor', icon: , label: 'Outdoor' }, + { value: 'indoor', icon: , label: 'Indoor' }, + { + value: 'mobile', + icon: , + label: 'Mobile', + }, + { + value: 'unknown', + icon: , + label: 'Unknown', + }, + ] - return ( -
-
- - -
-
- -
- {exposureOptions.map((option) => ( - - ))} -
-
-
-
-
- - - - - - - - - { -

- Temporary devices will be automatically deleted after a - maximum of one month. -

- } -
-
-
-
- {temporaryExpirationDate && ( -
- - handleExpirationDateChange(e.target.value)} - min={new Date().toISOString().split("T")[0]} - max={maxExpirationDate.toISOString().split("T")[0]} - className="flex-grow p-2 border rounded-md" - /> -
- )} -
-
-
- -
- { - if (e.key === "Enter") { - e.preventDefault(); - addTag(e); // Call addTag on Enter key - } - }} - /> - -
-
- {fields.map((field, index) => ( - - {getValues(`tags.${index}.value`)} - - - ))} -
-
-
- ); + return ( +
+
+ + +
+
+ +
+ {exposureOptions.map((option) => ( + + ))} +
+
+
+
+
+ + + + + + + + + { +

+ Temporary devices will be automatically deleted after a + maximum of one month. +

+ } +
+
+
+
+ {temporaryExpirationDate && ( +
+ + handleExpirationDateChange(e.target.value)} + min={new Date().toISOString().split('T')[0]} + max={maxExpirationDate.toISOString().split('T')[0]} + className="flex-grow rounded-md border p-2" + /> +
+ )} +
+
+
+ +
+ { + if (e.key === 'Enter') { + e.preventDefault() + addTag(e) // Call addTag on Enter key + } + }} + /> + +
+
+ {fields.map((field, index) => ( + + {getValues(`tags.${index}.value`)} + + + ))} +
+
+
+ ) } diff --git a/app/components/device/new/sensors-info.tsx b/app/components/device/new/sensors-info.tsx index a069cf13..c8d1e256 100644 --- a/app/components/device/new/sensors-info.tsx +++ b/app/components/device/new/sensors-info.tsx @@ -1,215 +1,215 @@ -import { useState, useEffect } from "react"; -import { useFormContext } from "react-hook-form"; -import { z } from "zod"; -import { CustomDeviceConfig } from "./custom-device-config"; -import { Card, CardContent } from "~/components/ui/card"; -import { cn } from "~/lib/utils"; -import { getSensorsForModel } from "~/utils/model-definitions"; +import { useState, useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { z } from 'zod' +import { CustomDeviceConfig } from './custom-device-config' +import { Card, CardContent } from '~/components/ui/card' +import { cn } from '~/lib/utils' +import { getSensorsForModel } from '~/utils/model-definitions' export const sensorSchema = z.object({ - title: z.string(), - unit: z.string(), - sensorType: z.string(), - icon: z.string().optional(), - image: z.string().optional(), -}); + title: z.string(), + unit: z.string(), + sensorType: z.string(), + icon: z.string().optional(), + image: z.string().optional(), +}) -export type Sensor = z.infer; +export type Sensor = z.infer type SensorGroup = { - sensorType: string; - sensors: Sensor[]; - image?: string; -}; + sensorType: string + sensors: Sensor[] + image?: string +} export function SensorSelectionStep() { - const { watch, setValue } = useFormContext(); - const selectedDevice = watch("model"); - const [selectedDeviceModel, setSelectedDeviceModel] = useState( - null, - ); - const [sensors, setSensors] = useState([]); - const [selectedSensors, setSelectedSensors] = useState([]); - - useEffect(() => { - if (selectedDevice) { - const deviceModel = selectedDevice.startsWith("homeV2") - ? "senseBoxHomeV2" - : selectedDevice; - setSelectedDeviceModel(deviceModel); - - const fetchSensors = () => { - const fetchedSensors = getSensorsForModel(deviceModel); - setSensors(fetchedSensors); - }; - fetchSensors(); - } - }, [selectedDevice]); - - useEffect(() => { - const savedSelectedSensors = watch("selectedSensors") || []; - setSelectedSensors(savedSelectedSensors); - }, [watch]); - - const groupSensorsByType = (sensors: Sensor[]): SensorGroup[] => { - const grouped = sensors.reduce( - (acc, sensor) => { - if (!acc[sensor.sensorType]) { - acc[sensor.sensorType] = []; - } - acc[sensor.sensorType].push(sensor); - return acc; - }, - {} as Record, - ); - - return Object.entries(grouped).map(([sensorType, sensors]) => ({ - sensorType, - sensors, - image: sensors.find((sensor) => sensor.image)?.image, - })); - }; - - const sensorGroups = groupSensorsByType(sensors); - - const handleGroupToggle = (group: SensorGroup) => { - const isGroupSelected = group.sensors.every((sensor) => - selectedSensors.some( - (s) => s.title === sensor.title && s.sensorType === sensor.sensorType, - ), - ); - - const updatedSensors = isGroupSelected - ? selectedSensors.filter( - (s) => - !group.sensors.some( - (sensor) => - s.title === sensor.title && s.sensorType === sensor.sensorType, - ), - ) - : [ - ...selectedSensors, - ...group.sensors.filter( - (sensor) => - !selectedSensors.some( - (s) => - s.title === sensor.title && - s.sensorType === sensor.sensorType, - ), - ), - ]; - - setSelectedSensors(updatedSensors); - setValue("selectedSensors", updatedSensors); - }; - - const handleSensorToggle = (sensor: Sensor) => { - const isAlreadySelected = selectedSensors.some( - (s) => s.title === sensor.title && s.sensorType === sensor.sensorType, - ); - - const updatedSensors = isAlreadySelected - ? selectedSensors.filter( - (s) => - !(s.title === sensor.title && s.sensorType === sensor.sensorType), - ) - : [...selectedSensors, sensor]; - - setSelectedSensors(updatedSensors); - setValue("selectedSensors", updatedSensors); - }; - - if (!selectedDevice) { - return

Please select a device first.

; - } - - if (selectedDevice === "Custom") { - return ; - } - - return ( -
-
-
- {sensorGroups.map((group) => { - const isGroupSelected = group.sensors.every((sensor) => - selectedSensors.some( - (s) => - s.title === sensor.title && - s.sensorType === sensor.sensorType, - ), - ); - - return ( - handleGroupToggle(group) - : undefined - } - > - -

- {group.sensorType} -

- -
    - {group.sensors.map((sensor) => { - const isSelected = selectedSensors.some( - (s) => - s.title === sensor.title && - s.sensorType === sensor.sensorType, - ); - - return ( -
  • { - e.stopPropagation(); - handleSensorToggle(sensor); - } - : undefined - } - > - {sensor.title} ({sensor.unit}) -
  • - ); - })} -
-
- {group.image && ( - {`${group.sensorType} - )} -
-
-
- ); - })} -
-
-
- ); + const { watch, setValue } = useFormContext() + const selectedDevice = watch('model') + const [selectedDeviceModel, setSelectedDeviceModel] = useState( + null, + ) + const [sensors, setSensors] = useState([]) + const [selectedSensors, setSelectedSensors] = useState([]) + + useEffect(() => { + if (selectedDevice) { + const deviceModel = selectedDevice.startsWith('homeV2') + ? 'senseBoxHomeV2' + : selectedDevice + setSelectedDeviceModel(deviceModel) + + const fetchSensors = () => { + const fetchedSensors = getSensorsForModel(deviceModel) + setSensors(fetchedSensors) + } + fetchSensors() + } + }, [selectedDevice]) + + useEffect(() => { + const savedSelectedSensors = watch('selectedSensors') || [] + setSelectedSensors(savedSelectedSensors) + }, [watch]) + + const groupSensorsByType = (sensors: Sensor[]): SensorGroup[] => { + const grouped = sensors.reduce( + (acc, sensor) => { + if (!acc[sensor.sensorType]) { + acc[sensor.sensorType] = [] + } + acc[sensor.sensorType]!.push(sensor) + return acc + }, + {} as Record, + ) + + return Object.entries(grouped).map(([sensorType, sensors]) => ({ + sensorType, + sensors, + image: sensors.find((sensor) => sensor.image)?.image, + })) + } + + const sensorGroups = groupSensorsByType(sensors) + + const handleGroupToggle = (group: SensorGroup) => { + const isGroupSelected = group.sensors.every((sensor) => + selectedSensors.some( + (s) => s.title === sensor.title && s.sensorType === sensor.sensorType, + ), + ) + + const updatedSensors = isGroupSelected + ? selectedSensors.filter( + (s) => + !group.sensors.some( + (sensor) => + s.title === sensor.title && s.sensorType === sensor.sensorType, + ), + ) + : [ + ...selectedSensors, + ...group.sensors.filter( + (sensor) => + !selectedSensors.some( + (s) => + s.title === sensor.title && + s.sensorType === sensor.sensorType, + ), + ), + ] + + setSelectedSensors(updatedSensors) + setValue('selectedSensors', updatedSensors) + } + + const handleSensorToggle = (sensor: Sensor) => { + const isAlreadySelected = selectedSensors.some( + (s) => s.title === sensor.title && s.sensorType === sensor.sensorType, + ) + + const updatedSensors = isAlreadySelected + ? selectedSensors.filter( + (s) => + !(s.title === sensor.title && s.sensorType === sensor.sensorType), + ) + : [...selectedSensors, sensor] + + setSelectedSensors(updatedSensors) + setValue('selectedSensors', updatedSensors) + } + + if (!selectedDevice) { + return

Please select a device first.

+ } + + if (selectedDevice === 'Custom') { + return + } + + return ( +
+
+
+ {sensorGroups.map((group) => { + const isGroupSelected = group.sensors.every((sensor) => + selectedSensors.some( + (s) => + s.title === sensor.title && + s.sensorType === sensor.sensorType, + ), + ) + + return ( + handleGroupToggle(group) + : undefined + } + > + +

+ {group.sensorType} +

+ +
    + {group.sensors.map((sensor) => { + const isSelected = selectedSensors.some( + (s) => + s.title === sensor.title && + s.sensorType === sensor.sensorType, + ) + + return ( +
  • { + e.stopPropagation() + handleSensorToggle(sensor) + } + : undefined + } + > + {sensor.title} ({sensor.unit}) +
  • + ) + })} +
+
+ {group.image && ( + {`${group.sensorType} + )} +
+
+
+ ) + })} +
+
+
+ ) } diff --git a/app/components/header/menu/index.tsx b/app/components/header/menu/index.tsx index fe31a44a..9a1bf533 100644 --- a/app/components/header/menu/index.tsx +++ b/app/components/header/menu/index.tsx @@ -1,222 +1,228 @@ import { - Globe, - LogIn, - LogOut, - Puzzle, - Menu as MenuIcon, - FileLock2, - Coins, - User2, - ExternalLink, - Settings, - Compass, -} from "lucide-react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Form, Link, useMatches, useNavigation, useSearchParams } from "react-router"; + Globe, + LogIn, + LogOut, + Puzzle, + Menu as MenuIcon, + FileLock2, + Coins, + User2, + ExternalLink, + Settings, + Compass, +} from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import Spinner from "~/components/spinner"; -import { toast } from "~/components/ui/use-toast"; -import { useOptionalUser } from "~/utils"; + Form, + Link, + useMatches, + useNavigation, + useSearchParams, +} from 'react-router' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import Spinner from '~/components/spinner' +import { toast } from '~/components/ui/use-toast' +import { useOptionalUser } from '~/utils' export default function Menu() { - const [searchParams] = useSearchParams(); - const redirectTo = - searchParams.size > 0 ? "/explore?" + searchParams.toString() : "/explore"; - const [open, setOpen] = useState(false); - const navigation = useNavigation(); - const isLoggingOut = Boolean(navigation.state === "submitting"); - const user = useOptionalUser(); - const matches = useMatches(); + const [searchParams] = useSearchParams() + const redirectTo = + searchParams.size > 0 ? '/explore?' + searchParams.toString() : '/explore' + const [open, setOpen] = useState(false) + const navigation = useNavigation() + const isLoggingOut = Boolean(navigation.state === 'submitting') + const user = useOptionalUser() + const matches = useMatches() - const { t } = useTranslation("menu"); + const { t } = useTranslation('menu') - return ( - - -
- -
-
- -
- - {!user ? ( -
-

{t("title")}

-

- {t("subtitle")} -

-
- ) : ( -
-

- {/* Max Mustermann */} - {user?.name} -

-

- {user?.email} -

-
- )} -
- - {user && ( - - {navigation.state === "loading" && ( -
- -
- )} - {!(matches[1].pathname === "/explore") && ( - - - - {"Explore"} - - - )} - {!(matches[1].pathname === "/profile") && ( - - - - Profile - - - )} + return ( + + +
+ +
+
+ +
+ + {!user ? ( +
+

{t('title')}

+

+ {t('subtitle')} +

+
+ ) : ( +
+

+ {/* Max Mustermann */} + {user?.name} +

+

+ {user?.email} +

+
+ )} +
+ + {user && ( + + {navigation.state === 'loading' && ( +
+ +
+ )} + {!(matches[1]?.pathname === '/explore') && ( + + + + {'Explore'} + + + )} + {!(matches[1]?.pathname === '/profile') && ( + + + + Profile + + + )} - {!(matches[1].pathname === "/settings") && ( - - - - {"Settings"} - - - - )} -
- )} - - - - - {t("tutorials_label")} - - - + {!(matches[1]?.pathname === '/settings') && ( + + + + {'Settings'} + + + + )} + + )} + + + + + {t('tutorials_label')} + + + - - - - {t("api_docs_label")} - - - - - - - - - - {t("data_protection_label")} - - - - + + + + {t('api_docs_label')} + + + + + + + + + + {t('data_protection_label')} + + + + - - - e.preventDefault()} - className="cursor-pointer" - > - - {t("donate_label")} - - - - + + + e.preventDefault()} + className="cursor-pointer" + > + + {t('donate_label')} + + + + - + - - { - // Prevent dropdown from closing - e.preventDefault(); - }} - > - {!user ? ( - setOpen(false)} - className="cursor-pointer w-full" - > - - - ) : ( -
{ - setOpen(false); - toast({ - description: "Successfully logged out.", - }); - }} - className="cursor-pointer w-full" - > - - -
- )} -
-
-
-
-
- ); + + { + // Prevent dropdown from closing + e.preventDefault() + }} + > + {!user ? ( + setOpen(false)} + className="w-full cursor-pointer" + > + + + ) : ( +
{ + setOpen(false) + toast({ + description: 'Successfully logged out.', + }) + }} + className="w-full cursor-pointer" + > + + +
+ )} +
+
+
+
+
+ ) } diff --git a/app/components/header/nav-bar/nav-bar-handler.tsx b/app/components/header/nav-bar/nav-bar-handler.tsx index 62d86612..7ee28be6 100644 --- a/app/components/header/nav-bar/nav-bar-handler.tsx +++ b/app/components/header/nav-bar/nav-bar-handler.tsx @@ -1,81 +1,81 @@ -import { Clock4Icon, Filter, IceCream2Icon, Tag } from "lucide-react"; -import FilterOptions from "./filter-options/filter-options"; +import { Clock4Icon, Filter, IceCream2Icon, Tag } from 'lucide-react' +import FilterOptions from './filter-options/filter-options' // import { PhenomenonSelect } from "./phenomenon-select/phenomenon-select"; -import FilterTags from "./filter-options/filter-tags"; -import useKeyboardNav from "./use-keyboard-nav"; -import Search from "~/components/search"; -import { cn } from "~/lib/utils"; -import { type Device } from "~/schema"; +import FilterTags from './filter-options/filter-tags' +import useKeyboardNav from './use-keyboard-nav' +import Search from '~/components/search' +import { cn } from '~/lib/utils' +import { type Device } from '~/schema' interface NavBarHandlerProps { - devices: Device[]; - searchString: string; + devices: Device[] + searchString: string } function getSections() { - return [ - { - title: "Filter", - icon: Filter, - color: "bg-blue-100", - component: , - }, - { - title: "Tags", - icon: Tag, - color: "bg-light-green", - component: , - }, - { - title: "Date & Time", - icon: Clock4Icon, - color: "bg-gray-300", - component:
Coming soon...
, - }, - { - title: "Phänomen", - icon: IceCream2Icon, - color: "bg-slate-500", - component:
Coming soon...
//, - }, - ]; + return [ + { + title: 'Filter', + icon: Filter, + color: 'bg-blue-100', + component: , + }, + { + title: 'Tags', + icon: Tag, + color: 'bg-light-green', + component: , + }, + { + title: 'Date & Time', + icon: Clock4Icon, + color: 'bg-gray-300', + component:
Coming soon...
, + }, + { + title: 'Phänomen', + icon: IceCream2Icon, + color: 'bg-slate-500', + component:
Coming soon...
, //, + }, + ] } export default function NavbarHandler({ - devices, - searchString, + devices, + searchString, }: NavBarHandlerProps) { - const sections = getSections(); + const sections = getSections() - const { cursor, setCursor } = useKeyboardNav(0, 0, sections.length); + const { cursor, setCursor } = useKeyboardNav(0, 0, sections.length) - if (searchString.length >= 2) { - return ; - } + if (searchString.length >= 2) { + return + } - return ( -
-
- {sections.map((section, index) => ( -
{ - setCursor(index); - }} - > - - {section.title} -
- ))} -
-
{sections[cursor].component}
-
- ); + return ( +
+
+ {sections.map((section, index) => ( +
{ + setCursor(index) + }} + > + + {section.title} +
+ ))} +
+
{sections.at(cursor)?.component}
+
+ ) } diff --git a/app/components/landing/sections/features-carousel.tsx b/app/components/landing/sections/features-carousel.tsx index a80adabd..42a19070 100644 --- a/app/components/landing/sections/features-carousel.tsx +++ b/app/components/landing/sections/features-carousel.tsx @@ -1,188 +1,188 @@ -import { AnimatePresence, motion, type Variants } from "framer-motion"; -import { ArrowLeft, ArrowRight } from "lucide-react"; -import { useState, useEffect } from "react"; -import FeatureCard from "./features-card"; -import { type Feature } from "~/lib/directus"; +import { AnimatePresence, motion, type Variants } from 'framer-motion' +import { ArrowLeft, ArrowRight } from 'lucide-react' +import { useState, useEffect } from 'react' +import FeatureCard from './features-card' +import { type Feature } from '~/lib/directus' type FeaturesProps = { - data: Feature[]; -}; + data: Feature[] +} const variants: Variants = { - enter: ({ direction }) => { - return { scale: 0.2, x: direction < 1 ? 50 : -50, opacity: 0 }; - }, - center: ({ position, direction }) => { - return { - scale: position() === "center" ? 1 : 0.7, - x: 0, - zIndex: getZIndex({ position, direction }), - opacity: 1, - }; - }, - exit: ({ direction }) => { - return { scale: 0.2, x: direction < 1 ? -50 : 50, opacity: 0 }; - }, -}; + enter: ({ direction }) => { + return { scale: 0.2, x: direction < 1 ? 50 : -50, opacity: 0 } + }, + center: ({ position, direction }) => { + return { + scale: position() === 'center' ? 1 : 0.7, + x: 0, + zIndex: getZIndex({ position, direction }), + opacity: 1, + } + }, + exit: ({ direction }) => { + return { scale: 0.2, x: direction < 1 ? -50 : 50, opacity: 0 } + }, +} function getZIndex({ - position, - direction, + position, + direction, }: { - position: () => string; - direction: number; + position: () => 'left' | 'center' | 'right' + direction: number }): number { - const indexes: { [key: string]: number } = { - left: direction > 0 ? 2 : 1, - center: 3, - right: direction > 0 ? 1 : 2, - }; - return indexes[position()]; + const indexes = { + left: direction > 0 ? 2 : 1, + center: 3, + right: direction > 0 ? 1 : 2, + } + return indexes[position()] } export default function FeaturesCarousel({ data }: FeaturesProps) { - const [[activeIndex, direction], setActiveIndex] = useState<[number, number]>( - [0, 0], - ); - const [isButtonDisabled, setIsButtonDisabled] = useState(false); - const [isMobileScreen, setIsMobileScreen] = useState(false); + const [[activeIndex, direction], setActiveIndex] = useState<[number, number]>( + [0, 0], + ) + const [isButtonDisabled, setIsButtonDisabled] = useState(false) + const [isMobileScreen, setIsMobileScreen] = useState(false) - useEffect(() => { - const handleResize = () => { - setIsMobileScreen(window.innerWidth <= 768); // Adjust the breakpoint as needed - }; + useEffect(() => { + const handleResize = () => { + setIsMobileScreen(window.innerWidth <= 768) // Adjust the breakpoint as needed + } - handleResize(); // Check on initial render + handleResize() // Check on initial render - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, []) - const indexInArrayScope = - ((activeIndex % data.length) + data.length) % data.length; + const indexInArrayScope = + ((activeIndex % data.length) + data.length) % data.length - const visibleItems: Feature[] = isMobileScreen - ? [data[indexInArrayScope]] - : [...data, ...data].slice(indexInArrayScope, indexInArrayScope + 3); + const visibleItems: Feature[] = isMobileScreen + ? [data[indexInArrayScope]!] + : [...data, ...data].slice(indexInArrayScope, indexInArrayScope + 3) - const handleClick = (newDirection: number): void => { - if (isButtonDisabled) return; // Prevent clicking if the button is disabled + const handleClick = (newDirection: number): void => { + if (isButtonDisabled) return // Prevent clicking if the button is disabled - setActiveIndex((prevIndex) => [prevIndex[0] + newDirection, newDirection]); - setIsButtonDisabled(true); // Disable the button + setActiveIndex((prevIndex) => [prevIndex[0] + newDirection, newDirection]) + setIsButtonDisabled(true) // Disable the button - setTimeout(() => { - setIsButtonDisabled(false); // Enable the button after 1 second - }, 1000); - }; + setTimeout(() => { + setIsButtonDisabled(false) // Enable the button after 1 second + }, 1000) + } - return ( -
- - handleClick(-1)} - initial="hidden" - whileInView="visible" - viewport={{ once: true }} - transition={{ - duration: 0.5, - delay: 0.8, - type: "spring", - stiffness: 150, - }} - variants={{ - visible: { opacity: 1, x: 0 }, - hidden: { opacity: 0, x: -50 }, - }} - > - - - - - - - - {visibleItems.map((item: Feature) => { - return ( - { - if (item === visibleItems[0]) { - return "left"; - } else if (item === visibleItems[1]) { - return "center"; - } else { - return "right"; - } - }, - }} - variants={variants} - initial="enter" - animate="center" - exit="exit" - transition={{ duration: 1 }} - > - - - ); - })} - - handleClick(1)} - initial="hidden" - whileInView="visible" - viewport={{ once: true }} - transition={{ - duration: 0.3, - delay: 0.8, - type: "spring", - stiffness: 150, - }} - variants={{ - visible: { opacity: 1, x: 0 }, - hidden: { opacity: 0, x: 50 }, - }} - > - - - - - - - -
- ); + return ( +
+ + handleClick(-1)} + initial="hidden" + whileInView="visible" + viewport={{ once: true }} + transition={{ + duration: 0.5, + delay: 0.8, + type: 'spring', + stiffness: 150, + }} + variants={{ + visible: { opacity: 1, x: 0 }, + hidden: { opacity: 0, x: -50 }, + }} + > + + + + + + + + {visibleItems.map((item: Feature) => { + return ( + { + if (item === visibleItems[0]) { + return 'left' + } else if (item === visibleItems[1]) { + return 'center' + } else { + return 'right' + } + }, + }} + variants={variants} + initial="enter" + animate="center" + exit="exit" + transition={{ duration: 1 }} + > + + + ) + })} + + handleClick(1)} + initial="hidden" + whileInView="visible" + viewport={{ once: true }} + transition={{ + duration: 0.3, + delay: 0.8, + type: 'spring', + stiffness: 150, + }} + variants={{ + visible: { opacity: 1, x: 0 }, + hidden: { opacity: 0, x: 50 }, + }} + > + + + + + + + +
+ ) } diff --git a/app/components/landing/sections/tools.tsx b/app/components/landing/sections/tools.tsx index c734d81b..2593467d 100644 --- a/app/components/landing/sections/tools.tsx +++ b/app/components/landing/sections/tools.tsx @@ -1,90 +1,90 @@ -import { motion } from "framer-motion"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { motion } from 'framer-motion' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' export default function Tools() { - const tools = [ - { - name: "Tool 1", - video: "/landing/stock_video.mp4", - id: 1, - }, - { - name: "Tool 2", - video: "/landing/stock_video.mp4", - id: 2, - }, - { - name: "Tool 3", - video: "/landing/stock_video.mp4", - id: 3, - }, - { - name: "Tool 4", - video: "/landing/stock_video.mp4", - id: 4, - }, - { - name: "Tool 5", - video: "/landing/stock_video.mp4", - id: 5, - }, - ]; - return ( -
-
-
-

- Tools -

-
- - -
- - {tools.map((tool) => { - return ( - - {tool.name} - - ); - })} - -
- {tools.map((tool) => { - return ( - -
- -
-
- ); - })} -
-
-
-
- ); + const tools = [ + { + name: 'Tool 1', + video: '/landing/stock_video.mp4', + id: 1, + }, + { + name: 'Tool 2', + video: '/landing/stock_video.mp4', + id: 2, + }, + { + name: 'Tool 3', + video: '/landing/stock_video.mp4', + id: 3, + }, + { + name: 'Tool 4', + video: '/landing/stock_video.mp4', + id: 4, + }, + { + name: 'Tool 5', + video: '/landing/stock_video.mp4', + id: 5, + }, + ] + return ( +
+
+
+

+ Tools +

+
+ + +
+ + {tools.map((tool) => { + return ( + + {tool.name} + + ) + })} + +
+ {tools.map((tool) => { + return ( + +
+ +
+
+ ) + })} +
+
+
+
+ ) } diff --git a/app/components/landing/stats.tsx b/app/components/landing/stats.tsx index 1d94e011..4dba47cb 100644 --- a/app/components/landing/stats.tsx +++ b/app/components/landing/stats.tsx @@ -1,61 +1,64 @@ -import { motion } from "framer-motion"; -import { useTranslation } from "react-i18next"; -import AnimatedCounter from "../ui/animated-counter"; +import { motion } from 'framer-motion' +import { useTranslation } from 'react-i18next' +import AnimatedCounter from '../ui/animated-counter' export default function Stats(stats: number[]) { - const { t } = useTranslation("stats"); + const { t } = useTranslation('stats') - const osemStats = [ - { - id: 1, - name: "devices", - value: stats[0] / 1000, - unit: "k", - }, - { - id: 2, - name: "measurements_total", - value: stats[1] / 1000000, - unit: "m", - }, - { - id: 3, - name: "measurements_per_minute", - value: stats[2] / 1000, - unit: "k", - }, - ]; + const osemStats = + stats.length === 3 + ? [ + { + id: 1, + name: 'devices', + value: stats[0]! / 1000, + unit: 'k', + }, + { + id: 2, + name: 'measurements_total', + value: stats[1]! / 1000000, + unit: 'm', + }, + { + id: 3, + name: 'measurements_per_minute', + value: stats[2]! / 1000, + unit: 'k', + }, + ] + : [] - return ( -
-
- {osemStats.map((stat) => ( -
- -
-
-
- -
-
- {stat.unit} -
-
-

{t(stat.name)}

-
-
-
- ))} -
-
- ); + return ( +
+
+ {osemStats.map((stat) => ( +
+ +
+
+
+ +
+
+ {stat.unit} +
+
+

{t(stat.name)}

+
+
+
+ ))} +
+
+ ) } diff --git a/app/components/map/layers/cluster/box-marker.tsx b/app/components/map/layers/cluster/box-marker.tsx index 582df870..9688edf4 100644 --- a/app/components/map/layers/cluster/box-marker.tsx +++ b/app/components/map/layers/cluster/box-marker.tsx @@ -70,7 +70,7 @@ export default function BoxMarker({ device, ...props }: BoxMarkerProps) { } if (compareMode) { void navigate( - `/explore/${matches[2].params.deviceId}/compare/${device.id}`, + `/explore/${matches[2]?.params.deviceId}/compare/${device.id}`, ) setCompareMode(false) return diff --git a/app/components/map/layers/cluster/cluster-layer.tsx b/app/components/map/layers/cluster/cluster-layer.tsx index d22883ea..73663bbb 100644 --- a/app/components/map/layers/cluster/cluster-layer.tsx +++ b/app/components/map/layers/cluster/cluster-layer.tsx @@ -1,152 +1,152 @@ -import { - type GeoJsonProperties, - type BBox, - type FeatureCollection, - type Point, -} from "geojson"; -import debounce from "lodash.debounce"; -import { useMemo, useCallback, useState, useEffect } from "react"; -import { Marker, useMap } from "react-map-gl"; -import { type PointFeature } from "supercluster"; -import useSupercluster from "use-supercluster"; -import BoxMarker from "./box-marker"; -import DonutChartCluster from "./donut-chart-cluster"; -import { type DeviceClusterProperties } from "~/routes/explore"; -import { type Device } from "~/schema"; - -const DEBOUNCE_VALUE = 50; +import { + type GeoJsonProperties, + type BBox, + type FeatureCollection, + type Point, +} from 'geojson' +import debounce from 'lodash.debounce' +import { useMemo, useCallback, useState, useEffect } from 'react' +import { Marker, useMap } from 'react-map-gl' +import { type PointFeature } from 'supercluster' +import useSupercluster from 'use-supercluster' +import BoxMarker from './box-marker' +import DonutChartCluster from './donut-chart-cluster' +import { type DeviceClusterProperties } from '~/routes/explore' +import { type Device } from '~/schema' + +const DEBOUNCE_VALUE = 50 // supercluster options const options = { - radius: 50, - maxZoom: 14, - map: (props: any) => ({ categories: { [props.status]: 1 } }), - reduce: (accumulated: any, props: any) => { - const categories: any = {}; - // clone the categories object from the accumulator - for (const key in accumulated.categories) { - categories[key] = accumulated.categories[key]; - } - // add props' category data to the clone - for (const key in props.categories) { - if (key in accumulated.categories) { - categories[key] = accumulated.categories[key] + props.categories[key]; - } else { - categories[key] = props.categories[key]; - } - } - // assign the clone to the accumulator - accumulated.categories = categories; - }, -}; + radius: 50, + maxZoom: 14, + map: (props: any) => ({ categories: { [props.status]: 1 } }), + reduce: (accumulated: any, props: any) => { + const categories: any = {} + // clone the categories object from the accumulator + for (const key in accumulated.categories) { + categories[key] = accumulated.categories[key] + } + // add props' category data to the clone + for (const key in props.categories) { + if (key in accumulated.categories) { + categories[key] = accumulated.categories[key] + props.categories[key] + } else { + categories[key] = props.categories[key] + } + } + // assign the clone to the accumulator + accumulated.categories = categories + }, +} export default function ClusterLayer({ - devices, + devices, }: { - devices: FeatureCollection; + devices: FeatureCollection }) { - const { osem: mapRef } = useMap(); - - // the viewport bounds and zoom level - const [bounds, setBounds] = useState( - mapRef?.getMap().getBounds().toArray().flat() as BBox - ); - const [zoom, setZoom] = useState(mapRef?.getZoom() || 0); - - // get clusters - const points: PointFeature[] = useMemo(() => { - return devices.features.map((device) => ({ - type: "Feature", - properties: { - cluster: false, - ...device.properties, - }, - geometry: device.geometry, - })); - }, [devices.features]); - - // get bounds and zoom level from the map - // debounce the change handler to prevent too many updates - const debouncedChangeHandler = debounce(() => { - if (!mapRef) return; - setBounds(mapRef.getMap().getBounds().toArray().flat() as BBox); - setZoom(mapRef.getZoom()); - }, DEBOUNCE_VALUE); - - // register the debounced change handler to map events - useEffect(() => { - if (!mapRef) return; - - mapRef?.getMap().on("load", debouncedChangeHandler); - mapRef?.getMap().on("zoom", debouncedChangeHandler); - mapRef?.getMap().on("move", debouncedChangeHandler); - mapRef?.getMap().on("resize", debouncedChangeHandler); - }, [debouncedChangeHandler, mapRef]); - - const { clusters, supercluster } = useSupercluster({ - points, - bounds, - zoom, - options, - }); - - const clusterOnClick = useCallback( - (cluster: DeviceClusterProperties) => { - // supercluster from hook can be null or undefined - if (!supercluster) return; - - const [longitude, latitude] = cluster.geometry.coordinates; - - const expansionZoom = Math.min( - supercluster.getClusterExpansionZoom(cluster.id as number), - 20 - ); - - mapRef?.getMap().flyTo({ - center: [longitude, latitude], - animate: true, - speed: 1.6, - zoom: expansionZoom, - essential: true, - }); - }, - [mapRef, supercluster] - ); - - const clusterMarker = useMemo(() => { - return clusters.map((cluster) => { - // every cluster point has coordinates - const [longitude, latitude] = cluster.geometry.coordinates; - // the point may be either a cluster or a crime point - const { cluster: isCluster } = cluster.properties; - - // we have a cluster to render - if (isCluster) { - return ( - - - - ); - } - - // we have a single device to render - return ( - - ); - }); - }, [clusterOnClick, clusters]); - - return <>{clusterMarker}; + const { osem: mapRef } = useMap() + + // the viewport bounds and zoom level + const [bounds, setBounds] = useState( + mapRef?.getMap().getBounds().toArray().flat() as BBox, + ) + const [zoom, setZoom] = useState(mapRef?.getZoom() || 0) + + // get clusters + const points: PointFeature[] = useMemo(() => { + return devices.features.map((device) => ({ + type: 'Feature', + properties: { + cluster: false, + ...device.properties, + }, + geometry: device.geometry, + })) + }, [devices.features]) + + // get bounds and zoom level from the map + // debounce the change handler to prevent too many updates + const debouncedChangeHandler = debounce(() => { + if (!mapRef) return + setBounds(mapRef.getMap().getBounds().toArray().flat() as BBox) + setZoom(mapRef.getZoom()) + }, DEBOUNCE_VALUE) + + // register the debounced change handler to map events + useEffect(() => { + if (!mapRef) return + + mapRef?.getMap().on('load', debouncedChangeHandler) + mapRef?.getMap().on('zoom', debouncedChangeHandler) + mapRef?.getMap().on('move', debouncedChangeHandler) + mapRef?.getMap().on('resize', debouncedChangeHandler) + }, [debouncedChangeHandler, mapRef]) + + const { clusters, supercluster } = useSupercluster({ + points, + bounds, + zoom, + options, + }) + + const clusterOnClick = useCallback( + (cluster: DeviceClusterProperties) => { + // supercluster from hook can be null or undefined + if (!supercluster) return + + const [longitude, latitude] = cluster.geometry.coordinates + + const expansionZoom = Math.min( + supercluster.getClusterExpansionZoom(cluster.id as number), + 20, + ) + + mapRef?.getMap().flyTo({ + center: [longitude ?? 0, latitude ?? 0], + animate: true, + speed: 1.6, + zoom: expansionZoom, + essential: true, + }) + }, + [mapRef, supercluster], + ) + + const clusterMarker = useMemo(() => { + return clusters.map((cluster) => { + // every cluster point has coordinates + const [longitude = 0, latitude = 0] = cluster.geometry.coordinates + // the point may be either a cluster or a crime point + const { cluster: isCluster } = cluster.properties + + // we have a cluster to render + if (isCluster) { + return ( + + + + ) + } + + // we have a single device to render + return ( + + ) + }) + }, [clusterOnClick, clusters]) + + return <>{clusterMarker} } diff --git a/app/components/map/layers/cluster/donut-chart-cluster.tsx b/app/components/map/layers/cluster/donut-chart-cluster.tsx index c00fc37c..2a81f3f1 100644 --- a/app/components/map/layers/cluster/donut-chart-cluster.tsx +++ b/app/components/map/layers/cluster/donut-chart-cluster.tsx @@ -1,102 +1,102 @@ -import { type DeviceClusterProperties } from "~/routes/explore"; +import { type DeviceClusterProperties } from '~/routes/explore' type DonutChartClusterType = { - cluster: any; - clusterOnClick: (cluster: DeviceClusterProperties) => void; -}; + cluster: any + clusterOnClick: (cluster: DeviceClusterProperties) => void +} // colors to use for the categories const colors = [ - { color: "#4EAF47", opacity: 1 }, - { color: "#575757", opacity: 0.65 }, - { color: "#575757", opacity: 0.65 }, - { color: "#38AADD", opacity: 1 }, -]; + { color: '#4EAF47', opacity: 1 }, + { color: '#575757', opacity: 0.65 }, + { color: '#575757', opacity: 0.65 }, + { color: '#38AADD', opacity: 1 }, +] export default function DonutChartCluster({ - cluster, - clusterOnClick, + cluster, + clusterOnClick, }: DonutChartClusterType) { - const [theme] = "light"; //useTheme(); - const { categories, point_count: pointCount } = cluster.properties; - const { active = 0, inactive = 0, old = 0 } = categories; - const counts: number[] = [active, inactive, old]; - const offsets: number[] = []; - let total = 0; - for (const count of counts) { - offsets.push(total); - total += count; - } + const [theme] = 'light' //useTheme(); + const { categories, point_count: pointCount } = cluster.properties + const { active = 0, inactive = 0, old = 0 } = categories + const counts: number[] = [active, inactive, old] + const offsets: number[] = [] + let total = 0 + for (const count of counts) { + offsets.push(total) + total += count + } - const fontSize = - pointCount >= 1000 - ? 14 - : pointCount >= 100 - ? 12 - : pointCount >= 10 - ? 10 - : 10; - const r = - pointCount >= 1000 - ? 36 - : pointCount >= 100 - ? 20 - : pointCount >= 10 - ? 18 - : 18; - const r0 = Math.round(r * 0.7); - const w = r * 2; + const fontSize = + pointCount >= 1000 + ? 14 + : pointCount >= 100 + ? 12 + : pointCount >= 10 + ? 10 + : 10 + const r = + pointCount >= 1000 + ? 36 + : pointCount >= 100 + ? 20 + : pointCount >= 10 + ? 18 + : 18 + const r0 = Math.round(r * 0.7) + const w = r * 2 - return ( -
clusterOnClick(cluster)}> - - {counts.map((count, i) => { - const start = offsets[i] / total; - let end = (offsets[i] + count) / total; + return ( +
clusterOnClick(cluster)}> + + {counts.map((count, i) => { + const start = (offsets[i] ?? 0) / total + let end = ((offsets[i] ?? 0) + count) / total - if (end - start === 1) end -= 0.00001; - const a0 = 2 * Math.PI * (start - 0.25); - const a1 = 2 * Math.PI * (end - 0.25); - const x0 = Math.cos(a0), - y0 = Math.sin(a0); - const x1 = Math.cos(a1), - y1 = Math.sin(a1); - const largeArc = end - start > 0.5 ? 1 : 0; + if (end - start === 1) end -= 0.00001 + const a0 = 2 * Math.PI * (start - 0.25) + const a1 = 2 * Math.PI * (end - 0.25) + const x0 = Math.cos(a0), + y0 = Math.sin(a0) + const x1 = Math.cos(a1), + y1 = Math.sin(a1) + const largeArc = end - start > 0.5 ? 1 : 0 - return ( - - ); - })} - - - {pointCount} - - -
- ); + return ( + + ) + })} + + + {pointCount} + + +
+ ) } diff --git a/app/components/map/layers/mobile/mobile-box-layer.tsx b/app/components/map/layers/mobile/mobile-box-layer.tsx index 03d1181b..30a29f5b 100644 --- a/app/components/map/layers/mobile/mobile-box-layer.tsx +++ b/app/components/map/layers/mobile/mobile-box-layer.tsx @@ -1,208 +1,210 @@ -import bbox from "@turf/bbox"; +import bbox from '@turf/bbox' import { - featureCollection, - lineString, - multiLineString, - point, -} from "@turf/helpers"; -import { type MultiLineString, type Point } from "geojson"; -import mapboxgl from "mapbox-gl"; -import { createContext, useContext, useEffect, useRef, useState } from "react"; -import { Layer, Source, useMap } from "react-map-gl"; -import { HIGH_COLOR, LOW_COLOR, createPalette } from "./color-palette"; -import { type Sensor } from "~/schema"; + featureCollection, + lineString, + multiLineString, + point, +} from '@turf/helpers' +import { type MultiLineString, type Point } from 'geojson' +import mapboxgl from 'mapbox-gl' +import { createContext, useContext, useEffect, useRef, useState } from 'react' +import { Layer, Source, useMap } from 'react-map-gl' +import { HIGH_COLOR, LOW_COLOR, createPalette } from './color-palette' +import { type Sensor } from '~/schema' interface CustomGeoJsonProperties { - locationId: number; - value: number; - createdAt: Date; - color: string; + locationId: number + value: number + createdAt: Date + color: string } export const HoveredPointContext = createContext({ - hoveredPoint: null, - setHoveredPoint: (_point: number | null) => {}, -}); + hoveredPoint: null, + setHoveredPoint: (_point: number | null) => {}, +}) export default function MobileBoxLayer({ - sensor, - minColor = LOW_COLOR, - maxColor = HIGH_COLOR, + sensor, + minColor = LOW_COLOR, + maxColor = HIGH_COLOR, }: { - sensor: Sensor; - minColor?: - | mapboxgl.CirclePaint["circle-color"] - | mapboxgl.LinePaint["line-color"]; - maxColor?: - | mapboxgl.CirclePaint["circle-color"] - | mapboxgl.LinePaint["line-color"]; + sensor: Sensor + minColor?: + | mapboxgl.CirclePaint['circle-color'] + | mapboxgl.LinePaint['line-color'] + maxColor?: + | mapboxgl.CirclePaint['circle-color'] + | mapboxgl.LinePaint['line-color'] }) { - const [sourceData, setSourceData] = useState(); - const { hoveredPoint, setHoveredPoint } = useContext(HoveredPointContext); - const popupRef = useRef(null); - - const { osem: mapRef } = useMap(); - - useEffect(() => { - const sensorData = sensor.data! as unknown as { - value: String; - location: { x: number; y: number; id: number }; - createdAt: Date; - }[]; - - // create color palette from min and max values - const minValue = Math.min(...sensorData.map((d) => Number(d.value))); - const maxValue = Math.max(...sensorData.map((d) => Number(d.value))); - const palette = createPalette( - minValue, - maxValue, - minColor as string, - maxColor as string, - ); - - // generate points from the sensor data - // apply color from palette - const points = sensorData.map((measurement) => { - const tempPoint = point( - [measurement.location.x, measurement.location.y], - { - value: Number(measurement.value), - createdAt: new Date(measurement.createdAt), - color: palette(Number(measurement.value)).hex(), - locationId: measurement.location.id, - }, - ); - return tempPoint; - }); - - if (points.length === 0) return; - - // generate a line from the points - const line = lineString(points.map((point) => point.geometry.coordinates)); - const lines = multiLineString([line.geometry.coordinates]); - - setSourceData( - featureCollection([...points, lines]), - ); - }, [maxColor, minColor, sensor.data]); - - useEffect(() => { - if (!mapRef || !sourceData) return; - - const bounds = bbox(sourceData).slice(0, 4) as [ - number, - number, - number, - number, - ]; - mapRef.fitBounds(bounds, { - padding: { - top: 100, - bottom: 400, - left: 500, - right: 100, - }, - }); - }, [mapRef, sourceData]); - - useEffect(() => { - if (!mapRef) return; - - const map = mapRef.getMap(); - - map.on("mousemove", "box-layer-point", (e) => { - if (!e.features || e.features.length === 0) return; - - const feature = e.features[0]; - const { locationId } = feature.properties as CustomGeoJsonProperties; - - setHoveredPoint(locationId); // Update hoveredPoint dynamically - }); - - map.on("mouseleave", "box-layer-point", () => { - setHoveredPoint(null); // Clear hoveredPoint - }); - }, [mapRef, setHoveredPoint]); - - useEffect(() => { - if (!mapRef) return; - - const map = mapRef.getMap(); - - // Cleanup previous popup - if (popupRef.current) { - popupRef.current.remove(); - popupRef.current = null; - } - - if (hoveredPoint !== null) { - const feature = sourceData?.features.find( - (feat) => feat.properties?.locationId === hoveredPoint, - ); - - if (feature && feature.geometry.type === "Point") { - const { coordinates } = feature.geometry; - const { value } = feature.properties as CustomGeoJsonProperties; - - popupRef.current = new mapboxgl.Popup({ - closeButton: false, - closeOnClick: false, - className: "highlight-popup", - }) - .setLngLat(coordinates as [number, number]) - .setHTML( - `
+ const [sourceData, setSourceData] = useState() + const { hoveredPoint, setHoveredPoint } = useContext(HoveredPointContext) + const popupRef = useRef(null) + + const { osem: mapRef } = useMap() + + useEffect(() => { + const sensorData = sensor.data! as unknown as { + value: String + location: { x: number; y: number; id: number } + createdAt: Date + }[] + + // create color palette from min and max values + const minValue = Math.min(...sensorData.map((d) => Number(d.value))) + const maxValue = Math.max(...sensorData.map((d) => Number(d.value))) + const palette = createPalette( + minValue, + maxValue, + minColor as string, + maxColor as string, + ) + + // generate points from the sensor data + // apply color from palette + const points = sensorData.map((measurement) => { + const tempPoint = point( + [measurement.location.x, measurement.location.y], + { + value: Number(measurement.value), + createdAt: new Date(measurement.createdAt), + color: palette(Number(measurement.value)).hex(), + locationId: measurement.location.id, + }, + ) + return tempPoint + }) + + if (points.length === 0) return + + // generate a line from the points + const line = lineString(points.map((point) => point.geometry.coordinates)) + const lines = multiLineString([line.geometry.coordinates]) + + setSourceData( + featureCollection([...points, lines]), + ) + }, [maxColor, minColor, sensor.data]) + + useEffect(() => { + if (!mapRef || !sourceData) return + + const bounds = bbox(sourceData).slice(0, 4) as [ + number, + number, + number, + number, + ] + mapRef.fitBounds(bounds, { + padding: { + top: 100, + bottom: 400, + left: 500, + right: 100, + }, + }) + }, [mapRef, sourceData]) + + useEffect(() => { + if (!mapRef) return + + const map = mapRef.getMap() + + map.on('mousemove', 'box-layer-point', (e) => { + if (!e.features || e.features.length === 0) return + + const feature = e.features[0] + + if (feature === undefined) return + const { locationId } = feature.properties as CustomGeoJsonProperties + + setHoveredPoint(locationId) // Update hoveredPoint dynamically + }) + + map.on('mouseleave', 'box-layer-point', () => { + setHoveredPoint(null) // Clear hoveredPoint + }) + }, [mapRef, setHoveredPoint]) + + useEffect(() => { + if (!mapRef) return + + const map = mapRef.getMap() + + // Cleanup previous popup + if (popupRef.current) { + popupRef.current.remove() + popupRef.current = null + } + + if (hoveredPoint !== null) { + const feature = sourceData?.features.find( + (feat) => feat.properties?.locationId === hoveredPoint, + ) + + if (feature && feature.geometry.type === 'Point') { + const { coordinates } = feature.geometry + const { value } = feature.properties as CustomGeoJsonProperties + + popupRef.current = new mapboxgl.Popup({ + closeButton: false, + closeOnClick: false, + className: 'highlight-popup', + }) + .setLngLat(coordinates as [number, number]) + .setHTML( + `
${sensor.title} ${value}${sensor.unit}
`, - ) - .addTo(map); - } - } else if (popupRef.current) { - (popupRef.current as mapboxgl.Popup).remove(); - popupRef.current = null; - } - }, [hoveredPoint, sourceData, mapRef, sensor.title, sensor.unit]); - - if (!sourceData) return null; - - return ( - <> - - - - - - - ); + ) + .addTo(map) + } + } else if (popupRef.current) { + ;(popupRef.current as mapboxgl.Popup).remove() + popupRef.current = null + } + }, [hoveredPoint, sourceData, mapRef, sensor.title, sensor.unit]) + + if (!sourceData) return null + + return ( + <> + + + + + + + ) } diff --git a/app/components/map/layers/mobile/mobile-overview-layer.tsx b/app/components/map/layers/mobile/mobile-overview-layer.tsx index f2259f31..885582ac 100644 --- a/app/components/map/layers/mobile/mobile-overview-layer.tsx +++ b/app/components/map/layers/mobile/mobile-overview-layer.tsx @@ -70,7 +70,7 @@ export default function MobileOverviewLayer({ const points = trips.flatMap((trip, index) => trip.points.map((location) => point([location.geometry.x, location.geometry.y], { - color: colors[index], // Assign stable color per trip + color: colors[index] ?? '#000000', // Assign stable color per trip tripNumber: index + 1, // Add trip number metadata timestamp: location.time, // Add timestamp metadata }), @@ -80,7 +80,7 @@ export default function MobileOverviewLayer({ // Set legend items for the trips const legend = trips.map((_, index) => ({ label: `Trip ${index + 1}`, - color: colors[index], + color: colors[index] ?? '#000000', })) setSourceData(featureCollection(points)) @@ -170,8 +170,8 @@ export default function MobileOverviewLayer({ if (hoveredTrip) { // Find the first point of the trip to get the coordinates (longitude, latitude) const { startTime, endTime } = hoveredTrip - const longitude = hoveredTrip.points[0].geometry.x - const latitude = hoveredTrip.points[0].geometry.y + const longitude = hoveredTrip.points[0]?.geometry.x ?? 0 + const latitude = hoveredTrip.points[0]?.geometry.y ?? 0 // Set the popupInfo state with coordinates and time range setPopupInfo({ longitude, latitude, startTime, endTime }) diff --git a/app/components/search/search-list.tsx b/app/components/search/search-list.tsx index 72da7f14..b666477b 100644 --- a/app/components/search/search-list.tsx +++ b/app/components/search/search-list.tsx @@ -36,7 +36,7 @@ export default function SearchList(props: SearchListProps) { const [searchParams] = useSearchParams() const [navigateTo, setNavigateTo] = useState( compareMode - ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}` + ? `/explore/${matches[2]?.params.deviceId}/compare/${selected.deviceId}` : selected.type === 'device' ? `/explore/${selected.deviceId + '?' + searchParams.toString()}}` : `/explore?${searchParams.toString()}`, @@ -45,7 +45,7 @@ export default function SearchList(props: SearchListProps) { const handleNavigate = useCallback( (result: any) => { return compareMode - ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}` + ? `/explore/${matches[2]?.params.deviceId}/compare/${selected.deviceId}` : result.type === 'device' ? `/explore/${result.deviceId + '?' + searchParams.toString()}` : `/explore?${searchParams.toString()}` diff --git a/app/lib/mobile-box-helper.ts b/app/lib/mobile-box-helper.ts index 4e71d71c..53496b6e 100644 --- a/app/lib/mobile-box-helper.ts +++ b/app/lib/mobile-box-helper.ts @@ -1,108 +1,112 @@ -import { getDistance } from "geolib"; +import { getDistance } from 'geolib' export interface LocationPoint { - geometry: { - x: number; - y: number; - }; - time: string; + geometry: { + x: number + y: number + } + time: string } interface Trip { - points: LocationPoint[]; - startTime: string; - endTime: string; + points: LocationPoint[] + startTime: string + endTime: string } export function categorizeIntoTrips( - dataPoints: LocationPoint[], - timeThreshold: number, // in seconds, time threshold for a new trip + dataPoints: LocationPoint[], + timeThreshold: number, // in seconds, time threshold for a new trip ): Trip[] { - const trips: Trip[] = []; - let currentTrip: LocationPoint[] = []; - - // Pre-sort data by time to ensure order - dataPoints.sort( - (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(), - ); - - for (let i = 1; i < dataPoints.length; i++) { - const previousPoint = dataPoints[i - 1]; - const currentPoint = dataPoints[i]; - - // Calculate time difference in seconds - const timeDifference = - (new Date(currentPoint.time).getTime() - new Date(previousPoint.time).getTime()) / 1000; - - // Check if a new trip should start based solely on the time difference - const isNewTrip = timeDifference > timeThreshold; - - if (isNewTrip) { - if (currentTrip.length > 0) { - trips.push({ - points: currentTrip, - startTime: currentTrip[0].time, - endTime: currentTrip[currentTrip.length - 1].time, - }); - } - currentTrip = []; - } - currentTrip.push(currentPoint); - } - - // Add the final trip - if (currentTrip.length > 0) { - trips.push({ - points: currentTrip, - startTime: currentTrip[0].time, - endTime: currentTrip[currentTrip.length - 1].time, - }); - } - - // Optionally merge small trips (can be removed if not needed) - return mergeSmallTrips(trips, timeThreshold); + const trips: Trip[] = [] + let currentTrip: LocationPoint[] = [] + + // Pre-sort data by time to ensure order + dataPoints.sort( + (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(), + ) + + for (let i = 1; i < dataPoints.length; i++) { + const previousPoint = dataPoints[i - 1]! + const currentPoint = dataPoints[i]! + + // Calculate time difference in seconds + const timeDifference = + (new Date(currentPoint.time).getTime() - + new Date(previousPoint.time).getTime()) / + 1000 + + // Check if a new trip should start based solely on the time difference + const isNewTrip = timeDifference > timeThreshold + + if (isNewTrip) { + if (currentTrip.length > 0) { + trips.push({ + points: currentTrip, + startTime: currentTrip[0]!.time, + endTime: currentTrip.at(-1)!.time, + }) + } + currentTrip = [] + } + currentTrip.push(currentPoint) + } + + // Add the final trip + if (currentTrip.length > 0) { + trips.push({ + points: currentTrip, + startTime: currentTrip[0]!.time, + endTime: currentTrip.at(-1)!.time, + }) + } + + // Optionally merge small trips (can be removed if not needed) + return mergeSmallTrips(trips, timeThreshold) } function mergeSmallTrips(trips: Trip[], timeThreshold: number): Trip[] { - if (trips.length <= 1) return trips; - - const mergedTrips: Trip[] = []; - let currentTrip: Trip | null = null; - - for (const trip of trips) { - // If a trip is too small (in terms of points or duration), merge it with the current trip - const tripDuration = (new Date(trip.endTime).getTime() - new Date(trip.startTime).getTime()) / 1000; - - if (tripDuration >= timeThreshold) { - if (currentTrip) { - mergedTrips.push(currentTrip); - currentTrip = null; - } - mergedTrips.push(trip); - } else { - if (!currentTrip) { - currentTrip = { points: [], startTime: "", endTime: "" }; - } - currentTrip.points.push(...trip.points); - - // Recompute start and end times - if (currentTrip.points.length > 0) { - currentTrip.startTime = currentTrip.points[0].time; - currentTrip.endTime = currentTrip.points[currentTrip.points.length - 1].time; - } - } - } - - // Add any remaining combined trip - if (currentTrip && currentTrip.points.length > 0) { - mergedTrips.push(currentTrip); - } - - // Post-process to sort all trips by time - return mergedTrips.map((trip) => { - trip.points.sort( - (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(), - ); - return trip; - }); + if (trips.length <= 1) return trips + + const mergedTrips: Trip[] = [] + let currentTrip: Trip | null = null + + for (const trip of trips) { + // If a trip is too small (in terms of points or duration), merge it with the current trip + const tripDuration = + (new Date(trip.endTime).getTime() - new Date(trip.startTime).getTime()) / + 1000 + + if (tripDuration >= timeThreshold) { + if (currentTrip) { + mergedTrips.push(currentTrip) + currentTrip = null + } + mergedTrips.push(trip) + } else { + if (!currentTrip) { + currentTrip = { points: [], startTime: '', endTime: '' } + } + currentTrip.points.push(...trip.points) + + // Recompute start and end times + if (currentTrip.points.length > 0) { + currentTrip.startTime = currentTrip.points[0]!.time + currentTrip.endTime = currentTrip.points.at(-1)!.time + } + } + } + + // Add any remaining combined trip + if (currentTrip && currentTrip.points.length > 0) { + mergedTrips.push(currentTrip) + } + + // Post-process to sort all trips by time + return mergedTrips.map((trip) => { + trip.points.sort( + (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(), + ) + return trip + }) } diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 9ad6a62a..cd5c826d 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -1,21 +1,25 @@ -import crypto from "node:crypto"; -import bcrypt from "bcryptjs"; -import { eq } from "drizzle-orm"; -import { createProfile } from "./profile.server"; -import { drizzleClient } from "~/db.server"; -import { type Password, type User, password as passwordTable, user } from "~/schema"; - - -export async function getUserById(id: User["id"]) { - return drizzleClient.query.user.findFirst({ - where: (user, { eq }) => eq(user.id, id), - }); +import crypto from 'node:crypto' +import bcrypt from 'bcryptjs' +import { eq } from 'drizzle-orm' +import { createProfile } from './profile.server' +import { drizzleClient } from '~/db.server' +import { + type Password, + type User, + password as passwordTable, + user, +} from '~/schema' + +export async function getUserById(id: User['id']) { + return drizzleClient.query.user.findFirst({ + where: (user, { eq }) => eq(user.id, id), + }) } -export async function getUserByEmail(email: User["email"]) { - return drizzleClient.query.user.findFirst({ - where: (user, { eq }) => eq(user.email, email), - }); +export async function getUserByEmail(email: User['email']) { + return drizzleClient.query.user.findFirst({ + where: (user, { eq }) => eq(user.email, email), + }) } // export async function getUserWithDevicesByName(name: User["name"]) { @@ -37,8 +41,8 @@ export async function getUserByEmail(email: User["email"]) { // }); // } -export async function deleteUserByEmail(email: User["email"]) { - return drizzleClient.delete(user).where(eq(user.email, email)); +export async function deleteUserByEmail(email: User['email']) { + return drizzleClient.delete(user).where(eq(user.email, email)) } //* user name shouldn't be unique @@ -46,116 +50,121 @@ export async function deleteUserByEmail(email: User["email"]) { return prisma.user.findUnique({ where: { name } }); } */ -export async function updateUserName(email: User["email"], newUserName: string){ - return drizzleClient - .update(user) - .set({ - name: newUserName, - }) - .where(eq(user.email, email)); +export async function updateUserName( + email: User['email'], + newUserName: string, +) { + return drizzleClient + .update(user) + .set({ + name: newUserName, + }) + .where(eq(user.email, email)) } export async function updateUserPassword( - userId: Password["userId"], - newPassword: string + userId: Password['userId'], + newPassword: string, ) { - const hashedPassword = await bcrypt.hash( - preparePasswordHash(newPassword), - 13, - ); - return drizzleClient - .update(passwordTable) - .set({ - hash: hashedPassword, - }) - .where(eq(passwordTable.userId, userId)); + const hashedPassword = await bcrypt.hash(preparePasswordHash(newPassword), 13) + return drizzleClient + .update(passwordTable) + .set({ + hash: hashedPassword, + }) + .where(eq(passwordTable.userId, userId)) } export async function updateUserlocale( - email: User["email"], - language: User["language"] + email: User['email'], + language: User['language'], ) { - return drizzleClient - .update(user) - .set({ - language: language, - }) - .where(eq(user.email, email)); + return drizzleClient + .update(user) + .set({ + language: language, + }) + .where(eq(user.email, email)) } export async function getUsers() { - return drizzleClient.query.user.findMany(); + return drizzleClient.query.user.findMany() } const preparePasswordHash = function preparePasswordHash( - plaintextPassword: string + plaintextPassword: string, ) { - // first round: hash plaintextPassword with sha512 - const hash = crypto.createHash("sha512"); - hash.update(plaintextPassword.toString(), "utf8"); - const hashed = hash.digest("base64"); // base64 for more entropy than hex + // first round: hash plaintextPassword with sha512 + const hash = crypto.createHash('sha512') + hash.update(plaintextPassword.toString(), 'utf8') + const hashed = hash.digest('base64') // base64 for more entropy than hex - return hashed; -}; + return hashed +} export async function createUser( - name: User["name"], - email: User["email"], - language: User["language"], - password: string + name: User['name'], + email: User['email'], + language: User['language'], + password: string, ) { - const hashedPassword = await bcrypt.hash(preparePasswordHash(password), 13); // make salt_factor configurable oSeM API uses 13 by default - - // Maybe wrap in a transaction - // https://stackoverflow.com/questions/76082778/drizzle-orm-how-do-you-insert-in-a-parent-and-child-table - const newUser = await drizzleClient - .insert(user) - .values({ - name, - email, - language, - }) - .returning(); - - await drizzleClient.insert(passwordTable).values({ - hash: hashedPassword, - userId: newUser[0].id, - }); - - await createProfile(newUser[0].id, name); - - return newUser; + const hashedPassword = await bcrypt.hash(preparePasswordHash(password), 13) // make salt_factor configurable oSeM API uses 13 by default + + // Maybe wrap in a transaction + // https://stackoverflow.com/questions/76082778/drizzle-orm-how-do-you-insert-in-a-parent-and-child-table + const newUser = await drizzleClient + .insert(user) + .values({ + name, + email, + language, + }) + .returning() + + if (newUser[0] === undefined) { + console.error('No new user was inserted!') + return null + } + + await drizzleClient.insert(passwordTable).values({ + hash: hashedPassword, + userId: newUser[0].id, + }) + + await createProfile(newUser[0].id, name) + + return newUser } export async function verifyLogin( - email: User["email"], - password: Password["hash"] + email: User['email'], + password: Password['hash'], ) { - const userWithPassword = await drizzleClient.query.user.findFirst({ - where: (user, { eq }) => eq(user.email, email), - with: { - profile: true, - password: true, - }, - }); - - if (!userWithPassword || !userWithPassword.password) { - return null; - } - - //* compare stored password with entered one - const isValid = await bcrypt.compare( - preparePasswordHash(password), - userWithPassword.password.hash - ); - - if (!isValid) { - return null; - } - - //* exclude password property (using spread operator) - //* const userWithoutPassword: {id: string; email: string;createdAt: Date; updatedAt: Date;} - const { password: _password, ...userWithoutPassword } = userWithPassword; - - return userWithoutPassword; + const userWithPassword = await drizzleClient.query.user.findFirst({ + where: (user, { eq }) => eq(user.email, email), + with: { + profile: true, + password: true, + }, + }) + + if (!userWithPassword || !userWithPassword.password) { + return null + } + + //* compare stored password with entered one + const isValid = await bcrypt.compare( + preparePasswordHash(password), + userWithPassword.password.hash, + ) + + if (!isValid) { + return null + } + + //* exclude password property (using spread operator) + //* const userWithoutPassword: {id: string; email: string;createdAt: Date; updatedAt: Date;} + const { password: _password, ...userWithoutPassword } = userWithPassword + + return userWithoutPassword } diff --git a/app/routes/device.$deviceId.edit.mqtt.tsx b/app/routes/device.$deviceId.edit.mqtt.tsx index 495dadbb..13459aa5 100644 --- a/app/routes/device.$deviceId.edit.mqtt.tsx +++ b/app/routes/device.$deviceId.edit.mqtt.tsx @@ -1,309 +1,316 @@ -import { Save } from "lucide-react"; -import React, { useState } from "react"; -import { data, redirect , Form, useActionData, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import ErrorMessage from "~/components/error-message"; -import { toast } from "~/components/ui/use-toast"; -import { checkMqttValidaty } from "~/models/mqtt.server"; -import { getUserId } from "~/utils/session.server"; +import { Save } from 'lucide-react' +import React, { useState } from 'react' +import { + data, + redirect, + Form, + useActionData, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import ErrorMessage from '~/components/error-message' +import { toast } from '~/components/ui/use-toast' +import { checkMqttValidaty } from '~/models/mqtt.server' +import { getUserId } from '~/utils/session.server' //***************************************************** export async function loader({ request }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home - const userId = await getUserId(request); - if (!userId) return redirect("/"); + //* if user is not logged in, redirect to home + const userId = await getUserId(request) + if (!userId) return redirect('/') - return ""; + return '' } //***************************************************** export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData(); - const { enableMQTTcb, mqttURL, mqttTopic } = Object.fromEntries(formData); + const formData = await request.formData() + const { enableMQTTcb, mqttURL, mqttTopic } = Object.fromEntries(formData) - //* ToDo: if mqtt checkbox is not enabled, reset mqtt to default - if (!enableMQTTcb) { - return data({ - errors: { - mqttURL: null, - mqttTopic: null, - }, - reset: true, - isMqttValid: null, - status: 200, - }); - } + //* ToDo: if mqtt checkbox is not enabled, reset mqtt to default + if (!enableMQTTcb) { + return data({ + errors: { + mqttURL: null, + mqttTopic: null, + }, + reset: true, + isMqttValid: null, + status: 200, + }) + } - const errors = { - mqttURL: mqttURL ? null : "Invalid URL (please use ws or wss URL)", - mqttTopic: mqttTopic ? null : "Invalid mqtt topic", - }; - const hasErrors = Object.values(errors).some((errorMessage) => errorMessage); + const errors = { + mqttURL: mqttURL ? null : 'Invalid URL (please use ws or wss URL)', + mqttTopic: mqttTopic ? null : 'Invalid mqtt topic', + } + const hasErrors = Object.values(errors).some((errorMessage) => errorMessage) - if (hasErrors) { - return data({ - errors: errors, - reset: false, - isMqttValid: null, - status: 400, - }); - } + if (hasErrors) { + return data({ + errors: errors, + reset: false, + isMqttValid: null, + status: 400, + }) + } - //* check mqtt connection validity - const isMqttValid = await checkMqttValidaty(mqttURL.toString()); + //* check mqtt connection validity + const isMqttValid = await checkMqttValidaty(mqttURL?.toString() ?? '') - return data({ - errors: errors, - reset: false, - isMqttValid: isMqttValid, - status: 200, - }); + return data({ + errors: errors, + reset: false, + isMqttValid: isMqttValid, + status: 200, + }) } //********************************** export default function EditBoxMQTT() { - const [mqttEnabled, setMqttEnabled] = useState(false); - const [mqttValid, setMqttValid] = useState(true); - const actionData = useActionData(); + const [mqttEnabled, setMqttEnabled] = useState(false) + const [mqttValid, setMqttValid] = useState(true) + const actionData = useActionData() - const mqttURLRef = React.useRef(null); - const mqttTopicRef = React.useRef(null); + const mqttURLRef = React.useRef(null) + const mqttTopicRef = React.useRef(null) - React.useEffect(() => { - if (actionData) { - const hasErrors = Object.values(actionData?.errors).some( - (errorMessage) => errorMessage, - ); + React.useEffect(() => { + if (actionData) { + const hasErrors = Object.values(actionData?.errors).some( + (errorMessage) => errorMessage, + ) - // ToDo - if (actionData.reset) { - // Do nothing for now - } else if (!hasErrors) { - if (actionData.isMqttValid) { - setMqttValid(true); - //* show conn. success msg - toast({ - description: "Successfully connected to mqtt url!", - }); - } else { - setMqttValid(false); - mqttURLRef.current?.focus(); - } - } else if (hasErrors && actionData?.errors?.mqttURL) { - mqttURLRef.current?.focus(); - } else if (hasErrors && actionData?.errors?.mqttTopic) { - mqttTopicRef.current?.focus(); - } - } - }, [actionData]); + // ToDo + if (actionData.reset) { + // Do nothing for now + } else if (!hasErrors) { + if (actionData.isMqttValid) { + setMqttValid(true) + //* show conn. success msg + toast({ + description: 'Successfully connected to mqtt url!', + }) + } else { + setMqttValid(false) + mqttURLRef.current?.focus() + } + } else if (hasErrors && actionData?.errors?.mqttURL) { + mqttURLRef.current?.focus() + } else if (hasErrors && actionData?.errors?.mqttTopic) { + mqttTopicRef.current?.focus() + } + } + }, [actionData]) - return ( -
-
-
- {/* Form */} -
- {/* Heading */} -
- {/* Title */} -
-
-

MQTT

-
-
- {/* Save button */} - -
-
-
+ return ( +
+
+
+ {/* Form */} + + {/* Heading */} +
+ {/* Title */} +
+
+

MQTT

+
+
+ {/* Save button */} + +
+
+
- {/* divider */} -
+ {/* divider */} +
-
-

- openSenseMap offers a{" "} - - MQTT{" "} - {" "} - client for connecting to public brokers. Documentation for the - parameters is provided{" "} - - in the docs.{" "} - - Please note that it's only possible to receive measurements - through MQTT. -

-
+
+

+ openSenseMap offers a{' '} + + MQTT{' '} + {' '} + client for connecting to public brokers. Documentation for the + parameters is provided{' '} + + in the docs.{' '} + + Please note that it's only possible to receive measurements + through MQTT. +

+
-
- setMqttEnabled(!mqttEnabled)} - /> - -
+
+ setMqttEnabled(!mqttEnabled)} + /> + +
- {/* MQTT URL */} -
- + {/* MQTT URL */} +
+ -
- - {actionData?.errors?.mqttURL && ( -
- {actionData.errors.mqttURL} -
- )} +
+ + {actionData?.errors?.mqttURL && ( +
+ {actionData.errors.mqttURL} +
+ )} - {!mqttValid && ( -
- Entered mqtt url is not valid, please try again with a valid - one. -
- )} -
-
+ {!mqttValid && ( +
+ Entered mqtt url is not valid, please try again with a valid + one. +
+ )} +
+
- {/* MQTT Topic */} -
- + {/* MQTT Topic */} +
+ -
- - {actionData?.errors?.mqttTopic && ( -
- {actionData.errors.mqttTopic} -
- )} -
-
+
+ + {actionData?.errors?.mqttTopic && ( +
+ {actionData.errors.mqttTopic} +
+ )} +
+
- {/* MQTT Message format */} -
- -
- -
- - -
-
- - -
-
-
-
+ {/* MQTT Message format */} +
+ +
+ +
+ + +
+
+ + +
+
+
+
- {/* MQTT Decoding options */} -
- + {/* MQTT Decoding options */} +
+ -
- -
-
+
+ +
+
- {/* MQTT Decoding options */} -
- + {/* MQTT Decoding options */} +
+ -
- -
-
- -
-
-
- ); +
+ +
+
+ +
+
+
+ ) } export function ErrorBoundary() { - return ( -
- -
- ); + return ( +
+ +
+ ) } diff --git a/app/routes/device.dashboard.$deviceId.tsx b/app/routes/device.dashboard.$deviceId.tsx index 804aa50a..b49be495 100644 --- a/app/routes/device.dashboard.$deviceId.tsx +++ b/app/routes/device.dashboard.$deviceId.tsx @@ -1,250 +1,253 @@ -import mapboxgl from "mapbox-gl/dist/mapbox-gl.css?url"; -import moment from "moment"; -import { Map, MapProvider, Marker } from "react-map-gl"; -import { type LinksFunction } from "react-router"; -import { NavBar } from "~/components/nav-bar"; -import { Badge } from "~/components/ui/badge"; +import mapboxgl from 'mapbox-gl/dist/mapbox-gl.css?url' +import moment from 'moment' +import { Map, MapProvider, Marker } from 'react-map-gl' +import { type LinksFunction } from 'react-router' +import { NavBar } from '~/components/nav-bar' +import { Badge } from '~/components/ui/badge' import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; -import { diffFromCreateDate, getMinuteFormattedString } from "~/utils"; + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '~/components/ui/card' +import { diffFromCreateDate, getMinuteFormattedString } from '~/utils' let deviceData = { - _id: "5b411d0e5dc1ec001b4f11c8", - createdAt: "2022-03-30T11:25:43.557Z", - updatedAt: "2023-10-26T06:28:23.033Z", - name: "Bahnhofstraße", - currentLocation: { - timestamp: "2018-07-07T20:05:34.723Z", - coordinates: [7.478471, 52.083515, 3.65], - type: "Point", - }, - exposure: "outdoor", - sensors: [ - { - title: "PM10", - unit: "µg/m³", - sensorType: "SDS 011", - icon: "osem-cloud", - _id: "5b411d0e5dc1ec001b4f11cc", - lastMeasurement: { - value: "23.73", - createdAt: "2023-10-13T06:28:23.027Z", - }, - }, - { - title: "PM2.5", - unit: "µg/m³", - sensorType: "SDS 011", - icon: "osem-cloud", - _id: "5b411d0e5dc1ec001b4f11cb", - lastMeasurement: { - value: "20.43", - createdAt: "2023-10-13T06:28:23.027Z", - }, - }, - { - title: "Temperatur", - unit: "°C", - sensorType: "DHT22", - icon: "osem-thermometer", - _id: "5b411d0e5dc1ec001b4f11ca", - lastMeasurement: { - value: "20.80", - createdAt: "2022-07-12T07:02:41.061Z", - }, - }, - { - title: "rel. Luftfeuchte", - unit: "%", - sensorType: "DHT22", - icon: "osem-humidity", - _id: "5b411d0e5dc1ec001b4f11c9", - lastMeasurement: { - value: "99.90", - createdAt: "2022-07-12T07:02:41.061Z", - }, - }, - ], - model: "luftdaten_sds011_dht22", - description: - "Mounted at the street side of my house. Traffic: approx. 8.000 vehicles/d", - image: "5b411d0e5dc1ec001b4f11c8_pblauf.jpg", - lastMeasurementAt: "2023-10-13T06:28:23.027Z", - grouptag: [""], - loc: [ - { - geometry: { - timestamp: "2018-07-07T20:05:34.723Z", - coordinates: [7.478471, 52.083515, 3.65], - type: "Point", - }, - type: "Feature", - }, - ], -}; + _id: '5b411d0e5dc1ec001b4f11c8', + createdAt: '2022-03-30T11:25:43.557Z', + updatedAt: '2023-10-26T06:28:23.033Z', + name: 'Bahnhofstraße', + currentLocation: { + timestamp: '2018-07-07T20:05:34.723Z', + coordinates: [7.478471, 52.083515, 3.65], + type: 'Point', + }, + exposure: 'outdoor', + sensors: [ + { + title: 'PM10', + unit: 'µg/m³', + sensorType: 'SDS 011', + icon: 'osem-cloud', + _id: '5b411d0e5dc1ec001b4f11cc', + lastMeasurement: { + value: '23.73', + createdAt: '2023-10-13T06:28:23.027Z', + }, + }, + { + title: 'PM2.5', + unit: 'µg/m³', + sensorType: 'SDS 011', + icon: 'osem-cloud', + _id: '5b411d0e5dc1ec001b4f11cb', + lastMeasurement: { + value: '20.43', + createdAt: '2023-10-13T06:28:23.027Z', + }, + }, + { + title: 'Temperatur', + unit: '°C', + sensorType: 'DHT22', + icon: 'osem-thermometer', + _id: '5b411d0e5dc1ec001b4f11ca', + lastMeasurement: { + value: '20.80', + createdAt: '2022-07-12T07:02:41.061Z', + }, + }, + { + title: 'rel. Luftfeuchte', + unit: '%', + sensorType: 'DHT22', + icon: 'osem-humidity', + _id: '5b411d0e5dc1ec001b4f11c9', + lastMeasurement: { + value: '99.90', + createdAt: '2022-07-12T07:02:41.061Z', + }, + }, + ], + model: 'luftdaten_sds011_dht22', + description: + 'Mounted at the street side of my house. Traffic: approx. 8.000 vehicles/d', + image: '5b411d0e5dc1ec001b4f11c8_pblauf.jpg', + lastMeasurementAt: '2023-10-13T06:28:23.027Z', + grouptag: [''], + loc: [ + { + geometry: { + timestamp: '2018-07-07T20:05:34.723Z', + coordinates: [7.478471, 52.083515, 3.65], + type: 'Point', + }, + type: 'Feature', + }, + ], +} //***************************************** //* required to view mapbox proberly (Y.Q.) export const links: LinksFunction = () => { - return [ - { - rel: "stylesheet", - href: mapboxgl, - }, - ]; -}; + return [ + { + rel: 'stylesheet', + href: mapboxgl, + }, + ] +} //********************************** export default function DeviceDashboard() { - //* map marker - const marker = { - longitude: deviceData.currentLocation.coordinates[0], - latitude: deviceData.currentLocation.coordinates[1], - }; + //* map marker + const marker = { + longitude: deviceData.currentLocation.coordinates[0] ?? 0, + latitude: deviceData.currentLocation.coordinates[1] ?? 0, + } - return ( -
- + return ( +
+ - {/* Left side - device info */} -
- - - {deviceData.name} - {deviceData._id} - - - {/* properties */} -
- - {deviceData.exposure.toUpperCase()} - + {/* Left side - device info */} +
+ + + {deviceData.name} + {deviceData._id} + + + {/* properties */} +
+ + {deviceData.exposure.toUpperCase()} + - {`${deviceData.sensors.length} SENSOR(S)`} + {`${deviceData.sensors.length} SENSOR(S)`} - - {/* Created {deviceData.createdAt.slice(0, 10)} */} - {diffFromCreateDate(deviceData.createdAt)} - + + {/* Created {deviceData.createdAt.slice(0, 10)} */} + {diffFromCreateDate(deviceData.createdAt)} + - {moment().diff(moment(deviceData.updatedAt), "days") > 3 ? ( - - INACTIVE - - ) : ( - - ACTIVE - - )} -
+ {moment().diff(moment(deviceData.updatedAt), 'days') > 3 ? ( + + INACTIVE + + ) : ( + + ACTIVE + + )} +
- {/* image */} -
-
- {"name"} -
-

- Mounted at the street side of my house. Traffic: approx. 8.000 - vehicles/d -

-
+ {/* image */} +
+
+ {'name'} +
+

+ Mounted at the street side of my house. Traffic: approx. 8.000 + vehicles/d +

+
- {/* Map view */} -
- - - - - -
- - + {/* Map view */} +
+ + + + + +
+ + - {/* Right side - measurements */} - - -
- {deviceData.sensors.map((sensor: any) => ( - - - - - {sensor.lastMeasurement.value} {sensor.unit} - - - - {sensor.title}{" "} - - - {sensor.sensorType} - - - - -
- {/*

5 minutes ago

*/} - {sensor.lastMeasurement?.createdAt === undefined || - moment().diff( - moment(sensor.lastMeasurement.createdAt), - "hours", - ) > 25 ? ( -

- INACTIVE -

- ) : ( -

- {getMinuteFormattedString( - sensor.lastMeasurement.createdAt, - )} -

- )} -
-
- ))} -
-
-
-
-
- ); + {/* Right side - measurements */} + + +
+ {deviceData.sensors.map((sensor: any) => ( + + + + + {sensor.lastMeasurement.value} {sensor.unit} + + + + {sensor.title}{' '} + + + {sensor.sensorType} + + + + +
+ {/*

5 minutes ago

*/} + {sensor.lastMeasurement?.createdAt === undefined || + moment().diff( + moment(sensor.lastMeasurement.createdAt), + 'hours', + ) > 25 ? ( +

+ INACTIVE +

+ ) : ( +

+ {getMinuteFormattedString( + sensor.lastMeasurement.createdAt, + )} +

+ )} +
+
+ ))} +
+
+
+
+
+ ) } diff --git a/app/routes/explore.$deviceId.$sensorId.$.tsx b/app/routes/explore.$deviceId.$sensorId.$.tsx index 0a4ccbc6..4c161fcf 100644 --- a/app/routes/explore.$deviceId.$sensorId.$.tsx +++ b/app/routes/explore.$deviceId.$sensorId.$.tsx @@ -79,8 +79,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { // Calculate the time range of the latest 5 trips const latestTripTimeRange = { - startTime: latestTrips[0].startTime, - endTime: latestTrips[latestTrips.length - 1].endTime, + startTime: latestTrips[0]?.startTime ?? 0, + endTime: latestTrips.at(-1)?.endTime ?? 0, } // Filter sensor data to include only the points within the time range of the latest 5 trips @@ -155,8 +155,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { // Calculate the time range of the latest 5 trips const latestTripTimeRange = { - startTime: latestTrips[0].startTime, - endTime: latestTrips[latestTrips.length - 1].endTime, + startTime: latestTrips[0]?.startTime ?? 0, + endTime: latestTrips.at(-1)?.endTime ?? 0, } // Filter sensor data to include only the points within the time range of the latest 5 trips diff --git a/app/routes/explore.$deviceId.tsx b/app/routes/explore.$deviceId.tsx index 81b0dd8c..386680d9 100644 --- a/app/routes/explore.$deviceId.tsx +++ b/app/routes/explore.$deviceId.tsx @@ -44,12 +44,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const filteredLocations = categorizeIntoTrips(formattedLocations, 60) // 60 seconds as time threshold // get the last time of the 5th trip - const lastTime = - filteredLocations[4]?.points[filteredLocations[4].points.length - 1]?.time + const lastTime = filteredLocations[4]?.points.at(-1)?.time // cut all locations from the device to the last time of the 5th trip const cutLocations = device.locations.filter((location) => { const locationTime = String(location.time) // Ensure time is treated as a string - return locationTime <= lastTime + return lastTime ? locationTime <= lastTime : false }) // set the locations to the device device.locations = cutLocations @@ -79,9 +78,7 @@ export default function DeviceId() { // Retrieving the data returned by the loader using the useLoaderData hook const data = useLoaderData() const matches = useMatches() - const isSensorView = matches[matches.length - 1].params.sensorId - ? true - : false + const isSensorView = matches.at(-1)?.params.sensorId ? true : false const [hoveredPoint, setHoveredPoint] = useState(null) const setHoveredPointDebug = (point: any) => { diff --git a/app/routes/explore.register.tsx b/app/routes/explore.register.tsx index 9a5cee83..1d297be8 100644 --- a/app/routes/explore.register.tsx +++ b/app/routes/explore.register.tsx @@ -1,253 +1,264 @@ -import i18next from "app/i18next.server"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction, data, redirect , Form, Link, useActionData, useNavigation, useSearchParams } from "react-router"; -import invariant from "tiny-invariant"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import ErrorMessage from "~/components/error-message"; -import Spinner from "~/components/spinner"; -import { Button } from "~/components/ui/button"; +import i18next from 'app/i18next.server' +import * as React from 'react' +import { useTranslation } from 'react-i18next' import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "~/components/ui/card"; -import { createUser, getUserByEmail } from "~/models/user.server"; -import { safeRedirect, validateEmail, validateName } from "~/utils"; -import { createUserSession, getUserId } from "~/utils/session.server"; + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, + data, + redirect, + Form, + Link, + useActionData, + useNavigation, + useSearchParams, +} from 'react-router' +import invariant from 'tiny-invariant' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import ErrorMessage from '~/components/error-message' +import Spinner from '~/components/spinner' +import { Button } from '~/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '~/components/ui/card' +import { createUser, getUserByEmail } from '~/models/user.server' +import { safeRedirect, validateEmail, validateName } from '~/utils' +import { createUserSession, getUserId } from '~/utils/session.server' export async function loader({ request }: LoaderFunctionArgs) { - const userId = await getUserId(request); - if (userId) return redirect("/"); - return {}; + const userId = await getUserId(request) + if (userId) return redirect('/') + return {} } export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData(); - const { username, email, password } = Object.fromEntries(formData); - const redirectTo = safeRedirect(formData.get("redirectTo"), "/explore"); - - if (!username || typeof username !== "string") { - return data( - { - errors: { - username: "UserName is required", - email: null, - password: null, - }, - }, - { status: 400 }, - ); - } - - //* Validate userName - const validateUserName = validateName(username?.toString()); - if (!validateUserName.isValid) { - return data( - { - errors: { - username: validateUserName.errorMsg, - password: null, - email: null, - }, - }, - { status: 400 }, - ); - } - - if (!validateEmail(email)) { - return data( - { errors: { username: null, email: "Email is invalid", password: null } }, - { status: 400 }, - ); - } - - if (typeof password !== "string" || password.length === 0) { - return data( - { - errors: { - username: null, - password: "Password is required", - email: null, - }, - }, - { status: 400 }, - ); - } - - if (password.length < 8) { - return data( - { - errors: { - username: null, - password: "Password is too short", - email: null, - }, - }, - { status: 400 }, - ); - } - - //* check if user exists by email - const existingUserByEmail = await getUserByEmail(email); - if (existingUserByEmail) { - return data( - { - errors: { - username: null, - email: "A user already exists with this email", - password: null, - }, - }, - { status: 400 }, - ); - } - - invariant(typeof username === "string", "username must be a string"); - - //* get current locale - const locale = await i18next.getLocale(request); - const language = locale === "de" ? "de_DE" : "en_US"; - - //* temp -> dummy name - // const name = "Max Mustermann"; - - const user = await createUser(username, email, language, password); - // const user = await createUser(email, password, username?.toString()); - - return createUserSession({ - request, - userId: user[0].id, - remember: false, - redirectTo, - }); + const formData = await request.formData() + const { username, email, password } = Object.fromEntries(formData) + const redirectTo = safeRedirect(formData.get('redirectTo'), '/explore') + + if (!username || typeof username !== 'string') { + return data( + { + errors: { + username: 'UserName is required', + email: null, + password: null, + }, + }, + { status: 400 }, + ) + } + + //* Validate userName + const validateUserName = validateName(username?.toString()) + if (!validateUserName.isValid) { + return data( + { + errors: { + username: validateUserName.errorMsg, + password: null, + email: null, + }, + }, + { status: 400 }, + ) + } + + if (!validateEmail(email)) { + return data( + { errors: { username: null, email: 'Email is invalid', password: null } }, + { status: 400 }, + ) + } + + if (typeof password !== 'string' || password.length === 0) { + return data( + { + errors: { + username: null, + password: 'Password is required', + email: null, + }, + }, + { status: 400 }, + ) + } + + if (password.length < 8) { + return data( + { + errors: { + username: null, + password: 'Password is too short', + email: null, + }, + }, + { status: 400 }, + ) + } + + //* check if user exists by email + const existingUserByEmail = await getUserByEmail(email) + if (existingUserByEmail) { + return data( + { + errors: { + username: null, + email: 'A user already exists with this email', + password: null, + }, + }, + { status: 400 }, + ) + } + + invariant(typeof username === 'string', 'username must be a string') + + //* get current locale + const locale = await i18next.getLocale(request) + const language = locale === 'de' ? 'de_DE' : 'en_US' + + //* temp -> dummy name + // const name = "Max Mustermann"; + + const user = await createUser(username, email, language, password) + // const user = await createUser(email, password, username?.toString()); + + return createUserSession({ + request, + userId: user && user[0] ? user[0].id : '', + remember: false, + redirectTo, + }) } export const meta: MetaFunction = () => { - return [{ title: "Explore" }]; -}; + return [{ title: 'Explore' }] +} export default function RegisterDialog() { - const { t } = useTranslation("register"); - const navigation = useNavigation(); - const [searchParams] = useSearchParams(); - const actionData = useActionData(); - const usernameRef = React.useRef(null); - const emailRef = React.useRef(null); - const passwordRef = React.useRef(null); - - React.useEffect(() => { - if (actionData?.errors?.username) { - usernameRef.current?.focus(); - } else if (actionData?.errors?.email) { - emailRef.current?.focus(); - } else if (actionData?.errors?.password) { - passwordRef.current?.focus(); - } - }, [actionData]); - - return ( -
- -
- - - {navigation.state === "loading" && ( -
- -
- )} -
- - Register - - Create a new account to get started. - - - -
- - - {actionData?.errors?.username && ( -
- {actionData.errors.username} -
- )} -
-
- - - {actionData?.errors?.email && ( -
- {actionData.errors.email} -
- )} -
-
- - - {actionData?.errors?.password && ( -
- {actionData.errors.password} -
- )} -
-
- - -
- {t("already_account_label")}{" "} - - {t("login_label")} - -
-
-
-
-
- ); + const { t } = useTranslation('register') + const navigation = useNavigation() + const [searchParams] = useSearchParams() + const actionData = useActionData() + const usernameRef = React.useRef(null) + const emailRef = React.useRef(null) + const passwordRef = React.useRef(null) + + React.useEffect(() => { + if (actionData?.errors?.username) { + usernameRef.current?.focus() + } else if (actionData?.errors?.email) { + emailRef.current?.focus() + } else if (actionData?.errors?.password) { + passwordRef.current?.focus() + } + }, [actionData]) + + return ( +
+ +
+ + + {navigation.state === 'loading' && ( +
+ +
+ )} +
+ + Register + + Create a new account to get started. + + + +
+ + + {actionData?.errors?.username && ( +
+ {actionData.errors.username} +
+ )} +
+
+ + + {actionData?.errors?.email && ( +
+ {actionData.errors.email} +
+ )} +
+
+ + + {actionData?.errors?.password && ( +
+ {actionData.errors.password} +
+ )} +
+
+ + +
+ {t('already_account_label')}{' '} + + {t('login_label')} + +
+
+
+
+
+ ) } export function ErrorBoundary() { - return ( -
- -
- ); + return ( +
+ +
+ ) } diff --git a/app/routes/explore.tsx b/app/routes/explore.tsx index ce02abeb..08f2a423 100644 --- a/app/routes/explore.tsx +++ b/app/routes/explore.tsx @@ -1,345 +1,350 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { type FeatureCollection, type Point } from "geojson"; -import mapboxglcss from "mapbox-gl/dist/mapbox-gl.css?url"; -import { useState, useRef, useEffect } from "react"; -import { type MapLayerMouseEvent, type MapRef, MapProvider, Layer, Source, Marker } from "react-map-gl"; +import { type FeatureCollection, type Point } from 'geojson' +import mapboxglcss from 'mapbox-gl/dist/mapbox-gl.css?url' +import { useState, useRef, useEffect } from 'react' import { - Outlet, - useNavigate, - useSearchParams, - useLoaderData, - useParams, - redirect, type LoaderFunctionArgs, type LinksFunction -} from "react-router"; -import type Supercluster from "supercluster"; -import ErrorMessage from "~/components/error-message"; -import Header from "~/components/header"; -import Map from "~/components/map"; -import { phenomenonLayers, defaultLayer } from "~/components/map/layers"; -import BoxMarker from "~/components/map/layers/cluster/box-marker"; -import ClusterLayer from "~/components/map/layers/cluster/cluster-layer"; -import Legend, { type LegendValue } from "~/components/map/legend"; -import { getDevices, getDevicesWithSensors } from "~/models/device.server"; -import { getPhenomena } from "~/models/phenomena.server"; -import { getProfileByUserId } from "~/models/profile.server"; -import { type Device, type Sensor } from "~/schema"; -import { getFilteredDevices } from "~/utils"; -import { getUser, getUserSession } from "~/utils/session.server"; - - + type MapLayerMouseEvent, + type MapRef, + MapProvider, + Layer, + Source, + Marker, + CircleLayer, +} from 'react-map-gl' +import { + Outlet, + useNavigate, + useSearchParams, + useLoaderData, + useParams, + redirect, + type LoaderFunctionArgs, + type LinksFunction, +} from 'react-router' +import type Supercluster from 'supercluster' +import ErrorMessage from '~/components/error-message' +import Header from '~/components/header' +import Map from '~/components/map' +import { phenomenonLayers, defaultLayer } from '~/components/map/layers' +import BoxMarker from '~/components/map/layers/cluster/box-marker' +import ClusterLayer from '~/components/map/layers/cluster/cluster-layer' +import Legend, { type LegendValue } from '~/components/map/legend' +import { getDevices, getDevicesWithSensors } from '~/models/device.server' +import { getPhenomena } from '~/models/phenomena.server' +import { getProfileByUserId } from '~/models/profile.server' +import { type Device, type Sensor } from '~/schema' +import { getFilteredDevices } from '~/utils' +import { getUser, getUserSession } from '~/utils/session.server' export type DeviceClusterProperties = - | Supercluster.PointFeature - | Supercluster.PointFeature< - Supercluster.ClusterProperties & { - categories: { - [x: number]: number; - }; - } - >; + | Supercluster.PointFeature + | Supercluster.PointFeature< + Supercluster.ClusterProperties & { + categories: { + [x: number]: number + } + } + > export async function loader({ request }: LoaderFunctionArgs) { - //* Get filter params - const url = new URL(request.url); - const filterParams = url.search; - const urlFilterParams = new URLSearchParams(url.search); + //* Get filter params + const url = new URL(request.url) + const filterParams = url.search + const urlFilterParams = new URLSearchParams(url.search) - // check if sensors are queried - if not get devices only to reduce load - const devices = !urlFilterParams.get("phenomenon") - ? await getDevices() - : await getDevicesWithSensors(); + // check if sensors are queried - if not get devices only to reduce load + const devices = !urlFilterParams.get('phenomenon') + ? await getDevices() + : await getDevicesWithSensors() - const session = await getUserSession(request); - const message = session.get("global_message") || null; + const session = await getUserSession(request) + const message = session.get('global_message') || null - var filteredDevices = getFilteredDevices(devices, urlFilterParams); + var filteredDevices = getFilteredDevices(devices, urlFilterParams) - const user = await getUser(request); - //const phenomena = await getPhenomena(); + const user = await getUser(request) + //const phenomena = await getPhenomena(); - if (user) { - const profile = await getProfileByUserId(user.id); - return { - devices, - user, - profile, - filteredDevices, - filterParams, - //phenomena - }; - } - return { - devices, - user, - profile: null, - filterParams, - filteredDevices, - message, - //phenomena, - }; + if (user) { + const profile = await getProfileByUserId(user.id) + return { + devices, + user, + profile, + filteredDevices, + filterParams, + //phenomena + } + } + return { + devices, + user, + profile: null, + filterParams, + filteredDevices, + message, + //phenomena, + } } export const links: LinksFunction = () => { - return [ - { - rel: "stylesheet", - href: mapboxglcss, - }, - ]; -}; + return [ + { + rel: 'stylesheet', + href: mapboxglcss, + }, + ] +} // This is for the live data display. The 21-06-2023 works with the seed Data, for Production take now minus 10 minutes -let currentDate = new Date("2023-06-21T14:13:11.024Z"); -if (process.env.NODE_ENV === "production") { - currentDate = new Date(Date.now() - 1000 * 600); +let currentDate = new Date('2023-06-21T14:13:11.024Z') +if (process.env.NODE_ENV === 'production') { + currentDate = new Date(Date.now() - 1000 * 600) } export default function Explore() { - // data from our loader - const { devices, user, profile, filterParams, filteredDevices, message } = - useLoaderData(); + // data from our loader + const { devices, user, profile, filterParams, filteredDevices, message } = + useLoaderData() - const mapRef = useRef(null); + const mapRef = useRef(null) - // get map bounds - const [, setViewState] = useState({ - longitude: 7.628202, - latitude: 51.961563, - zoom: 2, - }); - const navigate = useNavigate(); - // const [showSearch, setShowSearch] = useState(false); - const [selectedPheno, setSelectedPheno] = useState( - undefined, - ); - const [searchParams] = useSearchParams(); - const [filteredData, setFilteredData] = useState< - GeoJSON.FeatureCollection - >({ - type: "FeatureCollection", - features: [], - }); + // get map bounds + const [, setViewState] = useState({ + longitude: 7.628202, + latitude: 51.961563, + zoom: 2, + }) + const navigate = useNavigate() + // const [showSearch, setShowSearch] = useState(false); + const [selectedPheno, setSelectedPheno] = useState(undefined) + const [searchParams] = useSearchParams() + const [filteredData, setFilteredData] = useState< + GeoJSON.FeatureCollection + >({ + type: 'FeatureCollection', + features: [], + }) - //listen to search params change - // useEffect(() => { - // //filters devices for pheno - // if (searchParams.has("mapPheno") && searchParams.get("mapPheno") != "all") { - // let sensorsFiltered: any = []; - // let currentParam = searchParams.get("mapPheno"); - // //check if pheno exists in sensor-wiki data - // let pheno = data.phenomena.filter( - // (pheno: any) => pheno.slug == currentParam?.toString(), - // ); - // if (pheno[0]) { - // setSelectedPheno(pheno[0]); - // data.devices.features.forEach((device: any) => { - // device.properties.sensors.forEach((sensor: Sensor) => { - // if ( - // sensor.sensorWikiPhenomenon == currentParam && - // sensor.lastMeasurement - // ) { - // const lastMeasurementDate = new Date( - // //@ts-ignore - // sensor.lastMeasurement.createdAt, - // ); - // //take only measurements in the last 10mins - // //@ts-ignore - // if (currentDate < lastMeasurementDate) { - // sensorsFiltered.push({ - // ...device, - // properties: { - // ...device.properties, - // sensor: { - // ...sensor, - // lastMeasurement: { - // //@ts-ignore - // value: parseFloat(sensor.lastMeasurement.value), - // //@ts-ignore - // createdAt: sensor.lastMeasurement.createdAt, - // }, - // }, - // }, - // }); - // } - // } - // }); - // return false; - // }); - // setFilteredData({ - // type: "FeatureCollection", - // features: sensorsFiltered, - // }); - // } - // } else { - // setSelectedPheno(undefined); - // } - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [searchParams]); + //listen to search params change + // useEffect(() => { + // //filters devices for pheno + // if (searchParams.has("mapPheno") && searchParams.get("mapPheno") != "all") { + // let sensorsFiltered: any = []; + // let currentParam = searchParams.get("mapPheno"); + // //check if pheno exists in sensor-wiki data + // let pheno = data.phenomena.filter( + // (pheno: any) => pheno.slug == currentParam?.toString(), + // ); + // if (pheno[0]) { + // setSelectedPheno(pheno[0]); + // data.devices.features.forEach((device: any) => { + // device.properties.sensors.forEach((sensor: Sensor) => { + // if ( + // sensor.sensorWikiPhenomenon == currentParam && + // sensor.lastMeasurement + // ) { + // const lastMeasurementDate = new Date( + // //@ts-ignore + // sensor.lastMeasurement.createdAt, + // ); + // //take only measurements in the last 10mins + // //@ts-ignore + // if (currentDate < lastMeasurementDate) { + // sensorsFiltered.push({ + // ...device, + // properties: { + // ...device.properties, + // sensor: { + // ...sensor, + // lastMeasurement: { + // //@ts-ignore + // value: parseFloat(sensor.lastMeasurement.value), + // //@ts-ignore + // createdAt: sensor.lastMeasurement.createdAt, + // }, + // }, + // }, + // }); + // } + // } + // }); + // return false; + // }); + // setFilteredData({ + // type: "FeatureCollection", + // features: sensorsFiltered, + // }); + // } + // } else { + // setSelectedPheno(undefined); + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [searchParams]); - function calculateLabelPositions(length: number): string[] { - const positions: string[] = []; - for (let i = length - 1; i >= 0; i--) { - const position = - i === length - 1 ? "95%" : `${((i / (length - 1)) * 100).toFixed(0)}%`; - positions.push(position); - } - return positions; - } + function calculateLabelPositions(length: number): string[] { + const positions: string[] = [] + for (let i = length - 1; i >= 0; i--) { + const position = + i === length - 1 ? '95%' : `${((i / (length - 1)) * 100).toFixed(0)}%` + positions.push(position) + } + return positions + } - const legendLabels = () => { - const values = - //@ts-ignore - phenomenonLayers[selectedPheno.slug].paint["circle-color"].slice(3); - const numbers = values.filter( - (v: number | string) => typeof v === "number", - ); - const colors = values.filter((v: number | string) => typeof v === "string"); - const positions = calculateLabelPositions(numbers.length); + const legendLabels = () => { + const values = + //@ts-ignore + phenomenonLayers[selectedPheno.slug].paint['circle-color'].slice(3) + const numbers = values.filter((v: number | string) => typeof v === 'number') + const colors = values.filter((v: number | string) => typeof v === 'string') + const positions = calculateLabelPositions(numbers.length) - const legend: LegendValue[] = []; - const length = numbers.length; - for (let i = 0; i < length; i++) { - const legendObj: LegendValue = { - value: numbers[i], - color: colors[i], - position: positions[i], - }; - legend.push(legendObj); - } - return legend; - }; + const legend: LegendValue[] = [] + const length = numbers.length + for (let i = 0; i < length; i++) { + const legendObj: LegendValue = { + value: numbers[i], + color: colors[i], + position: positions[i]!, // positions will always be as long as numbers because positions are calculated from the numbers + } + legend.push(legendObj) + } + return legend + } - // // /** - // // * Focus the search input when the search overlay is displayed - // // */ - // // const focusSearchInput = () => { - // // searchRef.current?.focus(); - // // }; + // // /** + // // * Focus the search input when the search overlay is displayed + // // */ + // // const focusSearchInput = () => { + // // searchRef.current?.focus(); + // // }; - // /** - // * Display the search overlay when the ctrl + k key combination is pressed - // */ - // useHotkeys([ - // [ - // "ctrl+K", - // () => { - // setShowSearch(!showSearch); - // setTimeout(() => { - // focusSearchInput(); - // }, 100); - // }, - // ], - // ]); + // /** + // * Display the search overlay when the ctrl + k key combination is pressed + // */ + // useHotkeys([ + // [ + // "ctrl+K", + // () => { + // setShowSearch(!showSearch); + // setTimeout(() => { + // focusSearchInput(); + // }, 100); + // }, + // ], + // ]); - const onMapClick = (e: MapLayerMouseEvent) => { - if (e.features && e.features.length > 0) { - const feature = e.features[0]; + const onMapClick = (e: MapLayerMouseEvent) => { + if (e.features && e.features.length > 0) { + const feature = e.features[0] + if (feature === undefined) return - if (feature.layer.id === "phenomenon-layer") { - void navigate( - `/explore/${feature.properties?.id}?${searchParams.toString()}`, - ); - } - } - }; + if (feature.layer.id === 'phenomenon-layer') { + void navigate( + `/explore/${feature.properties?.id}?${searchParams.toString()}`, + ) + } + } + } - const handleMouseMove = (e: mapboxgl.MapLayerMouseEvent) => { - if (e.features && e.features.length > 0) { - mapRef!.current!.getCanvas().style.cursor = "pointer"; - } else { - mapRef!.current!.getCanvas().style.cursor = ""; - } - }; + const handleMouseMove = (e: mapboxgl.MapLayerMouseEvent) => { + if (e.features && e.features.length > 0) { + mapRef!.current!.getCanvas().style.cursor = 'pointer' + } else { + mapRef!.current!.getCanvas().style.cursor = '' + } + } - //* fly to sensebox location when url inludes deviceId - const { deviceId } = useParams(); - var deviceLoc: any; - let selectedDevice: any; - if (deviceId) { - selectedDevice = devices.features.find( - (device: any) => device.properties.id === deviceId, - ); - deviceLoc = [ - selectedDevice?.properties.latitude, - selectedDevice?.properties.longitude, - ]; - } + //* fly to sensebox location when url inludes deviceId + const { deviceId } = useParams() + var deviceLoc: any + let selectedDevice: any + if (deviceId) { + selectedDevice = devices.features.find( + (device: any) => device.properties.id === deviceId, + ) + deviceLoc = [ + selectedDevice?.properties.latitude, + selectedDevice?.properties.longitude, + ] + } - const buildLayerFromPheno = (selectedPheno: any) => { - //TODO: ADD VALUES TO DEFAULTLAYER FROM selectedPheno.ROV or min/max from values. - return defaultLayer; - }; + const buildLayerFromPheno = (selectedPheno: any) => { + //TODO: ADD VALUES TO DEFAULTLAYER FROM selectedPheno.ROV or min/max from values. + return defaultLayer as CircleLayer + } - return ( -
- -
- {selectedPheno && ( - - )} - setViewState(evt.viewState)} - interactiveLayerIds={selectedPheno ? ["phenomenon-layer"] : []} - onClick={onMapClick} - onMouseMove={handleMouseMove} - ref={mapRef} - initialViewState={ - deviceId - ? { latitude: deviceLoc[0], longitude: deviceLoc[1], zoom: 10 } - : { latitude: 7, longitude: 52, zoom: 2 } - } - > - {!selectedPheno && ( - } - /> - )} - {selectedPheno && ( - } - cluster={false} - > - - - )} + return ( +
+ +
+ {selectedPheno && ( + + )} + setViewState(evt.viewState)} + interactiveLayerIds={selectedPheno ? ['phenomenon-layer'] : []} + onClick={onMapClick} + onMouseMove={handleMouseMove} + ref={mapRef} + initialViewState={ + deviceId + ? { latitude: deviceLoc[0], longitude: deviceLoc[1], zoom: 10 } + : { latitude: 7, longitude: 52, zoom: 2 } + } + > + {!selectedPheno && ( + } + /> + )} + {selectedPheno && ( + } + cluster={false} + > + + + )} - {/* Render BoxMarker for the selected device */} - {selectedDevice && deviceId && ( - - - - )} + {/* Render BoxMarker for the selected device */} + {selectedDevice && deviceId && ( + + + + )} - {/* */} - - - -
- ); + +
+ +
+ ) } export function ErrorBoundary() { - return ( -
- -
- ); + return ( +
+ +
+ ) } diff --git a/app/routes/join.tsx b/app/routes/join.tsx index 9abef5bc..90dc51ba 100644 --- a/app/routes/join.tsx +++ b/app/routes/join.tsx @@ -1,259 +1,268 @@ -import i18next from "app/i18next.server"; -import * as React from "react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction, data, redirect , Form, Link, useActionData, useSearchParams } from "react-router"; - -import ErrorMessage from "~/components/error-message"; -import { getProfileByUsername } from "~/models/profile.server"; -import { createUser, getUserByEmail } from "~/models/user.server"; -import { safeRedirect, validateEmail, validateName } from "~/utils"; -import { createUserSession, getUserId } from "~/utils/session.server"; +import i18next from 'app/i18next.server' +import * as React from 'react' +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, + data, + redirect, + Form, + Link, + useActionData, + useSearchParams, +} from 'react-router' +import ErrorMessage from '~/components/error-message' +import { getProfileByUsername } from '~/models/profile.server' +import { createUser, getUserByEmail } from '~/models/user.server' +import { safeRedirect, validateEmail, validateName } from '~/utils' +import { createUserSession, getUserId } from '~/utils/session.server' export async function loader({ request }: LoaderFunctionArgs) { - const userId = await getUserId(request); - if (userId) return redirect("/"); - return {}; + const userId = await getUserId(request) + if (userId) return redirect('/') + return {} } export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - const name = formData.get("name"); + const formData = await request.formData() + const email = formData.get('email') + const password = formData.get('password') + const name = formData.get('name') - const redirectTo = safeRedirect(formData.get("redirectTo"), "/"); + const redirectTo = safeRedirect(formData.get('redirectTo'), '/') - if (!name || typeof name !== "string") { - return data( - { errors: { name: "Name is required", email: null, password: null } }, - { status: 400 }, - ); - } + if (!name || typeof name !== 'string') { + return data( + { errors: { name: 'Name is required', email: null, password: null } }, + { status: 400 }, + ) + } - //* Validate userName - const validateUserName = validateName(name?.toString()); - if (!validateUserName.isValid) { - return data( - { - errors: { - name: validateUserName.errorMsg, - password: null, - email: null, - }, - }, - { status: 400 }, - ); - } + //* Validate userName + const validateUserName = validateName(name?.toString()) + if (!validateUserName.isValid) { + return data( + { + errors: { + name: validateUserName.errorMsg, + password: null, + email: null, + }, + }, + { status: 400 }, + ) + } - if (!validateEmail(email)) { - return data( - { errors: { name: null, email: "Email is invalid", password: null } }, - { status: 400 }, - ); - } + if (!validateEmail(email)) { + return data( + { errors: { name: null, email: 'Email is invalid', password: null } }, + { status: 400 }, + ) + } - if (typeof password !== "string" || password.length === 0) { - return data( - { errors: { name: null, email: null, password: "Password is required" } }, - { status: 400 }, - ); - } + if (typeof password !== 'string' || password.length === 0) { + return data( + { errors: { name: null, email: null, password: 'Password is required' } }, + { status: 400 }, + ) + } - if (password.length < 8) { - return data( - { - errors: { - name: null, - email: null, - password: "Please use at least 8 characters.", - }, - }, - { status: 400 }, - ); - } + if (password.length < 8) { + return data( + { + errors: { + name: null, + email: null, + password: 'Please use at least 8 characters.', + }, + }, + { status: 400 }, + ) + } - //* check if user exists by email - const existingUserByEmail = await getUserByEmail(email); - if (existingUserByEmail) { - return data( - { - errors: { - name: null, - email: "A user already exists with this email", - password: null, - }, - }, - { status: 400 }, - ); - } + //* check if user exists by email + const existingUserByEmail = await getUserByEmail(email) + if (existingUserByEmail) { + return data( + { + errors: { + name: null, + email: 'A user already exists with this email', + password: null, + }, + }, + { status: 400 }, + ) + } - // check if profile exists by name - const existingUserByName = await getProfileByUsername(name); - if (existingUserByName) { - return data( - { - errors: { - name: "A user already exists with this name", - email: null, - password: null, - }, - }, - { status: 400 }, - ); - } + // check if profile exists by name + const existingUserByName = await getProfileByUsername(name) + if (existingUserByName) { + return data( + { + errors: { + name: 'A user already exists with this name', + email: null, + password: null, + }, + }, + { status: 400 }, + ) + } - //* get current locale - const locale = await i18next.getLocale(request); - const language = locale === "de" ? "de_DE" : "en_US"; + //* get current locale + const locale = await i18next.getLocale(request) + const language = locale === 'de' ? 'de_DE' : 'en_US' - const user = await createUser(name, email, language, password); + const user = await createUser(name, email, language, password) - return createUserSession({ - request, - userId: user[0].id, - remember: false, - redirectTo, - }); + return createUserSession({ + request, + userId: user && user[0] ? user[0].id : '', + remember: false, + redirectTo, + }) } export const meta: MetaFunction = () => { - return [{ title: "Sign Up" }]; -}; + return [{ title: 'Sign Up' }] +} export default function Join() { - const [searchParams] = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") ?? undefined; - const actionData = useActionData(); - const emailRef = React.useRef(null); - const passwordRef = React.useRef(null); - const nameRef = React.useRef(null); + const [searchParams] = useSearchParams() + const redirectTo = searchParams.get('redirectTo') ?? undefined + const actionData = useActionData() + const emailRef = React.useRef(null) + const passwordRef = React.useRef(null) + const nameRef = React.useRef(null) - React.useEffect(() => { - if (actionData?.errors?.email) { - emailRef.current?.focus(); - } else if (actionData?.errors?.password) { - passwordRef.current?.focus(); - } - }, [actionData]); + React.useEffect(() => { + if (actionData?.errors?.email) { + emailRef.current?.focus() + } else if (actionData?.errors?.password) { + passwordRef.current?.focus() + } + }, [actionData]) - return ( -
- {/* Form */} -
-
-
- -
- - {actionData?.errors?.name && ( -
- {actionData.errors.name} -
- )} -
-
+ return ( +
+ {/* Form */} +
+ +
+ +
+ + {actionData?.errors?.name && ( +
+ {actionData.errors.name} +
+ )} +
+
-
- -
- - {actionData?.errors?.email && ( -
- {actionData.errors.email} -
- )} -
-
+
+ +
+ + {actionData?.errors?.email && ( +
+ {actionData.errors.email} +
+ )} +
+
-
- -
- - {actionData?.errors?.password && ( -
- {actionData.errors.password} -
- )} -
-
+
+ +
+ + {actionData?.errors?.password && ( +
+ {actionData.errors.password} +
+ )} +
+
- - -
-
- Already have an account?{" "} - - Log in - -
-
- -
-
- ); + + +
+
+ Already have an account?{' '} + + Log in + +
+
+ +
+
+ ) } export function ErrorBoundary() { - return ( -
- -
- ); + return ( +
+ +
+ ) } diff --git a/app/utils/misc.ts b/app/utils/misc.ts index d8b8c5e5..bc1e2318 100644 --- a/app/utils/misc.ts +++ b/app/utils/misc.ts @@ -1,28 +1,28 @@ export function getUserImgSrc(imageId?: string | null) { - return `/resources/file/${imageId}`; + return `/resources/file/${imageId}` } export function getErrorMessage(error: unknown) { - if (typeof error === "string") return error; - if ( - error && - typeof error === "object" && - "message" in error && - typeof error.message === "string" - ) { - return error.message; - } - console.error("Unable to get error message for error", error); - return "Unknown Error"; + if (typeof error === 'string') return error + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof error.message === 'string' + ) { + return error.message + } + console.error('Unable to get error message for error', error) + return 'Unknown Error' } export function getInitials(string: string) { - if (!string) return ""; - var names = string.split(" "), - initials = names[0].substring(0, 1).toUpperCase(); + if (!string) return '' + let names = string.split(' ') + let initials = names.at(0)?.substring(0, 1).toUpperCase() ?? '??' - if (names.length > 1) { - initials += names[names.length - 1].substring(0, 1).toUpperCase(); - } - return initials; + if (names.length > 1) { + initials += names.at(-1)?.substring(0, 1).toUpperCase() ?? '?' + } + return initials } diff --git a/app/utils/sensor-wiki-helper.tsx b/app/utils/sensor-wiki-helper.tsx index bdf3832d..fac25af0 100644 --- a/app/utils/sensor-wiki-helper.tsx +++ b/app/utils/sensor-wiki-helper.tsx @@ -1,52 +1,52 @@ -import i18next from "i18next"; -import { type Unit } from "~/models/unit.server"; +import i18next from 'i18next' +import { type Unit } from '~/models/unit.server' export type SensorWikiTranslation = { - item: SensorWikiLabel[]; -}; + item: SensorWikiLabel[] +} export type SensorWikiLabel = { - languageCode: string; - text: string; -}; + languageCode: string + text: string +} export type SensorWikiSensor = { - id: number; - slug: string; - label: SensorWikiTranslation; - description: SensorWikiTranslation; - manufacturer: string; - lifePeriod: number; - price: number; - image: string; - datasheet: string; -}; + id: number + slug: string + label: SensorWikiTranslation + description: SensorWikiTranslation + manufacturer: string + lifePeriod: number + price: number + image: string + datasheet: string +} export type SensorWikiSensorElement = { - id: number; - accuracy: number; - accuracyUnit: Unit; - sensorId: number; - phenomenonId: number; -}; + id: number + accuracy: number + accuracyUnit: Unit + sensorId: number + phenomenonId: number +} export function sensorWikiLabel(label: SensorWikiLabel[]) { - // const locale = await i18next.getLocale(request); - if (!label) { - return undefined; - } - const lang = getLanguage(); - const labelFound = label.filter( - (labelItem: any) => labelItem.languageCode == lang - ); + // const locale = await i18next.getLocale(request); + if (!label) { + return undefined + } + const lang = getLanguage() + const labelFound = label.filter( + (labelItem: any) => labelItem.languageCode == lang, + ) - if (labelFound.length > 0) { - return labelFound[0].text; - } else { - return label[0].text; - } + if (labelFound.length > 0) { + return labelFound[0]!.text + } else { + return label[0]?.text + } } export function getLanguage() { - return i18next.language; + return i18next.language } diff --git a/eslint.config.js b/eslint.config.js index cc2e53df..ab74a1df 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,16 +1,17 @@ import { default as defaultConfig } from '@epic-web/config/eslint' +import prettier from 'eslint-plugin-prettier' /** @type {import("eslint").Linter.Config} */ -const options = [ +export default [ ...defaultConfig, - // add custom config objects here: - { - ignores: ['**/.react-router/**'], - }, { files: ['**/tests/**/*.ts'], - rules: { 'react-hooks/rules-of-hooks': 'off' }, + ignores: ['**/.react-router/**'], + plugins: { + prettier: prettier, + }, + rules: { + 'prettier/prettier': 'error', + }, }, ] - -export default options diff --git a/package-lock.json b/package-lock.json index 78c39435..69be2420 100644 --- a/package-lock.json +++ b/package-lock.json @@ -155,6 +155,8 @@ "@types/react-dom": "19.0.3", "@types/source-map-support": "^0.5.7", "@types/supercluster": "^7.1.3", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", "@vitejs/plugin-react": "^4.0.4", "@vitest/coverage-v8": "^3.0.5", "autoprefixer": "^10.4.15", @@ -164,6 +166,7 @@ "esbuild": "^0.19.4", "eslint": "^9.14.0", "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-unicorn": "^56.0.1", "fs-extra": "^11.2.0", "happy-dom": "^15.7.4", @@ -2648,6 +2651,19 @@ "node": ">=14" } }, + "node_modules/@pkgr/core": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.1.tgz", + "integrity": "sha512-VzgHzGblFmUeBmmrk55zPyrQIArQN4vujc9shWytaPdB3P7qhi0cpaiKIr7tlCmFv2lYUwnLospIqjL9ZSAhhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/@playwright/test": { "version": "1.50.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.0.tgz", @@ -4261,6 +4277,97 @@ "node": ">=12" } }, + "node_modules/@remix-run/eslint-config/node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@remix-run/eslint-config/node_modules/@typescript-eslint/parser": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@remix-run/eslint-config/node_modules/@typescript-eslint/type-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@remix-run/eslint-config/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -5500,66 +5607,314 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", - "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz", + "integrity": "sha512-ba0rr4Wfvg23vERs3eB+P3lfj2E+2g3lhWcCVukUuhtcdUx5lSIFZlGFEBHKr+3zizDa/TvZTptdNHVZWAkSBg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/type-utils": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.29.1", + "@typescript-eslint/type-utils": "8.29.1", + "@typescript-eslint/utils": "8.29.1", + "@typescript-eslint/visitor-keys": "8.29.1", "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.29.1.tgz", + "integrity": "sha512-2nggXGX5F3YrsGN08pw4XpMLO1Rgtnn4AzTegC2MDesv6q3QaTU5yU7IbS1tf1IwCR0Hv/1EFygLn9ms6LIpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.1", + "@typescript-eslint/visitor-keys": "8.29.1" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.29.1.tgz", + "integrity": "sha512-VT7T1PuJF1hpYC3AGm2rCgJBjHL3nc+A/bhOp9sGMKfi5v0WufsX/sHCFBfNTx2F+zA6qBc/PD0/kLRLjdt8mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.1.tgz", + "integrity": "sha512-l1enRoSaUkQxOQnbi0KPUtqeZkSiFlqrx9/3ns2rEDhGKfTa+88RmXqedC1zmVTOWrLc2e6DEJrTA51C9iLH5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.1", + "@typescript-eslint/visitor-keys": "8.29.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.29.1.tgz", + "integrity": "sha512-QAkFEbytSaB8wnmB+DflhUPz6CLbFWE2SnSCrRMEa+KnXIzDYbpsn++1HGvnfAsUY44doDXmvRkO5shlM/3UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.29.1", + "@typescript-eslint/types": "8.29.1", + "@typescript-eslint/typescript-estree": "8.29.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.1.tgz", + "integrity": "sha512-RGLh5CRaUEf02viP5c1Vh1cMGffQscyHe7HPAzGpfmfflFg1wUz2rYxd+OZqwpeypYvZ8UxSxuIpF++fmOzEcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/parser": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", - "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz", + "integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/scope-manager": "8.29.1", + "@typescript-eslint/types": "8.29.1", + "@typescript-eslint/typescript-estree": "8.29.1", + "@typescript-eslint/visitor-keys": "8.29.1", "debug": "^4.3.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.29.1.tgz", + "integrity": "sha512-2nggXGX5F3YrsGN08pw4XpMLO1Rgtnn4AzTegC2MDesv6q3QaTU5yU7IbS1tf1IwCR0Hv/1EFygLn9ms6LIpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.1", + "@typescript-eslint/visitor-keys": "8.29.1" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.29.1.tgz", + "integrity": "sha512-VT7T1PuJF1hpYC3AGm2rCgJBjHL3nc+A/bhOp9sGMKfi5v0WufsX/sHCFBfNTx2F+zA6qBc/PD0/kLRLjdt8mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.1.tgz", + "integrity": "sha512-l1enRoSaUkQxOQnbi0KPUtqeZkSiFlqrx9/3ns2rEDhGKfTa+88RmXqedC1zmVTOWrLc2e6DEJrTA51C9iLH5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.1", + "@typescript-eslint/visitor-keys": "8.29.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.1.tgz", + "integrity": "sha512-RGLh5CRaUEf02viP5c1Vh1cMGffQscyHe7HPAzGpfmfflFg1wUz2rYxd+OZqwpeypYvZ8UxSxuIpF++fmOzEcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/scope-manager": { @@ -5581,31 +5936,167 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", - "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.29.1.tgz", + "integrity": "sha512-DkDUSDwZVCYN71xA4wzySqqcZsHKic53A4BLqmrWFFpOpNSoxX233lwGu/2135ymTCR04PoKiEEEvN1gFYg4Tw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "5.62.0", - "@typescript-eslint/utils": "5.62.0", + "@typescript-eslint/typescript-estree": "8.29.1", + "@typescript-eslint/utils": "8.29.1", "debug": "^4.3.4", - "tsutils": "^3.21.0" + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "*" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.29.1.tgz", + "integrity": "sha512-2nggXGX5F3YrsGN08pw4XpMLO1Rgtnn4AzTegC2MDesv6q3QaTU5yU7IbS1tf1IwCR0Hv/1EFygLn9ms6LIpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.1", + "@typescript-eslint/visitor-keys": "8.29.1" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.29.1.tgz", + "integrity": "sha512-VT7T1PuJF1hpYC3AGm2rCgJBjHL3nc+A/bhOp9sGMKfi5v0WufsX/sHCFBfNTx2F+zA6qBc/PD0/kLRLjdt8mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.1.tgz", + "integrity": "sha512-l1enRoSaUkQxOQnbi0KPUtqeZkSiFlqrx9/3ns2rEDhGKfTa+88RmXqedC1zmVTOWrLc2e6DEJrTA51C9iLH5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.1", + "@typescript-eslint/visitor-keys": "8.29.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.29.1.tgz", + "integrity": "sha512-QAkFEbytSaB8wnmB+DflhUPz6CLbFWE2SnSCrRMEa+KnXIzDYbpsn++1HGvnfAsUY44doDXmvRkO5shlM/3UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.29.1", + "@typescript-eslint/types": "8.29.1", + "@typescript-eslint/typescript-estree": "8.29.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.29.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.1.tgz", + "integrity": "sha512-RGLh5CRaUEf02viP5c1Vh1cMGffQscyHe7HPAzGpfmfflFg1wUz2rYxd+OZqwpeypYvZ8UxSxuIpF++fmOzEcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.29.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/types": { @@ -9845,6 +10336,37 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-prettier": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", + "integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.4", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz", @@ -10575,6 +11097,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -18968,6 +19497,19 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/prettier-plugin-tailwindcss": { "version": "0.5.14", "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.14.tgz", @@ -22061,6 +22603,23 @@ "url": "https://github.com/fontello/svg2ttf?sponsor=1" } }, + "node_modules/synckit": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz", + "integrity": "sha512-szhWDqNNI9etJUvbZ1/cx1StnZx8yMmFxme48SwR4dty4ioSY50KEZlpv0qAfgc1fpRzuh9hBXEzoCpJ779dLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -22687,9 +23246,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index c48cb02b..e3dfd10e 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,8 @@ "@types/react-dom": "19.0.3", "@types/source-map-support": "^0.5.7", "@types/supercluster": "^7.1.3", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", "@vitejs/plugin-react": "^4.0.4", "@vitest/coverage-v8": "^3.0.5", "autoprefixer": "^10.4.15", @@ -194,6 +196,7 @@ "esbuild": "^0.19.4", "eslint": "^9.14.0", "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-unicorn": "^56.0.1", "fs-extra": "^11.2.0", "happy-dom": "^15.7.4", diff --git a/tsconfig.json b/tsconfig.json index b1e43d0e..a9fd1699 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,15 @@ { + "extends": ["@epic-web/config/typescript"], "exclude": [], - "include": ["./types", "env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"], + "include": [ + "./types", + "env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + ".react-router/types/**/*" + ], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2020"], "types": ["@react-router/node", "vite/client", "vitest/globals"],