diff --git a/.env.example b/.env.example index 2259bc556..f718654d0 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,44 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" +# SHADOW_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" + +PG_CLIENT_SSL="false" + SESSION_SECRET="super-duper-s3cret" + +MAPBOX_ACCESS_TOKEN="" +MAPBOX_GEOCODING_API="https://api.mapbox.com/geocoding/v5/mapbox.places/" + +OSEM_API_URL="https://api.opensensemap.org/" +DIRECTUS_URL="https://coelho.opensensemap.org" +SENSORWIKI_API_URL="https://api.sensors.wiki/" + + +MYBADGES_API_URL = "https://api.v2.mybadges.org/" +MYBADGES_URL = "https://mybadges.org/" +MYBADGES_SERVERADMIN_USERNAME = "" +MYBADGES_SERVERADMIN_PASSWORD = "" +MYBADGES_ISSUERID_OSEM = "" +MYBADGES_CLIENT_ID = "" +MYBADGES_CLIENT_SECRET = "" + +NOVU_API_URL = "" +NOVU_WEBSOCKET_URL = "" +NOVU_APPLICATION_IDENTIFIER = "" + +SENSORWIKI_API_URL="https://api.sensors.wiki/" + +MYBADGES_API_URL = "https://api.v2.mybadges.org/" +MYBADGES_URL = "https://mybadges.org/" +MYBADGES_SERVERADMIN_USERNAME = "" +MYBADGES_SERVERADMIN_PASSWORD = "" +MYBADGES_ISSUERID_OSEM = "" +MYBADGES_CLIENT_ID = "" +MYBADGES_CLIENT_SECRET = "" + +NOVU_API_URL = "" +NOVU_WEBSOCKET_URL = "" +NOVU_APPLICATION_IDENTIFIER = "" + +# login to testing.opensensemap.org to register box in testing database (use your own account for now) +TESTING_ACCOUNT="" +TESTING_PW="test1234" \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 3c3134acc..404fac1b2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -9,7 +9,7 @@ module.exports = { env: { "cypress/globals": true, }, - plugins: ["cypress"], + plugins: ["cypress", "unicorn"], // We're using vitest which has a very similar API to jest // (so the linting plugins work nicely), but we have to // set the jest version explicitly. @@ -18,4 +18,28 @@ module.exports = { version: 28, }, }, + // Enable kebabCase filename convention for all files + rules: { + "unicorn/filename-case": [ + "error", + { + case: "kebabCase", + }, + ], + }, + // But disable kebabCase filename convention for all files + // in /app/routes because of remix.run filename conventions + overrides: [ + { + files: [ + "app/routes/**/*.ts", + "app/routes/**/*.js", + "app/routes/**/*.tsx", + "app/routes/**/*.jsx", + ], + rules: { + "unicorn/filename-case": "off", + }, + }, + ], }; diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 000000000..8150d9fd1 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,15 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 11d1e0297..d6557e75f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: 🚀 Deploy +name: 🏗️ Build on: push: branches: @@ -6,30 +6,32 @@ on: - dev pull_request: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: actions: write contents: read + packages: write jobs: lint: name: ⬣ ESLint runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 16 + cache: npm + cache-dependency-path: ./package.json + node-version: 18 - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false + - name: 📥 Install deps + run: npm install - name: 🔬 Lint run: npm run lint @@ -38,21 +40,18 @@ jobs: name: ʦ TypeScript runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 16 + cache: npm + cache-dependency-path: ./package.json + node-version: 18 - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false + - name: 📥 Install deps + run: npm install - name: 🔎 Type check run: npm run typecheck --if-present @@ -61,21 +60,18 @@ jobs: name: ⚡ Vitest runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 16 + cache: npm + cache-dependency-path: ./package.json + node-version: 18 - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false + - name: 📥 Install deps + run: npm install - name: ⚡ Run vitest run: npm run test -- --coverage @@ -84,91 +80,91 @@ jobs: name: ⚫️ Cypress runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 🏄 Copy test env vars run: cp .env.example .env - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 16 + cache: npm + cache-dependency-path: ./package.json + node-version: 18 - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false + - name: 📥 Install deps + run: npm install - name: 🐳 Docker compose # the sleep is just there to give time for postgres to get started - run: docker-compose up -d && sleep 3 + run: docker compose -f docker-compose.ci.yml up -d && sleep 60 env: DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres" - name: 🛠 Setup Database - run: npx prisma migrate reset --force + uses: nick-fields/retry@v3.0.0 + with: + timeout_minutes: 10 + max_attempts: 5 + retry_wait_seconds: 45 + retry_on: error + command: npm run drizzle:migrate - name: ⚙️ Build run: npm run build - name: 🌳 Cypress run - uses: cypress-io/github-action@v5 + uses: cypress-io/github-action@v6 with: start: npm run start:mocks - wait-on: "http://localhost:8811" + wait-on: http://localhost:8811 env: - PORT: "8811" + PORT: 8811 build: name: 🐳 Build # only build/deploy main branch on pushes - if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} + # if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 - - - name: 👀 Read app name - uses: SebRollen/toml-action@v1.0.2 - id: app_name - with: - file: "fly.toml" - field: "app" + uses: actions/checkout@v4 - name: 🐳 Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: version: v0.9.1 # Setup cache - name: ⚡️ Cache Docker layers - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx- - - name: 🔑 Fly Registry Auth - uses: docker/login-action@v2 + - name: 📋 Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/opensensemap/frontend + + - name: 🔑 GitHub Registry Auth + uses: docker/login-action@v3 with: - registry: registry.fly.io - username: x - password: ${{ secrets.FLY_API_TOKEN }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: 🐳 Docker build - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: context: . push: true - tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} build-args: | COMMIT_SHA=${{ github.sha }} cache-from: type=local,src=/tmp/.buildx-cache @@ -183,40 +179,3 @@ jobs: run: | rm -rf /tmp/.buildx-cache mv /tmp/.buildx-cache-new /tmp/.buildx-cache - - deploy: - name: 🚀 Deploy - runs-on: ubuntu-latest - needs: [lint, typecheck, vitest, cypress, build] - # only build/deploy main branch on pushes - if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} - - steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 - - - name: 👀 Read app name - uses: SebRollen/toml-action@v1.0.2 - id: app_name - with: - file: "fly.toml" - field: "app" - - - name: 🚀 Deploy Staging - if: ${{ github.ref == 'refs/heads/dev' }} - uses: superfly/flyctl-actions@1.3 - with: - args: "deploy --app ${{ steps.app_name.outputs.value }}-staging --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - - - name: 🚀 Deploy Production - if: ${{ github.ref == 'refs/heads/main' }} - uses: superfly/flyctl-actions@1.3 - with: - args: "deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.github/workflows/purge.yml b/.github/workflows/purge.yml new file mode 100644 index 000000000..d6c65043d --- /dev/null +++ b/.github/workflows/purge.yml @@ -0,0 +1,19 @@ +name: 🗑️ Purge untagged images +on: + schedule: + - cron: "0 0 * * *" + +permissions: + packages: write + +jobs: + purge_untagged_images: + runs-on: ubuntu-latest + steps: + - name: 🧹 Remove untagged images + uses: vlaurin/action-ghcr-prune@v0.6.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + organization: ${{ github.repository_owner}} + container: ${{ github.event.repository.name }} + prune-untagged: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 72bfc1fad..29c850753 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# ignore VS Code json files +.vscode + +.DS_Store node_modules /build @@ -7,5 +11,9 @@ node_modules /cypress/screenshots /cypress/videos /postgres-data +/pgdata + +/app/styles/**/*.css -/app/styles/tailwind.css +/db/imports/*.csv +measurements.csv diff --git a/.prettierignore b/.prettierignore index 72bfc1fad..9d625a707 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,4 +8,4 @@ node_modules /cypress/videos /postgres-data -/app/styles/tailwind.css +/app/styles/**/*.css diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..6d4bf5c7d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + // List of extensions which should be recommended for users of this workspace. + "recommendations": [ + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss" + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1ca1abb58..8fcffeeff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # base node image -FROM node:16-bullseye-slim as base +FROM node:22-bullseye-slim as base # set for base and all layer that inherit from it ENV NODE_ENV production @@ -13,7 +13,7 @@ FROM base as deps WORKDIR /myapp ADD package.json package-lock.json .npmrc ./ -RUN npm install --production=false +RUN npm install --include=dev # Setup production node_modules FROM base as production-deps @@ -22,7 +22,7 @@ WORKDIR /myapp COPY --from=deps /myapp/node_modules /myapp/node_modules ADD package.json package-lock.json .npmrc ./ -RUN npm prune --production +RUN npm prune --omit=dev # Build the app FROM base as build @@ -30,11 +30,10 @@ FROM base as build WORKDIR /myapp COPY --from=deps /myapp/node_modules /myapp/node_modules - -ADD prisma . -RUN npx prisma generate - ADD . . + +RUN npm run drizzle:generate +#RUN npm run drizzle:migrate RUN npm run build # Finally, build the production image with minimal footprint @@ -43,10 +42,10 @@ FROM base WORKDIR /myapp COPY --from=production-deps /myapp/node_modules /myapp/node_modules -COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma +#COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma COPY --from=build /myapp/build /myapp/build COPY --from=build /myapp/public /myapp/public ADD . . -CMD ["npm", "start"] +CMD ["npm", "start"] \ No newline at end of file diff --git a/app/components/aggregation-filter.tsx b/app/components/aggregation-filter.tsx new file mode 100644 index 000000000..2a6d70734 --- /dev/null +++ b/app/components/aggregation-filter.tsx @@ -0,0 +1,115 @@ +import { Filter } from "lucide-react"; +import { Button } from "./ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { Separator } from "./ui/separator"; +import { Badge } from "./ui/badge"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./ui/command"; +import { useSearchParams, useSubmit } from "@remix-run/react"; +import { useEffect, useState } from "react"; + +type Aggregation = { + value: string; + label: string; +}; + +const aggregations: Aggregation[] = [ + { + value: "raw", + label: "Raw", + }, + { + value: "10m", + label: "10 Minutes", + }, + { + value: "1h", + label: "1 Hour", + }, + { + value: "1d", + label: "1 Day", + }, + { + value: "1m", + label: "1 Month", + }, + { + value: "1y", + label: "1 Year", + }, +]; + +export function AggregationFilter() { + const submit = useSubmit(); + const [searchParams] = useSearchParams(); + + const [open, setOpen] = useState(false); + + const aggregationParam = searchParams.get("aggregation") || "raw"; + const selectedAggregation = aggregations.find( + (aggregation) => aggregation.value === aggregationParam, + ); + + // Shortcut to open aggregation selection + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "a" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((open) => !open); + } + }; + + document.addEventListener("keydown", down); + + return () => { + document.removeEventListener("keydown", down); + }; + }, []); + + return ( + + + + + + + + + No aggregation found. + + {aggregations.map((aggregation) => ( + { + setOpen(false); + searchParams.set("aggregation", value); + submit(searchParams); + }} + > + {aggregation.label} + + ))} + + + + + + ); +} diff --git a/app/components/client-only.tsx b/app/components/client-only.tsx new file mode 100644 index 000000000..441096bcb --- /dev/null +++ b/app/components/client-only.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import { useHydrated } from "~/utils/use-hydrated"; + +type Props = { + /** + * You are encouraged to add a fallback that is the same dimensions + * as the client rendered children. This will avoid content layout + * shift which is disgusting + */ + children(): React.ReactNode; + fallback?: React.ReactNode; +}; + +/** + * Render the children only after the JS has loaded client-side. Use an optional + * fallback component if the JS is not yet loaded. + * + * Example: Render a Chart component if JS loads, renders a simple FakeChart + * component server-side or if there is no JS. The FakeChart can have only the + * UI without the behavior or be a loading spinner or skeleton. + * ```tsx + * return ( + * }> + * {() => } + * + * ); + * ``` + */ +export function ClientOnly({ children, fallback = null }: Props) { + return useHydrated() ? <>{children()} : <>{fallback}; +} diff --git a/app/components/daterange-filter.tsx b/app/components/daterange-filter.tsx new file mode 100644 index 000000000..9580a80d0 --- /dev/null +++ b/app/components/daterange-filter.tsx @@ -0,0 +1,170 @@ +import { Clock } from "lucide-react"; +import { Button } from "./ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { Separator } from "./ui/separator"; +import { Badge } from "./ui/badge"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./ui/command"; +import { useEffect, useState } from "react"; +import { Calendar } from "./ui/calendar"; +import { useLoaderData, useSearchParams, useSubmit } from "@remix-run/react"; +import type { loader } from "~/routes/explore.$deviceId._index"; +import type { DateRange } from "react-day-picker"; +import { PopoverClose } from "@radix-ui/react-popover"; +import dateTimeRanges from "~/lib/date-ranges"; +import { format } from "date-fns"; + +export function DateRangeFilter() { + // Get data from the loader + const loaderData = useLoaderData(); + + // Form submission handler + const submit = useSubmit(); + const [searchParams] = useSearchParams(); + + const [open, setOpen] = useState(false); + + // State for selected date range and aggregation + const [date, setDate] = useState({ + from: loaderData.fromDate ? new Date(loaderData.fromDate) : undefined, + to: loaderData.toDate ? new Date(loaderData.toDate) : undefined, + }); + + if ( + !date?.from && + !date?.to && + loaderData.selectedSensors[0].data.length > 0 + ) { + // on initial load, without a selected time range, check what time rage the last 20000 data points are in + const firstDate = loaderData.selectedSensors[0].data[0].time; + const lastDate = + loaderData.selectedSensors[0].data[ + loaderData.selectedSensors[0].data.length - 1 + ].time; + 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); + } + }; + + document.addEventListener("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]); + + 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(); + + setDate({ + from: timeRange?.from, + to: timeRange?.to, + }); + }} + > + {dateTimeRange.label} + + ))} + + + +
+
+ { + submit(searchParams); + }} + > + Apply + +
+
+
+
+ ); +} diff --git a/app/components/device-card.tsx b/app/components/device-card.tsx new file mode 100644 index 000000000..683442764 --- /dev/null +++ b/app/components/device-card.tsx @@ -0,0 +1,35 @@ +import { Circle } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "./ui/card"; +import type { Device } from "~/schema"; + +interface DeviceCardProps { + device: Device; +} + +export default function DeviceCard({ device }: DeviceCardProps) { + return ( + + +
+ {device.name} + {device.description} +
+
+ +
+
+ + {device.model} +
+
Updated {device.updatedAt.toString()}
+
+
+
+ ); +} diff --git a/app/components/device-detail/device-detail-box.tsx b/app/components/device-detail/device-detail-box.tsx new file mode 100644 index 000000000..4004748f7 --- /dev/null +++ b/app/components/device-detail/device-detail-box.tsx @@ -0,0 +1,510 @@ +import { + Form, + useLoaderData, + useNavigate, + useNavigation, + useSearchParams, + useSubmit, +} from "@remix-run/react"; +import Graph from "./graph"; +import Spinner from "../spinner"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "../ui/accordion"; +import type { loader } from "~/routes/explore.$deviceId._index"; +import { + ChevronUp, + Minus, + Share2, + XSquare, + EllipsisVertical, + X, + ExternalLink, + Scale, + Archive, + Cpu, + Rss, + CalendarPlus, +} from "lucide-react"; +import { Fragment, useEffect, useRef, useState } from "react"; +import type { DraggableData } from "react-draggable"; +import Draggable from "react-draggable"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../ui/alert-dialog"; +import ShareLink from "./share-link"; +import { getArchiveLink } from "~/utils/device"; +import { useBetween } from "use-between"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { isTablet, isBrowser } from "react-device-detect"; +import type { Device, Sensor, SensorWithMeasurement } from "~/schema"; +import { format, formatDistanceToNow } from "date-fns"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; +import SensorIcon from "../sensor-icon"; +import { Separator } from "../ui/separator"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { Button } from "../ui/button"; + +export interface LastMeasurementProps { + time: Date; + value: string; +} + +export interface DeviceAndSelectedSensors { + device: Device; + selectedSensors: Sensor[]; +} + +const useCompareMode = () => { + const [compareMode, setCompareMode] = useState(false); + return { compareMode, setCompareMode }; +}; + +export const useSharedCompareMode = () => useBetween(useCompareMode); + +export default function DeviceDetailBox() { + const navigation = useNavigation(); + const navigate = useNavigate(); + const data = useLoaderData(); + const nodeRef = useRef(null); + // state variables + const [open, setOpen] = useState(true); + const [openGraph, setOpenGraph] = useState( + Boolean(data.selectedSensors.length > 0 ? true : false), + ); + const [offsetPositionX, setOffsetPositionX] = useState(0); + const [offsetPositionY, setOffsetPositionY] = useState(0); + const { compareMode, setCompareMode } = useSharedCompareMode(); + const [refreshOn] = useState(false); + const [refreshSecond, setRefreshSecond] = useState(59); + useEffect(() => { + setOpenGraph(Boolean(data.selectedSensors.length)); + }, [data.selectedSensors]); + + const [sensors, setSensors] = useState(); + useEffect(() => { + setSensors(data.sensors); + }, [data.sensors]); + + const [searchParams] = useSearchParams(); + + // get list of selected sensor ids from URL search params + const selectedSensorIds = searchParams.getAll("sensor"); + + function handleDrag(_e: any, data: DraggableData) { + setOffsetPositionX(data.x); + setOffsetPositionY(data.y); + } + + function handleCompareClick() { + setCompareMode(!compareMode); + setOpenGraph(false); + setOpen(false); + } + + const addLineBreaks = (text: string) => + text.split("\\n").map((text, index) => ( + + {text} +
+
+ )); + + useEffect(() => { + let interval: any = null; + if (refreshOn) { + if (refreshSecond == 0) { + setRefreshSecond(59); + } + interval = setInterval(() => { + setRefreshSecond((refreshSecond) => refreshSecond - 1); + }, 1000); + } else if (!refreshOn) { + clearInterval(interval); + } + return () => clearInterval(interval); + }, [refreshOn, refreshSecond]); + + const submit = useSubmit(); + + const getDeviceImage = (imageUri: string) => + imageUri !== null + ? `https://opensensemap.org/userimages/${imageUri}` + : "https://images.placeholders.dev/?width=400&height=350&text=No%20image&bgColor=%234fae48&textColor=%23727373"; + + return ( + <> + {open && ( + +
+
+ {navigation.state === "loading" && ( +
+ +
+ )} +
+
+
+ {data.device.name} +
+ + + + + + + Share this link + + + + Close + + + + + + + + + Actions + + handleCompareClick()} + > + + Compare + + + + + + Archive + + + + + + + + External Link + + + + + + + setOpen(false)} + /> + { + navigate("/explore"); + }} + /> +
+
+ + + + General + + +
+
+ device_image +
+
+
+ + + External Link + + +
+ {" "} + {data.device.sensorWikiModel ?? "Not specified"} +
+ +
+ + {format(new Date(data.device.updatedAt), "PPP")} +
+ +
+ + {format(new Date(data.device.createdAt), "PPP")} +
+
+
+
+ {/*
+ device_image +
*/} +
+
+
+ + + + Description + + + {addLineBreaks(data.device.description || "")} + + + + + + + Sensors + + +
{ + // handle sensor selection and keep time/aggregation params if at least one sensor is selected + const formData = new FormData(e.currentTarget); + if (formData.getAll("sensor").length > 0) { + searchParams.delete("sensor"); + searchParams.forEach((value, key) => { + formData.append(key, value); + }); + } + submit(formData); + }} + className={ + navigation.state === "loading" + ? "pointer-events-none" + : "" + } + > +
+ {sensors + ? sensors.map((sensor: SensorWithMeasurement) => { + return ( + + + + ); + }) + : null} +
+
+
+
+
+
+
+
+
+ )} + {compareMode && ( + + { + setCompareMode(!compareMode); + setOpen(true); + }} + /> + Compare devices + + Choose a device from the map to compare with. + + + )} + {!open && ( +
{ + setOpen(true); + }} + className="absolute bottom-[10px] left-4 flex cursor-pointer rounded-xl border border-gray-100 bg-white shadow-lg transition-colors duration-300 ease-in-out hover:brightness-90 dark:bg-zinc-800 dark:text-zinc-200 dark:opacity-90 sm:bottom-[30px] sm:left-[10px]" + > + + + +
+ +
+
+ +

Open device details

+
+
+
+
+ )} + {selectedSensorIds.length > 0 ? ( + + ) : null} + + ); +} diff --git a/app/components/device-detail/graph.tsx b/app/components/device-detail/graph.tsx new file mode 100644 index 000000000..db36d2c37 --- /dev/null +++ b/app/components/device-detail/graph.tsx @@ -0,0 +1,345 @@ +import { + useLoaderData, + useNavigation, + useSearchParams, +} from "@remix-run/react"; +import { + Chart as ChartJS, + LineElement, + TimeScale, + CategoryScale, + LinearScale, + PointElement, + Legend, + Tooltip as ChartTooltip, +} from "chart.js"; +import "chartjs-adapter-date-fns"; +import { Line } from "react-chartjs-2"; +import type { ChartOptions } from "chart.js"; +import { de, enGB } from "date-fns/locale"; +import type { LastMeasurementProps } from "./device-detail-box"; +import type { loader } from "~/routes/explore.$deviceId._index"; +import { useMemo, useRef, useState } from "react"; +import { saveAs } from "file-saver"; +import { Download, X } from "lucide-react"; +import type { DraggableData } from "react-draggable"; +import Draggable from "react-draggable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { datesHave48HourRange } from "~/lib/utils"; +import { isBrowser, isTablet } from "react-device-detect"; +import { useTheme } from "remix-themes"; +import { AggregationFilter } from "../aggregation-filter"; +import { DateRangeFilter } from "../daterange-filter"; +import Spinner from "../spinner"; + +// Registering Chart.js components that will be used in the graph +ChartJS.register( + LineElement, + TimeScale, + CategoryScale, + LinearScale, + PointElement, + ChartTooltip, + Legend, +); + +export default function Graph(props: any) { + const loaderData = useLoaderData(); + const navigation = useNavigation(); + const [offsetPositionX, setOffsetPositionX] = useState(0); + const [offsetPositionY, setOffsetPositionY] = useState(0); + const [searchParams, setSearchParams] = useSearchParams(); + + // form submission handler + // const submit = useSubmit(); + // const [searchParams] = useSearchParams(); + + const nodeRef = useRef(null); + const chartRef = useRef>(null); + + // get theme from tailwind + const [theme] = useTheme(); + + const lineData = useMemo(() => { + // Helper function to construct the label with device name + const getLabel = (sensor: any, includeDeviceName: any) => { + return includeDeviceName + ? `${sensor.title} (${sensor.device_name})` + : sensor.title; + }; + + const includeDeviceName = + loaderData.selectedSensors.length === 2 && + loaderData.selectedSensors[0].device_name !== + loaderData.selectedSensors[1].device_name; + + return { + labels: loaderData.selectedSensors[0].data.map( + (measurement: LastMeasurementProps) => measurement.time, + ), + datasets: + loaderData.selectedSensors.length === 2 + ? [ + { + label: getLabel( + loaderData.selectedSensors[0], + includeDeviceName, + ), + data: loaderData.selectedSensors[0].data, + pointRadius: 0, + borderColor: loaderData.selectedSensors[0].color, + backgroundColor: loaderData.selectedSensors[0].color, + yAxisID: "y", + }, + { + label: getLabel( + loaderData.selectedSensors[1], + includeDeviceName, + ), + data: loaderData.selectedSensors[1].data, + pointRadius: 0, + borderColor: loaderData.selectedSensors[1].color, + backgroundColor: loaderData.selectedSensors[1].color, + yAxisID: "y1", + }, + ] + : [ + { + label: getLabel( + loaderData.selectedSensors[0], + includeDeviceName, + ), + data: loaderData.selectedSensors[0].data, + pointRadius: 0, + borderColor: loaderData.selectedSensors[0].color, + backgroundColor: loaderData.selectedSensors[0].color, + yAxisID: "y", + }, + ], + }; + }, [loaderData.selectedSensors]); + + const options: ChartOptions<"line"> = useMemo(() => { + return { + maintainAspectRatio: false, + responsive: true, + spanGaps: false, + interaction: { + mode: "index", + intersect: false, + }, + parsing: { + xAxisKey: "time", + yAxisKey: "value", + }, + scales: { + x: { + type: "time", + time: { + // display hour when timerange < 1 day and day when timerange > 1 day + unit: datesHave48HourRange( + new Date(loaderData.fromDate), + new Date(loaderData.toDate), + ) + ? "hour" + : "day", + displayFormats: { + day: "dd.MM.yyyy", + millisecond: "mm:ss", + second: "mm:ss", + minute: "HH:mm", + hour: "HH:mm", + }, + tooltipFormat: "dd.MM.yyyy HH:mm", + }, + adapters: { + date: { + locale: loaderData.locale === "de" ? de : enGB, + }, + }, + ticks: { + major: { + enabled: true, + }, + font: (context) => { + if (context.tick && context.tick.major) { + return { + weight: "bold", + }; + } + }, + maxTicksLimit: 8, + }, + grid: { + color: + theme === "dark" ? "rgba(255, 255, 255)" : "rgba(0, 0, 0, 0.1)", + borderColor: + theme === "dark" ? "rgba(255, 255, 255)" : "rgba(0, 0, 0, 0.1)", + }, + }, + y: { + title: { + display: true, + text: + loaderData.selectedSensors[0].title + + " in " + + loaderData.selectedSensors[0].unit, + }, + type: "linear", + display: true, + position: "left", + grid: { + color: + theme === "dark" ? "rgba(255, 255, 255)" : "rgba(0, 0, 0, 0.1)", + borderColor: + theme === "dark" ? "rgba(255, 255, 255)" : "rgba(0, 0, 0, 0.1)", + }, + }, + y1: { + title: { + display: true, + text: loaderData.selectedSensors[1] + ? loaderData.selectedSensors[1].title + + " in " + + loaderData.selectedSensors[1].unit + : "", //data.sensors[1].unit + }, + type: "linear", + display: "auto", + position: "right", + // grid line settings + grid: { + drawOnChartArea: false, // only want the grid lines for one axis to show up + }, + }, + }, + }; + }, [ + loaderData.fromDate, + loaderData.toDate, + loaderData.locale, + loaderData.selectedSensors, + theme, + ]); + + function handlePngDownloadClick() { + if (chartRef.current) { + if (chartRef.current === null) return; + // why is chartRef.current always never??? + const imageString = chartRef.current.canvas.toDataURL("image/png", 1.0); + saveAs(imageString, "chart.png"); + } + } + + function handleCsvDownloadClick() { + const labels = lineData.labels; + const dataset = lineData.datasets[0]; + + // header + let csvContent = "timestamp,deviceId,sensorId,value,unit,phenomena"; + csvContent += "\n"; + for (let i = 0; i < labels.length; i++) { + // timestamp + csvContent += `${labels[i]},`; + // deviceId + csvContent += `${loaderData.selectedSensors[0].deviceId},`; + // sensorId + csvContent += `${dataset?.data[i]?.sensorId},`; + // value + csvContent += `${dataset?.data[i]?.value},`; + // unit + csvContent += `${loaderData.selectedSensors[0].unit},`; + // phenomenon + csvContent += `${loaderData.selectedSensors[0].title}`; + // new line + csvContent += "\n"; + } + + // Creating a Blob and saving it as a CSV file + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + saveAs(blob, "chart_data.csv"); + } + + function handleDrag(_e: any, data: DraggableData) { + setOffsetPositionX(data.x); + setOffsetPositionY(data.y); + } + + return ( + <> + {props.openGraph && ( + +
+ {navigation.state === "loading" && ( +
+ +
+ )} +
+
+ + +
+
+ + + + + + + PNG + + {loaderData.selectedSensors.length < 2 && ( + + CSV + + )} + + + { + searchParams.delete("sensor"); + searchParams.delete("date_to"); + searchParams.delete("date_from"); + searchParams.delete("aggregation"); + setSearchParams(searchParams); + props.setOpenGraph(false); + }} + /> +
+
+
+ {(loaderData.selectedSensors[0].data.length === 0 && + loaderData.selectedSensors[1] === undefined) || + (loaderData.selectedSensors[0].data.length === 0 && + loaderData.selectedSensors[1].data.length === 0) ? ( +
There is no data for the selected time period.
+ ) : ( + + )} +
+
+
+ )} + + ); +} diff --git a/app/components/device-detail/profile-box-selection.tsx b/app/components/device-detail/profile-box-selection.tsx new file mode 100644 index 000000000..10aadbc36 --- /dev/null +++ b/app/components/device-detail/profile-box-selection.tsx @@ -0,0 +1,70 @@ +// import { useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; +import { + 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", + }, +]; + +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 + + +
+ +
+
+ + + +
+
+ ); +} diff --git a/app/components/device-detail/share-link.tsx b/app/components/device-detail/share-link.tsx new file mode 100644 index 000000000..3f8b5fa8f --- /dev/null +++ b/app/components/device-detail/share-link.tsx @@ -0,0 +1,100 @@ +/* eslint-disable jsx-a11y/anchor-has-content */ +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { useToast } from "@/components/ui/use-toast"; +import { Copy, Link } from "lucide-react"; + +export default function ShareLink() { + const { toast } = useToast(); + + return ( +
+
+ {/* */} +
+ + + +
+ {/* */} +
+ + + +
+ {/* */} +
+ + + + + +
+ {/* */} +
+ + + +
+ {/* */} +
+ + + +
+
+ {/* */} +
+ + + +
+
+ ); +} diff --git a/app/components/device/new/advanced/index.tsx b/app/components/device/new/advanced/index.tsx new file mode 100644 index 000000000..3cdfbb11e --- /dev/null +++ b/app/components/device/new/advanced/index.tsx @@ -0,0 +1,703 @@ +import { InfoIcon } from "lucide-react"; +import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Checkbox } from "~/components/ui/checkbox"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useField } from "remix-validated-form"; + +interface AdvancedProps { + data: any; +} + +export default function Advanced({ data }: AdvancedProps) { + const { t } = useTranslation("newdevice"); + + // ttn form fields + const [ttnEnabled, setTtnEnabled] = useState( + data["ttn.enabled"] === "on", + ); + const ttnAppIdField = useField("ttn.appId"); + const ttnDeviceIdField = useField("ttn.devId"); + const ttnDecodeProfileField = useField("ttn.decodeProfile"); + const [ttnDecodeProfile, setTtnDecodeProfile] = useState( + data["ttn.decodeProfile"] ? data["ttn.decodeProfile"] : undefined, + ); + const ttnDecodeOptionsField = useField("ttn.decodeOptions"); + const ttnPortField = useField("ttn.port"); + + // mqtt form fields + const [mqttEnabled, setMqttEnabled] = useState( + data.mqttEnabled === "on", + ); + const mqttUrlField = useField("mqtt.url"); + const mqttTopicField = useField("mqtt.topic"); + const mqttFormatField = useField("mqtt.messageFormat"); + const mqttDecodeOptionsField = useField("mqtt.decodeOptions"); + const mqttConnectOptionsField = useField("mqtt.connectOptions"); + + return ( +
+
+

+ {t("advanced_text")} +

+
+
+
+

TheThingsNetwork - TTN

+ {/* {hasObjPropMatchWithPrefixKey(formContext.fieldErrors, [ + "ttn", + ]) ? ( + + ) : null} */} +
+
+ + + Info + + + placeholder + , + + placeholder + , + ]} + /> + + +
+
+ { + checked === "indeterminate" + ? setTtnEnabled(undefined) + : setTtnEnabled(checked); + }} + /> + +
+ {ttnEnabled ? ( +
+
+ +
+
+ +
+ {ttnAppIdField.error && ( + + {ttnAppIdField.error} + + )} +
+
+ +
+ +
+
+ +
+ {ttnDeviceIdField.error && ( + + {ttnDeviceIdField.error} + + )} +
+
+ +
+ +
+
+ +
+ {ttnDecodeProfileField.error && ( + + {ttnDecodeProfileField.error} + + )} +
+
+ +
+ +
+
+ +
+ {ttnDecodeOptionsField.error && ( + + {ttnDecodeOptionsField.error} + + )} +
+
+ +
+ +
+
+ +
+ {ttnPortField.error && ( + + {ttnPortField.error} + + )} +
+
+
+ ) : null} +
+
+
+

MQTT

+ {/* {hasObjPropMatchWithPrefixKey(formContext.fieldErrors, [ + "mqtt", + ]) ? ( + + ) : null} */} +
+
+ + + Info + + + placeholder + , + + placeholder + , + ]} + /> + + +
+
+ { + checked === "indeterminate" + ? setMqttEnabled(undefined) + : setMqttEnabled(checked); + // if (checked === false) { + // formContext.validateField("mqtt"); + // } + }} + /> + +
+ {mqttEnabled ? ( +
+
+ +
+
+ +
+ {mqttUrlField.error && ( + + {mqttUrlField.error} + + )} +
+
+ +
+ +
+
+ +
+ {mqttTopicField.error && ( + + {mqttTopicField.error} + + )} +
+
+ +
+
+ +
+
+
+

+ The file format your data will be transferred in. +

+
+
+ + +
+
+ + +
+
+ {mqttFormatField.error && ( + + {mqttFormatField.error} + + )} +
+
+
+ +
+ +
+
+ +
+ {mqttDecodeOptionsField.error && ( + + {mqttDecodeOptionsField.error} + + )} +
+
+ +
+ +
+
+ +
+ {mqttConnectOptionsField.error && ( + + {mqttConnectOptionsField.error} + + )} +
+
+
+ ) : null} +
+
+
+ ); +} diff --git a/app/components/device/new/general/index.tsx b/app/components/device/new/general/index.tsx new file mode 100644 index 000000000..86901b12d --- /dev/null +++ b/app/components/device/new/general/index.tsx @@ -0,0 +1,206 @@ +import { InfoIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { useField } from "remix-validated-form"; +import TagsInput from "react-tagsinput"; +import "react-tagsinput/react-tagsinput.css"; +import { useState } from "react"; + +export interface GeneralProps { + data: any; +} + +export default function General({ data }: GeneralProps) { + const { t } = useTranslation("newdevice"); + + const nameField = useField("name"); + const exposureField = useField("exposure"); + const groupIdField = useField("groupId"); + + const [tags, setTags] = useState( + data.groupId ? data.groupId.split(", ") : [], + ); + + const handleChange = (tags: any) => { + setTags(tags); + }; + + return ( +
+
+

+ {t("general_text")} +

+
+ +
+ + + Info + {t("general_info_text")} + +
+ +
+
+ +
+
+ +
+ {nameField.error && ( + + {nameField.error} + + )} +
+
+ +
+
+
+ {t("exposure")} +
+
+
+
+

+ {t("exposure_explaination")} +

+
+
+ + +
+
+ + +
+
+ + +
+
+ {exposureField.error && ( + + {exposureField.error} + + )} +
+
+
+ +
+ +
+
+ 0 ? tags.join(", ") : ""} + className="hidden" + disabled={tags.length === 0} + /> + +
+ {groupIdField.error && ( + + {groupIdField.error} + + )} +
+
+
+
+ ); +} diff --git a/app/components/device/new/select-device/index.tsx b/app/components/device/new/select-device/index.tsx new file mode 100644 index 000000000..501b4de62 --- /dev/null +++ b/app/components/device/new/select-device/index.tsx @@ -0,0 +1,169 @@ +import { CheckCircleIcon } from "@heroicons/react/24/outline"; +import { InfoIcon } from "lucide-react"; +import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { AspectRatio } from "~/components/ui/aspect-ratio"; +import { Card, CardContent, CardFooter, CardTitle } from "~/components/ui/card"; +import { sensorWikiLabel } from "~/utils/sensor-wiki-helper"; +import { useField } from "remix-validated-form"; +import SensorWikHoverCard from "~/components/sensor-wiki-hover-card"; + +interface SelectDeviceProps { + data: any; +} + +export default function SelectDevice({ data }: SelectDeviceProps) { + const [deviceType, setDeviceType] = useState(data.data.type); + const { t } = useTranslation("newdevice"); + + const deviceTypeField = useField("type"); + + return ( +
+
+ {deviceTypeField.error && ( + {deviceTypeField.error} + )} +

+ {t("select_device_text")} +

+
+ +
+ {data.devices.map((device: any) => { + return ( + { + setDeviceType(device.slug); + deviceTypeField.validate(); + }} + className="relative data-[checked=true]:ring-2 data-[checked=true]:ring-light-green dark:data-[checked=true]:ring-dark-green cursor-pointer dark:bg-dark-boxes dark:text-dark-text" + > + + + {device.slug} + + + + {sensorWikiLabel(device.label.item)} + {deviceType === device.slug && ( + + )} + + + } + /> + ); + })} + + setDeviceType("own_device")} + className="relative data-[checked=true]:ring-2 data-[checked=true]:ring-light-green dark:data-[checked=true]:ring-dark-green cursor-pointer dark:bg-dark-boxes dark:text-dark-text" + > + + + {/* own:device */} + + + + {t("own_device")} + {deviceType === "own_device" && ( + + )} + + +
+ + + +
+ + + Info + + + placeholder + , + ]} + /> + + +
+
+ ); +} diff --git a/app/components/device/new/select-location/index.tsx b/app/components/device/new/select-location/index.tsx new file mode 100644 index 000000000..64022a288 --- /dev/null +++ b/app/components/device/new/select-location/index.tsx @@ -0,0 +1,330 @@ +import React, { useCallback, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { + Map, + type MapLayerMouseEvent, + type MapRef, + Marker, + type MarkerDragEvent, + NavigationControl, + GeolocateControl, + type GeolocateResultEvent, + Source, +} from "react-map-gl"; + +import GeocoderControl from "~/components/map/geocoder-control"; +import { InfoIcon } from "lucide-react"; +import getUserLocale from "get-user-locale"; +import { useField } from "remix-validated-form"; + +export interface SelectLocationProps { + data: any; +} + +//********************************** +export default function SelectLocation({ data }: SelectLocationProps) { + const { t } = useTranslation("newdevice"); + const mapRef = useRef(null); + const userLocaleString = getUserLocale()?.toString() || "en"; + + const latitudeField = useField("latitude"); + const longitudeField = useField("longitude"); + const heightField = useField("height"); + + //* map view + const [viewState, setViewState] = React.useState({ + latitude: data.data.latitude ? data.data.latitude : 51, + longitude: data.data.longitude ? data.data.longitude : 10, + zoom: 3.5, + }); + + //* map marker + const [marker, setMarker] = useState({ + latitude: data.data.latitude ? data.data.latitude : "", + longitude: data.data.longitude ? data.data.longitude : "", + }); + + //* location height + const [height, setHeight] = useState( + data.data.height ? data.data.height : "", + ); + + //* height derivation helper function + const heightDerivation = useCallback( + (lng: number, lat: number) => { + const elevation = mapRef.current?.queryTerrainElevation([lng, lat]); + setHeight(elevation ? Math.round(elevation * 100) / 100 : ""); + setTimeout(() => heightField.validate(), 0); + }, + [heightField], + ); + + //* on-marker-drag event + const onMarkerDrag = useCallback( + (event: MarkerDragEvent) => { + // console.log(event); + setMarker({ + longitude: Math.round(event.lngLat.lng * 1000000) / 1000000, + latitude: Math.round(event.lngLat.lat * 1000000) / 1000000, + }); + heightDerivation(event.lngLat.lng, event.lngLat.lat); + }, + [heightDerivation], + ); + + //* on-geolocate event + const onGeolocate = useCallback( + (event: GeolocateResultEvent) => { + // console.log(event); + setMarker({ + longitude: Math.round(event.coords.longitude * 1000000) / 1000000, + latitude: Math.round(event.coords.latitude * 1000000) / 1000000, + }); + mapRef.current?.on("moveend", () => { + heightDerivation(event.coords.longitude, event.coords.latitude); + }); + }, + [heightDerivation], + ); + + //* on-geocoder-result event + const onResult = (event: any) => { + // console.log(event); + setMarker({ + longitude: + Math.round(event.result.geometry.coordinates[0] * 1000000) / 1000000, + latitude: + Math.round(event.result.geometry.coordinates[1] * 1000000) / 1000000, + }); + mapRef.current?.on("moveend", () => { + heightDerivation( + event.result.geometry.coordinates[0], + event.result.geometry.coordinates[1], + ); + }); + }; + + //* on-map-click event + const onClick = (event: MapLayerMouseEvent) => { + // console.log(event); + setMarker({ + longitude: Math.round(event.lngLat.lng * 1000000) / 1000000, + latitude: Math.round(event.lngLat.lat * 1000000) / 1000000, + }); + heightDerivation(event.lngLat.lng, event.lngLat.lat); + }; + + return ( +
+
+

+ {t("location_text")} +

+
+ + {/* Map view */} +
+ setViewState(evt.viewState)} + onClick={onClick} + mapStyle="mapbox://styles/mapbox/streets-v12" + mapboxAccessToken={ENV.MAPBOX_ACCESS_TOKEN} + style={{ + width: "100%", + height: "55vh", + }} + terrain={{ + source: "mapbox-dem", + }} + > + + {marker.latitude ? ( + + ) : null} + + + + +
+ + {/* Latitude, Longitude */} +
+
+
+
+ + {latitudeField.error && ( + {latitudeField.error} + )} +
+ +
+ { + if ( + Number(e.target.value) >= -90 && + Number(e.target.value) <= 90 + ) { + setMarker({ + latitude: e.target.value, + longitude: marker.longitude, + }); + latitudeField.validate(); + } + }} + aria-describedby="name-error" + className={ + "w-full rounded border border-gray-200 px-2 py-1 text-base dark:bg-dark-boxes" + + (!marker.latitude + ? " border-[#FF0000] shadow-[#FF0000] focus:border-[#FF0000] focus:shadow focus:shadow-[#FF0000] " + : "") + } + /> +
+
+ +
+
+ + {longitudeField.error && ( + + {longitudeField.error} + + )} +
+ +
+ { + if ( + Number(e.target.value) >= -180 && + Number(e.target.value) <= 180 + ) { + setMarker({ + latitude: marker.latitude, + longitude: e.target.value, + }); + longitudeField.validate(); + } + }} + aria-describedby="name-error" + className={ + "w-full rounded border border-gray-200 px-2 py-1 text-base dark:bg-dark-boxes" + + (!marker.longitude + ? " border-[#FF0000] shadow-[#FF0000] focus:border-[#FF0000] focus:shadow focus:shadow-[#FF0000] " + : "") + } + /> +
+
+ +
+
+ + {heightField.error && ( + {heightField.error} + )} +
+
+ { + if ( + Number(e.target.value) >= -200 && + Number(e.target.value) <= 10000 + ) { + setHeight(e.target.value); + heightField.validate(); + } + }} + aria-describedby="name-error" + className="w-full rounded border border-gray-200 px-2 py-1 text-base dark:bg-dark-boxes" + /> +
+
+
+
+
+ ); +} diff --git a/app/components/device/new/select-sensors/index.tsx b/app/components/device/new/select-sensors/index.tsx new file mode 100644 index 000000000..231d9a480 --- /dev/null +++ b/app/components/device/new/select-sensors/index.tsx @@ -0,0 +1,272 @@ +import { PlusCircleIcon, XCircleIcon } from "@heroicons/react/24/outline"; +import { useState } from "react"; +import { AspectRatio } from "~/components/ui/aspect-ratio"; +import { Card, CardContent, CardFooter, CardTitle } from "~/components/ui/card"; +import { sensorWikiLabel } from "~/utils/sensor-wiki-helper"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Trans, useTranslation } from "react-i18next"; +import { useField } from "remix-validated-form"; +import SensorWikHoverCard from "~/components/sensor-wiki-hover-card"; +import { InfoIcon } from "lucide-react"; + +interface SelectSensorsProps { + data: any; +} + +export default function SelectSensors({ data }: SelectSensorsProps) { + const [addedSensors, setAddedSensors] = useState(data.data.sensors ?? {}); + const { t } = useTranslation("newdevice"); + + const sensorsField = useField("sensors"); + + function addSensor(sensorItem: any, key: string) { + //this is an array because objects would not work with the forms [sensorSlug, phenomenonId, unitSlug] + let sensorArray = ["", sensorItem.sensor.slug, sensorItem.phenomenonId, ""]; + const newSensorObject = { ...addedSensors }; + if (newSensorObject[key]) { + newSensorObject[key].push(sensorArray); + } else { + newSensorObject[key] = [sensorArray]; + } + setAddedSensors(newSensorObject); + } + + function deleteSensorItem(index: any, key: string) { + const newSensorObject = { ...addedSensors }; + newSensorObject["p-" + key].splice(index, 1); + setAddedSensors(newSensorObject); + } + + return ( +
+
+ {sensorsField.error && ( + {sensorsField.error} + )} +

+ {t("select_sensors_text")} +

+
+ +
+ + + Info + + + placeholder + , + ]} + /> + + +
+ +
+ {Object.entries(data.groupedSensors).map(([key, value]) => { + const phenomenonSlug: string = data.phenomena.find( + (pheno: any) => pheno.id == key, + ).slug; + return ( +
+ + {sensorWikiLabel( + data.phenomena.find((pheno: any) => pheno.id == key).label + .item, + )} + + } + /> +
+
+ {/** @ts-ignore */} + {value.map((sensor: any) => { + return ( + { + addSensor(sensor, "p-" + key); + sensorsField.validate(); + }} + key={sensor.id} + className="relative hover:cursor-pointer hover:ring-2 hover:ring-light-green data-[checked=true]:ring-4 data-[checked=true]:ring-light-green" + > + + + {sensor.sensor.slug} + + + + + {sensor.sensor.label.item[0].text} + + + + + } + /> + ); + })} +
+
{JSON.stringify(data.json, null, 2)}
+
+ {addedSensors["p-" + key] && + addedSensors["p-" + key].length > 0 && ( +
+

+ {t("your_added")}{" "} + {sensorWikiLabel( + data.phenomena.find((pheno: any) => pheno.id == key) + .label.item, + )}{" "} + {t("sensors")} +

+ + + + {t("title")} + + {t("sensor")} + + {t("phenomenon")} + {t("unit")} + {t("delete")} + + + + {addedSensors["p-" + key].map( + (sensorItem: any, index: number) => { + return ( + + + + + + pheno.id == key, + ).slug + } + checked={true} + readOnly + className="hidden" + /> + + {sensorItem[1]} + + + {sensorWikiLabel( + data.phenomena.find( + (pheno: any) => pheno.id == key, + ).label.item, + )} + + + {" "} + + + { + deleteSensorItem(index, key); + sensorsField.validate(); + }} + title="Delete Sensor" + className="h-10 w-10 cursor-pointer text-red-500" + > + + + ); + }, + )} + +
+
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/app/components/device/new/summary/index.tsx b/app/components/device/new/summary/index.tsx new file mode 100644 index 000000000..a1e47f0e1 --- /dev/null +++ b/app/components/device/new/summary/index.tsx @@ -0,0 +1,191 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useTranslation } from "react-i18next"; +import { sensorWikiLabel } from "~/utils/sensor-wiki-helper"; + +interface SummaryProps { + data: any; + phenomena: any; +} + +export default function Summary({ data, phenomena }: SummaryProps) { + const { t } = useTranslation("newdevice"); + return ( + <> +
+
+

+ {t("summary_text")} +

+
+ +

{t("summary_general")}

+ + + + + Type + {data.type} + + + Name + {data.name} + + + Exposure + {data.exposure} + + + Tags + {data.groupId} + + + Latitude + {data.latitude} + + + Longitude + {data.longitude} + + + {t("height")} + {data.height} + + +
+ +

{t("summary_sensors")}

+ + {/* General Information */} + + + {t("title")} + {t("sensor")} + {t("phenomenon")} + {t("unit")} + + + + {Object.values(data.sensors).map((phenomenon: any) => { + return phenomenon.map((sensor: any, index: any) => { + return ( + + {sensor[0]} + {sensor[1]} + + {sensorWikiLabel( + phenomena.find((pheno: any) => pheno.slug == sensor[2]) + .label.item, + )} + + {sensor[3]} + + ); + }); + })} + +
+ +

Your Extensions

+ {data["ttn.enabled"] && ( +
+
TTN
+ + {/* General Information */} + + + App ID + + {data["ttn.appId"]} + + + + Device ID + + {data["ttn.devId"]} + + + + Decode Profile + + {data["ttn.decodeProfile"]} + + + {!data["ttn.decodeOptions"] || + (data["ttn.decodeOptions"] !== "" && ( + + + Decode Options + + + {data["ttn.decodeOptions"]} + + + ))} + {data["ttn.port"] !== "" && ( + + Port + + {data["ttn.port"]} + + + )} + +
+
+ )} + + {data.mqttEnabled && ( +
+
MQTT
+ + {/* General Information */} + + + URL + + {data["mqtt.url"]} + + + + Topic + + {data["mqtt.topic"]} + + + + Message Format + + {data["mqtt.messageFormat"]} + + + + Decode Options + + {data["mqtt.decodeOptions"]} + + + + + Connection Options + + + {data["mqtt.connectOptions"]} + + + +
+
+ )} + +
{JSON.stringify(data, null, 2)}
+
+ + ); +} diff --git a/app/components/error-boundary.tsx b/app/components/error-boundary.tsx new file mode 100644 index 000000000..21d4a5eb5 --- /dev/null +++ b/app/components/error-boundary.tsx @@ -0,0 +1,44 @@ +import { + isRouteErrorResponse, + useParams, + useRouteError, +} from "@remix-run/react"; +import { type ErrorResponse } from "@remix-run/router"; +import { getErrorMessage } from "~/utils/misc"; + +type StatusHandler = (info: { + error: ErrorResponse; + params: Record; +}) => JSX.Element | null; + +export function GeneralErrorBoundary({ + defaultStatusHandler = ({ error }) => ( +

+ {error.status} {error.data} +

+ ), + statusHandlers, + unexpectedErrorHandler = (error) =>

{getErrorMessage(error)}

, +}: { + defaultStatusHandler?: StatusHandler; + statusHandlers?: Record; + unexpectedErrorHandler?: (error: unknown) => JSX.Element | null; +}) { + const error = useRouteError(); + const params = useParams(); + + if (typeof document !== "undefined") { + console.error(error); + } + + return ( +
+ {isRouteErrorResponse(error) + ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({ + error, + params, + }) + : unexpectedErrorHandler(error)} +
+ ); +} diff --git a/app/components/error-message.tsx b/app/components/error-message.tsx new file mode 100644 index 000000000..19cb053a2 --- /dev/null +++ b/app/components/error-message.tsx @@ -0,0 +1,29 @@ +import { X } from "lucide-react"; +import { Alert, AlertDescription } from "./ui/alert"; +import { useNavigate } from "@remix-run/react"; + +export default function ErrorMessage() { + let navigate = useNavigate(); + const goBack = () => navigate(-1); + + return ( + +
+ { + goBack(); + }} + /> +
+

+ Oh no, this shouldn't happen, but don't worry, our team is on the case! +

+ +

+ Add some info here. +

+
+
+ ); +} diff --git a/app/components/header/home/index.tsx b/app/components/header/home/index.tsx new file mode 100644 index 000000000..2882b23ec --- /dev/null +++ b/app/components/header/home/index.tsx @@ -0,0 +1,13 @@ +import { Link } from "@remix-run/react"; + +export default function Home() { + return ( +
+ + + +
+ ); +} \ No newline at end of file diff --git a/app/components/header/index.tsx b/app/components/header/index.tsx new file mode 100644 index 000000000..ec91bff91 --- /dev/null +++ b/app/components/header/index.tsx @@ -0,0 +1,24 @@ +import Home from "./home"; +import NavBar from "./nav-bar"; +import Menu from "./menu"; +import { useLoaderData } from "@remix-run/react"; +import Notification from "./notification"; +import type { loader } from "~/routes/explore.$deviceId._index"; + +interface HeaderProps { + devices: any; +} + +export default function Header(props: HeaderProps) { + const data = useLoaderData(); + return ( +
+ + +
+ {data?.user?.email ? : null} + +
+
+ ); +} diff --git a/app/components/header/menu/index.tsx b/app/components/header/menu/index.tsx new file mode 100644 index 000000000..df6877aca --- /dev/null +++ b/app/components/header/menu/index.tsx @@ -0,0 +1,307 @@ +import { + Form, + Link, + useNavigation, + useSearchParams, + useLoaderData, +} from "@remix-run/react"; +// import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { useToast } from "@/components/ui/use-toast"; +import type { loader } from "~/routes/explore"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Spinner from "~/components/spinner"; +import { + Globe, + LogIn, + LogOut, + PlusCircle, + Puzzle, + Menu as MenuIcon, + Cpu, + Settings, + Mail, + Fingerprint, + FileLock2, + Coins, + User2, + ExternalLink, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip"; + +export function useFirstRender() { + const firstRender = useRef(true); + + useEffect(() => { + firstRender.current = false; + }, []); + + return firstRender.current; +} + +export default function Menu() { + const [searchParams] = useSearchParams(); + const redirectTo = + searchParams.size > 0 ? "/explore?" + searchParams.toString() : "/explore"; + const data = useLoaderData(); + const [open, setOpen] = useState(false); + const { toast } = useToast(); + const navigation = useNavigation(); + const isLoggingOut = Boolean(navigation.state === "submitting"); + const [timeToToast, setTimeToToast] = useState(false); + + const { t } = useTranslation("menu"); + + const firstRender = useFirstRender(); + + useEffect(() => { + if (!firstRender && !timeToToast) { + setTimeToToast(true); + } else if (!firstRender && timeToToast) { + if (data.user === null) { + toast({ + description: t("toast_logout_success"), + }); + } + if (data.user !== null) { + const creationDate = Date.parse(data.user.createdAt); + const now = Date.now(); + const diff = now - creationDate; + if (diff < 10000) { + toast({ + description: t("toast_user_creation_success"), + }); + setTimeout(() => { + toast({ + description: t("toast_login_success"), + }); + }, 100); + } else { + toast({ + description: t("toast_login_success"), + }); + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.user, toast, firstRender]); + + return ( + + +
+ +
+
+ +
+ + {data.user === null ? ( +
+

{t("title")}

+

+ {t("subtitle")} +

+
+ ) : ( +
+

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

+

+ {data.user.email} +

+
+ )} +
+ + {data.user !== null ? ( + + {navigation.state === "loading" && ( +
+ +
+ )} + {data.profile && ( + + + Profile + + )} + + + + + {t("settings_label")} + + + + + + + {t("my_devices_label")} + + + + + + + {t("add_device_label")} + + + +
+ ) : null} + + + + + {t("tutorials_label")} + + + + + + + + {t("api_docs_label")} + + + + + + + + + + {t("contact_label")} + + + + + + {t("imprint_label")} + + + + + + {t("data_protection_label")} + + + + + + + + e.preventDefault()} + className="cursor-pointer" + > + + {t("donate_label")} + + + + + + + + + + + + + {data.user === null ? ( + setOpen(false)} + > + + + ) : ( +
{ + setOpen(false); + // toast({ + // description: "Logging out ...", + // }); + }} + > + + +
+ )} +
+
+ +

Coming soon...

+
+
+
+
+
+
+
+ ); +} diff --git a/app/components/header/menu/my-devices/index.tsx b/app/components/header/menu/my-devices/index.tsx new file mode 100644 index 000000000..ba8ec7e13 --- /dev/null +++ b/app/components/header/menu/my-devices/index.tsx @@ -0,0 +1,32 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface MyDevicesDialogProps { + isMyDevicesDialogOpen: boolean; + setIsMyDevicesDialogOpen: (value: boolean) => void; +} + +export default function MyDevicesDialog(props: MyDevicesDialogProps) { + return ( +
+ + + + My Devices + + Here you can see all your devices. + + + + +
+ ); +} diff --git a/app/components/header/menu/profile/index.tsx b/app/components/header/menu/profile/index.tsx new file mode 100644 index 000000000..7ca83bbde --- /dev/null +++ b/app/components/header/menu/profile/index.tsx @@ -0,0 +1,32 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface ProfileDialogProps { + isProfileDialogOpen: boolean; + setIsProfileDialogOpen: (value: boolean) => void; +} + +export default function ProfileDialog(props: ProfileDialogProps) { + return ( +
+ + + + Profile + + Here you can edit your profile information. + + + + +
+ ); +} diff --git a/app/components/header/menu/user-settings/index.tsx b/app/components/header/menu/user-settings/index.tsx new file mode 100644 index 000000000..bf34376ad --- /dev/null +++ b/app/components/header/menu/user-settings/index.tsx @@ -0,0 +1,32 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface UserSettingsDialogProps { + isSettingsDialogOpen: boolean; + setIsSettingsDialogOpen: (value: boolean) => void; +} + +export default function UserSettingsDialog(props: UserSettingsDialogProps) { + return ( +
+ + + + Settings + + Here you can edit your user settings + + + + +
+ ); +} diff --git a/app/components/header/nav-bar/filter-options/filter-options.tsx b/app/components/header/nav-bar/filter-options/filter-options.tsx new file mode 100644 index 000000000..4cc9b658f --- /dev/null +++ b/app/components/header/nav-bar/filter-options/filter-options.tsx @@ -0,0 +1,135 @@ +import { + useSearchParams, + useNavigation, + useLoaderData, +} from "@remix-run/react"; +import { X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "~/components/ui/button"; +import { Label } from "~/components/ui/label"; +import Spinner from "../../../spinner"; +import type { loader } from "~/routes/explore"; +import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group"; + +export default function FilterOptions() { + const data = useLoaderData(); + //* searchParams hook + const [searchParams, setSearchParams] = useSearchParams(); + const navigation = useNavigation(); + + //* Set initial filter params based on url Search Params + const [exposureVal, setExposureVal] = useState( + searchParams.get("exposure") ?? null, + ); + const [statusVal, setStatusVal] = useState( + searchParams.get("status") ?? null, + ); + const [, setPhenomenonVal] = useState(searchParams.get("phenomenon") ?? null); + + //* Update filter params based on url Search Params + useEffect(() => { + setExposureVal(searchParams.get("exposure") ?? null); + setStatusVal(searchParams.get("status") ?? null); + setPhenomenonVal(searchParams.get("phenomenon") ?? null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + return ( +
+ {navigation.state === "loading" && ( +
+ +
+ )} +
+
+ + { + if (value === "all") { + searchParams.delete("exposure"); + } else if (value === "") { + searchParams.delete("exposure"); + } else { + searchParams.set("exposure", value); + } + setSearchParams(searchParams); + }} + > + + all + + + indoor + + + outdoor + + + mobile + + +
+
+ + { + if (value === "all") { + searchParams.delete("status"); + } else if (value === "") { + searchParams.delete("status"); + } else { + searchParams.set("status", value); + } + setSearchParams(searchParams); + }} + > + + all + + + active + + + inactive + + + old + + +
+
+
+ + +
+
+ ); +} diff --git a/app/components/header/nav-bar/index.tsx b/app/components/header/nav-bar/index.tsx new file mode 100644 index 000000000..31a4a6be5 --- /dev/null +++ b/app/components/header/nav-bar/index.tsx @@ -0,0 +1,108 @@ +import { useState, useEffect, useRef, createContext } from "react"; +import { useMap } from "react-map-gl"; +import NavbarHandler from "./nav-bar-handler"; +import { AnimatePresence, motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import { SearchIcon, XIcon } from "lucide-react"; +import type { Device } from "~/schema"; + +interface NavBarProps { + devices: Device[]; +} + +export const NavbarContext = createContext({ + open: false, + setOpen: (_open: boolean) => {}, +}); + +export default function NavBar(props: NavBarProps) { + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + const [searchString, setSearchString] = useState(""); + + const { osem: mapRef } = useMap(); + + const { t } = useTranslation("search"); + + useEffect(() => { + if (mapRef) { + mapRef.on("click", () => setOpen(false)); + } + }, [mapRef]); + + // register keyboard shortcuts + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((prevState) => !prevState); + } + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); + + // focus input when opening + useEffect(() => { + if (open) { + inputRef.current?.focus(); + } else { + inputRef.current?.blur(); + setSearchString(""); + } + }, [open]); + + return ( +
+
+
+ + setOpen(true)} + onChange={(e) => setSearchString(e.target.value)} + className="h-fit w-full flex-1 border-none focus:border-none bg-white focus:outline-none focus:ring-0 dark:bg-zinc-800 dark:text-zinc-200" + value={searchString} + /> + {!open && ( + + ctrl + K + + )} + {open && ( + { + setSearchString(""); + setOpen(false); + inputRef.current?.blur(); + }} + className="h-6" + /> + )} +
+ + + {open && ( + + + + )} + + +
+
+ ); +} diff --git a/app/components/header/nav-bar/nav-bar-handler.tsx b/app/components/header/nav-bar/nav-bar-handler.tsx new file mode 100644 index 000000000..1af5f53df --- /dev/null +++ b/app/components/header/nav-bar/nav-bar-handler.tsx @@ -0,0 +1,80 @@ +import type { Device } from "~/schema"; +import Search from "~/components/search"; +import { Clock4Icon, Cog, Filter, IceCream2Icon } from "lucide-react"; +import useKeyboardNav from "./use-keyboard-nav"; +import { cn } from "~/lib/utils"; +import FilterOptions from "./filter-options/filter-options"; +import { PhenomenonSelect } from "./phenomenon-select/phenomenon-select"; + +interface NavBarHandlerProps { + devices: Device[]; + searchString: string; +} + +function getSections(devices: Device[]) { + return [ + { + title: "Datum & Zeit", + icon: Clock4Icon, + color: "bg-blue-100", + component:
Datum & Zeit
, + }, + { + title: "Filter", + icon: Filter, + color: "bg-gray-300", + component: , + }, + { + title: "Phänomen", + icon: IceCream2Icon, + color: "bg-slate-500", + component: , + }, + { + title: "Einstellungen", + icon: Cog, + color: "bg-light-green", + component:
Einstellungen
, + }, + ]; +} + +export default function NavbarHandler({ + devices, + searchString, +}: NavBarHandlerProps) { + const sections = getSections(devices); + + const { cursor, setCursor } = useKeyboardNav(0, 0, sections.length); + + if (searchString.length >= 2) { + return ; + } + + return ( +
+
+ {sections.map((section, index) => ( +
{ + setCursor(index); + }} + > + + {section.title} +
+ ))} +
+
{sections[cursor].component}
+
+ ); +} diff --git a/app/components/header/nav-bar/phenomenon-select/phenomenon-select.tsx b/app/components/header/nav-bar/phenomenon-select/phenomenon-select.tsx new file mode 100644 index 000000000..4b311d5dd --- /dev/null +++ b/app/components/header/nav-bar/phenomenon-select/phenomenon-select.tsx @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + useLoaderData, + useNavigation, + useSearchParams, +} from "@remix-run/react"; +// import { useTranslation } from "react-i18next"; +import type { SensorWikiLabel } from "~/utils/sensor-wiki-helper"; +import { sensorWikiLabel } from "~/utils/sensor-wiki-helper"; +import type { Key } from "react"; +import { useState, useEffect } from "react"; // Changed import +import { Label } from "~/components/ui/label"; +import Spinner from "~/components/spinner"; +import { X } from "lucide-react"; +import { Checkbox } from "~/components/ui/checkbox"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { Button } from "~/components/ui/button"; +import type { loader } from "~/routes/explore"; + +export function PhenomenonSelect() { + const data = useLoaderData(); + // const { t } = useTranslation("navbar"); + const loaderData = useLoaderData(); + const navigation = useNavigation(); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedCheckboxes, setSelectedCheckboxes] = useState([]); // Added state for selected checkboxes + + useEffect(() => { + // When URL parameters change, update selected checkboxes accordingly + const phenomenonParam = searchParams.get("phenomenon") || "all"; + const phenomenonArray = + phenomenonParam === "all" ? [] : phenomenonParam.split(","); + setSelectedCheckboxes(phenomenonArray); + }, [searchParams]); + + const handleCheckboxChange = (slug: string) => { + const updatedCheckboxes = selectedCheckboxes.includes(slug) + ? selectedCheckboxes.filter((checkbox) => checkbox !== slug) + : [...selectedCheckboxes, slug]; + + setSelectedCheckboxes(updatedCheckboxes); + + // Update URL search parameters + if (updatedCheckboxes.length === 0) { + searchParams.delete("phenomenon"); + } else { + searchParams.set("phenomenon", updatedCheckboxes.join(",")); + } + setSearchParams(searchParams); + }; + + return ( +
+ {navigation.state === "loading" && ( +
+ +
+ )} + {/* +
+ {loaderData.phenomena.map( + ( + p: { slug: string; label: { item: SensorWikiLabel[] } }, + i: Key | null | undefined, + ) => ( +
+ handleCheckboxChange(p.slug)} + /> + +
+ ), + )} +
+
+
+ + +
*/} +
Coming soon
+
+ ); +} diff --git a/app/components/header/nav-bar/sensor-filter.tsx b/app/components/header/nav-bar/sensor-filter.tsx new file mode 100644 index 000000000..0a2bf5255 --- /dev/null +++ b/app/components/header/nav-bar/sensor-filter.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import { CloudSunRain, SunIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { Form } from "@remix-run/react"; +import { useTranslation } from "react-i18next"; +import { cn } from "~/lib/utils"; +import { sensorWikiLabel } from "~/utils/sensor-wiki-helper"; +import { type Phenomenon } from "~/models/phenomena.server"; + +interface SensorFilterProps { + className?: React.HTMLAttributes["className"]; + + sensor: string | undefined; + setSensor: (sensor: string | undefined) => void; + isDialogOpen: boolean; + setIsDialogOpen: (open: boolean) => void; + + setIsHovered: (hovered: boolean) => void; + phenomena: Phenomenon[]; +} + +export function SensorFilter(props: SensorFilterProps, request: Request) { + const { t } = useTranslation("navbar"); + + return ( +
+ + + + + props.setIsHovered(false)} + > +
+
    +
  • + +
  • + {props.phenomena.map((p, i) => { + return ( +
  • + +
  • + ); + })} +
+
+
+
{ + props.setIsDialogOpen(false); + }} + > + + + +
+
+
+
+
+ ); +} diff --git a/app/components/header/nav-bar/time-filter/index.tsx b/app/components/header/nav-bar/time-filter/index.tsx new file mode 100644 index 000000000..45fab643d --- /dev/null +++ b/app/components/header/nav-bar/time-filter/index.tsx @@ -0,0 +1,426 @@ +"use client"; + +import * as React from "react"; +// import { useSearchParams, useSubmit } from "@remix-run/react"; +import { format } from "date-fns"; +import { de, enGB } from "date-fns/locale"; +import { CalendarIcon } from "@heroicons/react/24/outline"; +import { Clock, CalendarSearch, CalendarClock } from "lucide-react"; +import type { DateRange } from "react-day-picker"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Form } from "@remix-run/react"; +import { useToast } from "@/components/ui/use-toast"; + +import { getUserLocale } from "get-user-locale"; +import { useTranslation } from "react-i18next"; + +interface TimeFilterProps { + className?: React.HTMLAttributes["className"]; + + dateRange: DateRange | undefined; + setDateRange: (date: DateRange | undefined) => void; + + singleDate: Date | undefined; + setSingleDate: (date: Date | undefined) => void; + + isDialogOpen: boolean; + setIsDialogOpen: (open: boolean) => void; + + setIsHovered: (hovered: boolean) => void; + + timeState: string | undefined; + setTimeState: (value: string) => void; + + onChange: (timerange: any) => void; + value: any; +} + +export function TimeFilter(props: TimeFilterProps) { + // const submit = useSubmit(); + // const [searchParams] = useSearchParams(); + const { toast } = useToast(); + + const { t } = useTranslation("navbar"); + const userLocaleString = getUserLocale(); + const userLocale = userLocaleString === "de" ? de : enGB; + + const today = new Date(); + + return ( +
+ + + + + props.setIsHovered(false)} + > + + + + + {t("live_label")} + + + + {t("pointintime_label")} + + + + {t("timeperiod_label")} + + + +
+ {t("live_description")} +
+
+
{ + props.setTimeState("live"); + props.setIsDialogOpen(false); + }} + > + + + + +
+
+
+ +
+ {t("pointintime_description")} +
+
+ {props.singleDate === undefined ? ( +
+ {t("date_picker_label")} +
+ ) : ( +
+
+ {props.singleDate?.getDate() < 10 + ? "0" + props.singleDate?.getDate() + : props.singleDate?.getDate()} +
+
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { month: "long" } + ).format(props.singleDate)}{" "} + {props.singleDate?.getFullYear()} +
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { weekday: "long" } + ).format(props.singleDate)} +
+
+
+ )} +
+
+ { + props.setSingleDate(value); + }} + locale={userLocale} + className="mx-auto" + disabled={{ after: today }} + toMonth={today} + /> +
+
+ + +
+
+
{ + if (props.singleDate === undefined) { + e.preventDefault(); + toast({ + description: "Please select a date", + }); + } else { + props.setTimeState("pointintime"); + props.setIsDialogOpen(false); + } + }} + > + + + + + + + +
+
+
+ +
+ {t("timeperiod_description")} +
+
+ {props.dateRange === undefined || + props.dateRange.from === undefined ? ( +
+ {t("date_range_picker_label")} +
+ ) : ( +
+
+
+ {props.dateRange?.from?.getDate() < 10 + ? "0" + props.dateRange.from?.getDate() + : props.dateRange.from?.getDate()} +
+
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { month: "long" } + ).format(props.dateRange.from)}{" "} + {props.dateRange.from?.getFullYear()} +
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { weekday: "long" } + ).format(props.dateRange.from)} +
+
+
+ +
+
+ - +
+
+ + {props.dateRange.to !== undefined ? ( +
+
+ {props.dateRange.to?.getDate() < 10 + ? "0" + props.dateRange.to?.getDate() + : props.dateRange.to?.getDate()} +
+
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { month: "long" } + ).format(props.dateRange.to)}{" "} + {props.dateRange.to?.getFullYear()} +
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { weekday: "long" } + ).format(props.dateRange.to)} +
+
+
+ ) : ( +
+ )} +
+ )} +
+
+ +
+
+ + +
+
+
{ + if ( + props.dateRange?.from === undefined || + props.dateRange?.to === undefined + ) { + e.preventDefault(); + toast({ + description: "Please select a date range", + }); + } else { + props.setTimeState("timeperiod"); + props.setIsDialogOpen(false); + } + }} + > + + + + + + + + + + +
+
+ + + +
+
+ ); +} diff --git a/app/components/header/nav-bar/time-filter/time-filter.tsx b/app/components/header/nav-bar/time-filter/time-filter.tsx new file mode 100644 index 000000000..da8cfdb95 --- /dev/null +++ b/app/components/header/nav-bar/time-filter/time-filter.tsx @@ -0,0 +1,363 @@ +"use client"; + +import * as React from "react"; +// import { useSearchParams, useSubmit } from "@remix-run/react"; +import { de, enGB } from "date-fns/locale"; +import { Clock, CalendarSearch, CalendarClock } from "lucide-react"; +import type { DateRange } from "react-day-picker"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Form } from "@remix-run/react"; +import { useToast } from "@/components/ui/use-toast"; + +import { getUserLocale } from "get-user-locale"; +import { useTranslation } from "react-i18next"; + +interface TimeFilterProps { + className?: React.HTMLAttributes["className"]; + + dateRange: DateRange | undefined; + setDateRange: (date: DateRange | undefined) => void; + + singleDate: Date | undefined; + setSingleDate: (date: Date | undefined) => void; + + isDialogOpen: boolean; + setIsDialogOpen: (open: boolean) => void; + + setIsHovered: (hovered: boolean) => void; + + timeState: string; + setTimeState: (value: string) => void; + + onChange: (timerange: any) => void; + value: any; +} + +export function TimeFilter(props: TimeFilterProps) { + // const submit = useSubmit(); + // const [searchParams] = useSearchParams(); + const { toast } = useToast(); + + const { t } = useTranslation("navbar"); + const userLocaleString = getUserLocale(); + const userLocale = userLocaleString === "de" ? de : enGB; + + const today = new Date(); + + return ( + + + + + {t("live_label")} + + + + {t("pointintime_label")} + + + + {t("timeperiod_label")} + + + +
+ {t("live_description")} +
+
+
{ + props.setTimeState("live"); + props.setIsDialogOpen(false); + }} + > + + + + +
+
+
+ +
+ {t("pointintime_description")} +
+
+ {props.singleDate === undefined ? ( +
+ {t("date_picker_label")} +
+ ) : ( +
+
+ {props.singleDate?.getDate() < 10 + ? "0" + props.singleDate?.getDate() + : props.singleDate?.getDate()} +
+
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { month: "long" } + ).format(props.singleDate)}{" "} + {props.singleDate?.getFullYear()} +
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { weekday: "long" } + ).format(props.singleDate)} +
+
+
+ )} +
+
+ { + props.setSingleDate(value); + }} + locale={userLocale} + className="mx-auto" + disabled={{ after: today }} + toMonth={today} + /> +
+
+ + +
+
+
{ + if (props.singleDate === undefined) { + e.preventDefault(); + toast({ + description: "Please select a date", + }); + } else { + props.setTimeState("pointintime"); + props.setIsDialogOpen(false); + } + }} + > + + + + + + + +
+
+
+ +
+ {t("timeperiod_description")} +
+
+ {props.dateRange === undefined || + props.dateRange.from === undefined ? ( +
+ {t("date_range_picker_label")} +
+ ) : ( +
+
+
+ {props.dateRange?.from?.getDate() < 10 + ? "0" + props.dateRange.from?.getDate() + : props.dateRange.from?.getDate()} +
+
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { month: "long" } + ).format(props.dateRange.from)}{" "} + {props.dateRange.from?.getFullYear()} +
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { weekday: "long" } + ).format(props.dateRange.from)} +
+
+
+ +
+
+ - +
+
+ + {props.dateRange.to !== undefined ? ( +
+
+ {props.dateRange.to?.getDate() < 10 + ? "0" + props.dateRange.to?.getDate() + : props.dateRange.to?.getDate()} +
+
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { month: "long" } + ).format(props.dateRange.to)}{" "} + {props.dateRange.to?.getFullYear()} +
+
+ {new Intl.DateTimeFormat( + userLocaleString === "de" ? "de" : "en-GB", + { weekday: "long" } + ).format(props.dateRange.to)} +
+
+
+ ) : ( +
+ )} +
+ )} +
+
+ +
+
+ + +
+
+
{ + if ( + props.dateRange?.from === undefined || + props.dateRange?.to === undefined + ) { + e.preventDefault(); + toast({ + description: "Please select a date range", + }); + } else { + props.setTimeState("timeperiod"); + props.setIsDialogOpen(false); + } + }} + > + + + + + + + + + + +
+
+ + + ); +} diff --git a/app/components/header/nav-bar/use-keyboard-nav.tsx b/app/components/header/nav-bar/use-keyboard-nav.tsx new file mode 100644 index 000000000..2dd8fdbf4 --- /dev/null +++ b/app/components/header/nav-bar/use-keyboard-nav.tsx @@ -0,0 +1,64 @@ +import type { Key } from "react"; +import { useState, useEffect } from "react"; + +export default function useKeyboardNav( + initCursorVal: number = 0, + cursorMin: number = 0, + cursorMax: number = 0 +) { + const useKeyPress = function (targetKey: Key) { + const [keyPressed, setKeyPressed] = useState(false); + + useEffect(() => { + const downHandler = ({ key }: { key: string }) => { + if (key === targetKey) { + setKeyPressed(true); + } + }; + + const upHandler = ({ key }: { key: string }) => { + if (key === targetKey) { + setKeyPressed(false); + } + }; + + window.addEventListener("keydown", downHandler); + window.addEventListener("keyup", upHandler); + + return () => { + window.removeEventListener("keydown", downHandler); + window.removeEventListener("keyup", upHandler); + }; + }, [targetKey]); + + return keyPressed; + }; + + const downPress = useKeyPress("ArrowDown"); + const upPress = useKeyPress("ArrowUp"); + const enterPress = useKeyPress("Enter"); + const controlPress = useKeyPress("Control"); + const metaPress = useKeyPress("Meta"); + const [cursor, setCursor] = useState(initCursorVal); + + useEffect(() => { + if (downPress && cursor < cursorMax - 1) { + setCursor(cursor + 1); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [downPress, cursorMax]); + useEffect(() => { + if (upPress && cursor > 0) { + setCursor(cursor - 1); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [upPress, cursorMin]); + + return { + cursor, + setCursor, + enterPress, + controlPress, + metaPress, + }; +} diff --git a/app/components/header/notification/index.tsx b/app/components/header/notification/index.tsx new file mode 100644 index 000000000..46149ead3 --- /dev/null +++ b/app/components/header/notification/index.tsx @@ -0,0 +1,50 @@ +import { + NovuProvider, + PopoverNotificationCenter, + NotificationBell, +} from "@novu/notification-center"; +import type { IMessage } from "@novu/notification-center"; +import { useLoaderData } from "@remix-run/react"; +import { useTheme } from "remix-themes"; +import type { loader } from "~/root"; + +function onNotificationClick(message: IMessage) { + if (message?.cta?.data?.url) { + //window.location.href = message.cta.data.url; + window.open(message.cta.data.url, "_blank"); + } +} + +export default function Notification() { + const data = useLoaderData(); + // get theme from tailwind + const [theme] = useTheme(); + return ( +
+ + { + //header content here + return
; + }} + footer={() => { + //footer content here + return
; + }} + > + {({ unseenCount }) => } +
+
+
+ ); +} diff --git a/app/components/label-button.tsx b/app/components/label-button.tsx new file mode 100644 index 000000000..4a77d3ad0 --- /dev/null +++ b/app/components/label-button.tsx @@ -0,0 +1,5 @@ +export function LabelButton({ + ...props +}: Omit, "className">) { + return