+
+
+ {senseBox?.id}
+
+ navigator.clipboard.writeText(senseBox?.id)}
+ className="ml-[6px] mr-1 inline-block h-4 w-4 align-text-bottom text-[#818a91] dark:text-white cursor-pointer"
+ />
+
+ );
+ },
+ },
+ {
+ id: "actions",
+ header: () =>
Actions
,
+ cell: ({ row }) => {
+ const senseBox = row.original;
+
+ return (
+
+
+
+ Open menu
+
+
+
+
+ Actions
+
+
+ Overview
+
+
+ Show on map
+
+
+ Edit
+
+
+ Data upload
+
+
+
+ Support
+
+
+ navigator.clipboard.writeText(senseBox?.id)}
+ className="cursor-pointer"
+ >
+ Copy ID
+
+
+
+ );
+ },
+ },
+];
diff --git a/app/components/mydevices/dt/data-table.tsx b/app/components/mydevices/dt/data-table.tsx
new file mode 100644
index 000000000..f6a27bb57
--- /dev/null
+++ b/app/components/mydevices/dt/data-table.tsx
@@ -0,0 +1,217 @@
+"use client";
+
+import type {
+ ColumnDef,
+ ColumnFiltersState,
+ SortingState,
+} from "@tanstack/react-table";
+import {
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import React from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from "@/components/ui/select";
+import {
+ ChevronLeft,
+ ChevronRight,
+ ChevronsLeft,
+ ChevronsRight,
+} from "lucide-react";
+
+interface DataTableProps
{
+ columns: ColumnDef[];
+ data: TData[];
+}
+
+export function DataTable({
+ columns,
+ data,
+}: DataTableProps) {
+ const [sorting, setSorting] = React.useState([]);
+ const [columnFilters, setColumnFilters] = React.useState(
+ [],
+ );
+
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ onSortingChange: setSorting,
+ getSortedRowModel: getSortedRowModel(),
+ onColumnFiltersChange: setColumnFilters,
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ },
+ initialState: {
+ pagination: {
+ pageSize: 5,
+ },
+ },
+ });
+ const tableColsWidth = [30, 30, 40];
+
+ return (
+
+
+
+ table.getColumn("name")?.setFilterValue(event.target.value)
+ }
+ className="max-w-sm dark:text-white dark:border-white"
+ />
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell, index) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+
+
+
+ Rows per page
+ {
+ table.setPageSize(Number(value));
+ }}
+ // disabled={isPending}
+ >
+
+
+
+
+ {[5, 10, 20, 30, 40, 50].map((item) => (
+
+ {item}
+
+ ))}
+
+
+
+
+ {`Page ${table.getState().pagination.pageIndex + 1} of ${
+ table.getPageCount() ?? 10
+ }`}
+
+
+ table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+
+ First page
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+
+ Previous page
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+
+ Next page
+
+ table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+
+ Last page
+
+
+
+
+
+ );
+}
diff --git a/app/components/mydevices/edit-device/edit-device-sidebar-nav.tsx b/app/components/mydevices/edit-device/edit-device-sidebar-nav.tsx
new file mode 100644
index 000000000..131e77c6e
--- /dev/null
+++ b/app/components/mydevices/edit-device/edit-device-sidebar-nav.tsx
@@ -0,0 +1,45 @@
+import { useLocation } from "@remix-run/react";
+import { Link } from "react-router-dom";
+import { buttonVariants } from "~/components/ui/button";
+import { cn } from "~/lib/utils";
+
+interface SidebarNavProps extends React.HTMLAttributes {
+ items: {
+ href: string;
+ title: string;
+ icon: any;
+ }[];
+}
+
+export function EditDviceSidebarNav({
+ className,
+ items,
+ ...props
+}: SidebarNavProps) {
+ const pathname = useLocation().pathname;
+ return (
+
+ {items.map((item) => (
+
+ {item.title}
+
+ ))}
+
+ );
+}
diff --git a/app/components/nav-bar.tsx b/app/components/nav-bar.tsx
new file mode 100644
index 000000000..ece4efb3c
--- /dev/null
+++ b/app/components/nav-bar.tsx
@@ -0,0 +1,182 @@
+import { Form, Link, useLocation } from "@remix-run/react";
+import { Button } from "./ui/button";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTrigger,
+} from "./ui/sheet";
+import {
+ ChevronDownIcon,
+ Globe,
+ LogOut,
+ Mailbox,
+ Plus,
+ Puzzle,
+ Settings,
+ UserIcon,
+} from "lucide-react";
+import { SidebarNav } from "./sidebar-nav";
+import { useOptionalUser } from "~/utils";
+import { UserAvatar } from "~/routes/resources.user-avatar";
+import {
+ DropdownMenu,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useState } from "react";
+
+const sidebarNavItems = [
+ {
+ title: "Your profile",
+ href: "/profile/me",
+ icon: ,
+ },
+ {
+ title: "Settings",
+ href: "/settings",
+ icon: ,
+ separator: true,
+ },
+ {
+ title: "Forum",
+ href: "https://docs.sensebox.de/",
+ icon: ,
+ },
+ {
+ title: "API Docs",
+ href: "https://docs.opensensemap.org/",
+ icon: ,
+ separator: true,
+ },
+];
+
+export function NavBar() {
+ const location = useLocation();
+ const parts = location.pathname
+ .split("/")
+ .slice(1)
+ .map((item) => item.charAt(0).toUpperCase() + item.slice(1).toLowerCase());
+
+ // To be able to close nested sheet component.
+ const [sheetOpen, setSheetOpen] = useState(false);
+
+ // User is optional
+ // If no user render Login button
+ const user = useOptionalUser();
+
+ return (
+
+
+
+
+
+
+
+ {parts.join(" / ")}
+
+
+
+ {user ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ New device
+
+
+
+
+
+ Transfer device
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {user.name}
+
+
+ {user.email}
+
+
+
+
+
+
+ <>
+
+
+ >
+
+
+
+
+ >
+ ) : (
+ <>
+
+
Login
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/app/components/search/index.tsx b/app/components/search/index.tsx
new file mode 100644
index 000000000..c6b5df15d
--- /dev/null
+++ b/app/components/search/index.tsx
@@ -0,0 +1,127 @@
+import { useEffect, useState } from "react";
+import SearchList from "./search-list";
+import { useTranslation, Trans } from "react-i18next";
+import getUserLocale from "get-user-locale";
+
+interface SearchProps {
+ searchString: string;
+ devices: any;
+}
+
+export default function Search(props: SearchProps) {
+ let { t } = useTranslation("search");
+
+ const userLocaleString = getUserLocale()?.toString() || "en";
+ const [searchResultsLocation, setSearchResultsLocation] = useState([]);
+ const [searchResultsDevice, setSearchResultsDevice] = useState([]);
+
+ /**
+ * One of the functions that is called when the user types in the search bar. It returns the search results for locations, retrived from the mapbox geocode API.
+ *
+ * @param searchstring string to search for locations on mapbox geocode API
+ */
+ function getLocations(searchstring: string) {
+ var url: URL = new URL(ENV.MAPBOX_GEOCODING_API + `${searchstring}.json`);
+
+ url.search = new URLSearchParams({
+ access_token: `${ENV.MAPBOX_ACCESS_TOKEN}`,
+ limit: "4",
+ language: userLocaleString,
+ }).toString();
+
+ var requestOptions: RequestInit = {
+ method: "GET",
+ redirect: "follow",
+ };
+
+ fetch(url, requestOptions)
+ .then((response) => response.json())
+ .then((data) => {
+ if (data.features.length === 0) {
+ setSearchResultsLocation([]);
+ } else {
+ data.features.forEach((feature: any) => {
+ feature.type = "location";
+ });
+ setSearchResultsLocation(data.features);
+ }
+ })
+ .catch((error) => console.log("error", error));
+ }
+
+ /**
+ * One of the functions that is called when the user types in the search bar. It returns the search results for devices, retrived from the device list. The device list is proviided by the database in the /explore route and passed down to the search component.
+ *
+ * @param searchString string to search for devices in the device list
+ */
+ function getDevices(searchString: string) {
+ var results: any[] = [];
+ var deviceResults = 0;
+
+ for (let index = 0; index < props.devices.features.length; index++) {
+ const device = props.devices.features[index];
+ if (deviceResults === 4) {
+ setSearchResultsDevice(results);
+ return;
+ }
+ if (
+ device.properties.name
+ .toLowerCase()
+ .includes(searchString.toLowerCase()) ||
+ device.properties.id.toLowerCase().includes(searchString.toLowerCase())
+ ) {
+ var newStructured = {
+ display_name: device.properties.name,
+ deviceId: device.properties.id,
+ lng: device.properties.longitude,
+ lat: device.properties.latitude,
+ type: "device",
+ };
+ results.push(newStructured);
+ deviceResults++;
+ setSearchResultsDevice(results);
+ }
+ if (deviceResults === 0) {
+ setSearchResultsDevice([]);
+ }
+ }
+ }
+
+ /**
+ * useEffect hook that is called when the search string changes. It calls the getLocations and getDevices functions to get the search results for locations and devices.
+ */
+ useEffect(() => {
+ if (props.searchString.length >= 2) {
+ getLocations(props.searchString);
+ getDevices(props.searchString);
+ }
+ if (props.searchString.length < 2) {
+ setSearchResultsLocation([]);
+ setSearchResultsDevice([]);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [props.searchString]);
+
+ if (searchResultsLocation.length > 0 || searchResultsDevice.length > 0)
+ return (
+
+ );
+
+ return null;
+}
diff --git a/app/components/search/search-list-item.tsx b/app/components/search/search-list-item.tsx
new file mode 100644
index 000000000..6b688de56
--- /dev/null
+++ b/app/components/search/search-list-item.tsx
@@ -0,0 +1,57 @@
+import type { VariantProps } from "class-variance-authority";
+import { cva } from "class-variance-authority";
+import type { HTMLProps } from "react";
+
+export type HeroIcon = React.ComponentType<
+ React.PropsWithoutRef> & {
+ title?: string | undefined;
+ titleId?: string | undefined;
+ }
+>;
+
+interface SearchListItemProps
+ extends VariantProps,
+ HTMLProps {
+ index: number;
+ controlPress: boolean;
+ icon: HeroIcon;
+ name: string;
+}
+
+const searchListItemStyle = cva(
+ "relative my-1 flex gap-2 h-8 px-2 items-center rounded-lg data-[active=true]:bg-light-green data-[active=true]:text-white",
+ {
+ variants: {
+ active: {
+ true: "bg-light-green text-white",
+ },
+ },
+ }
+);
+
+export default function SearchListItem({
+ active,
+ index,
+ controlPress,
+ icon,
+ name,
+ ...props
+}: SearchListItemProps) {
+ const Icon = icon;
+
+ return (
+
+ {controlPress && (
+
+ {index + 1}
+
+ )}
+
+
+
+
+ {name}
+
+
+ );
+}
diff --git a/app/components/search/search-list.tsx b/app/components/search/search-list.tsx
new file mode 100644
index 000000000..47924983d
--- /dev/null
+++ b/app/components/search/search-list.tsx
@@ -0,0 +1,172 @@
+import { useState, useEffect, useCallback, useContext } from "react";
+import { useMap } from "react-map-gl";
+import { useMatches, useNavigate, useSearchParams } from "@remix-run/react";
+
+import SearchListItem from "./search-list-item";
+import { goTo } from "~/lib/search-map-helper";
+import useKeyboardNav from "../header/nav-bar/use-keyboard-nav";
+import { NavbarContext } from "../header/nav-bar";
+import { Cpu, Globe, MapPin } from "lucide-react";
+import { useSharedCompareMode } from "../device-detail/device-detail-box";
+
+interface SearchListProps {
+ searchResultsLocation: any[];
+ searchResultsDevice: any[];
+}
+
+export default function SearchList(props: SearchListProps) {
+ const { osem } = useMap();
+ const navigate = useNavigate();
+ const { setOpen } = useContext(NavbarContext);
+ const { compareMode } = useSharedCompareMode();
+ const matches = useMatches();
+
+ const { cursor, setCursor, enterPress, controlPress } = useKeyboardNav(
+ 0,
+ 0,
+ props.searchResultsDevice.length + props.searchResultsLocation.length,
+ );
+
+ const length =
+ props.searchResultsDevice.length + props.searchResultsLocation.length;
+ const searchResultsAll = props.searchResultsDevice.concat(
+ props.searchResultsLocation,
+ );
+ const [selected, setSelected] = useState(searchResultsAll[cursor]);
+ const [searchParams] = useSearchParams();
+ const [navigateTo, setNavigateTo] = useState(
+ compareMode
+ ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}`
+ : selected.type === "device"
+ ? `/explore/${selected.deviceId}`
+ : `/explore${searchParams.size > 0 ? "?" + searchParams.toString() : ""}`,
+ );
+
+ const handleNavigate = useCallback(
+ (result: any) => {
+ return compareMode
+ ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}`
+ : result.type === "device"
+ ? `/explore/${result.deviceId}`
+ : `/explore${
+ searchParams.size > 0 ? "?" + searchParams.toString() : ""
+ }`;
+ },
+ [searchParams, compareMode, matches, selected],
+ );
+
+ const setShowSearchCallback = useCallback(
+ (state: boolean) => {
+ setOpen(state);
+ },
+ [setOpen],
+ );
+
+ const handleDigitPress = useCallback(
+ (event: any) => {
+ if (
+ typeof Number(event.key) === "number" &&
+ Number(event.key) <= length &&
+ controlPress
+ ) {
+ event.preventDefault();
+ setCursor(Number(event.key) - 1);
+ goTo(osem, searchResultsAll[Number(event.key) - 1]);
+ setTimeout(() => {
+ setShowSearchCallback(false);
+ navigate(handleNavigate(searchResultsAll[Number(event.key) - 1]));
+ }, 500);
+ }
+ },
+ [
+ controlPress,
+ length,
+ navigate,
+ osem,
+ searchResultsAll,
+ setCursor,
+ setShowSearchCallback,
+ handleNavigate,
+ ],
+ );
+
+ useEffect(() => {
+ setSelected(searchResultsAll[cursor]);
+ }, [cursor, searchResultsAll]);
+
+ useEffect(() => {
+ const navigate = handleNavigate(selected);
+ setNavigateTo(navigate);
+ }, [selected, handleNavigate]);
+
+ useEffect(() => {
+ if (length !== 0 && enterPress) {
+ goTo(osem, selected);
+ setShowSearchCallback(false);
+ navigate(navigateTo);
+ }
+ }, [
+ enterPress,
+ osem,
+ navigate,
+ selected,
+ setShowSearchCallback,
+ navigateTo,
+ length,
+ ]);
+
+ useEffect(() => {
+ // attach the event listener
+ window.addEventListener("keydown", handleDigitPress);
+
+ // remove the event listener
+ return () => {
+ window.removeEventListener("keydown", handleDigitPress);
+ };
+ });
+
+ return (
+
+ {props.searchResultsDevice.length > 0 && (
+
+ )}
+ {props.searchResultsDevice.map((device: any, i) => (
+ setCursor(i)}
+ onClick={() => {
+ goTo(osem, device);
+ setShowSearchCallback(false);
+ navigate(navigateTo);
+ }}
+ />
+ ))}
+ {props.searchResultsLocation.length > 0 && (
+
+ )}
+ {props.searchResultsLocation.map((location: any, i) => {
+ return (
+ setCursor(i + props.searchResultsDevice.length)}
+ onClick={() => {
+ goTo(osem, location);
+ setShowSearchCallback(false);
+ navigate(navigateTo);
+ }}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/app/components/sensor-icon.tsx b/app/components/sensor-icon.tsx
new file mode 100644
index 000000000..cf14ca5f8
--- /dev/null
+++ b/app/components/sensor-icon.tsx
@@ -0,0 +1,17 @@
+import { Activity, ThermometerIcon, Volume1Icon } from "lucide-react";
+
+interface SensorIconProps {
+ title: string;
+ className: string | undefined;
+}
+
+export default function SensorIcon(props: SensorIconProps) {
+ switch (props.title.toLowerCase()) {
+ case "temperatur":
+ return ;
+ case "lautstärke":
+ return ;
+ default:
+ return ;
+ }
+}
diff --git a/app/components/sensor-wiki-hover-card.tsx b/app/components/sensor-wiki-hover-card.tsx
new file mode 100644
index 000000000..37d81fe3d
--- /dev/null
+++ b/app/components/sensor-wiki-hover-card.tsx
@@ -0,0 +1,151 @@
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
+import { useEffect, useState } from "react";
+import { sensorWikiLabel } from "~/utils/sensor-wiki-helper";
+
+interface SensorWikHoverCardProps {
+ slug: string;
+ type: "phenomena" | "sensors" | "devices" | "domains" | "units";
+ phenomenonSlug?: string;
+ trigger: React.ReactNode;
+ side?: "top" | "right" | "bottom" | "left";
+ avoidCollisions?: boolean;
+ openDelay?: number;
+ closeDelay?: number;
+}
+const getData = async (slug: string, type: string, phenomenonSlug?: string) => {
+ const response = await fetch(`${ENV.SENSORWIKI_API_URL}${type}/${slug}`);
+ const data = await response.json();
+ let sensorElement;
+ if (phenomenonSlug) {
+ sensorElement = data.elements.find(
+ (element: any) => element.phenomena.slug === phenomenonSlug,
+ );
+ }
+
+ let content;
+ switch (type) {
+ case "phenomena":
+ content = (
+
+ {data.description
+ ? data.description.item[0].text
+ : "No data available."}
+
+ );
+ break;
+ case "sensors":
+ content = (
+
+
+
+
+ Manufacturer
+
+ {data.manufacturer ? data.manufacturer : "n/s"}
+
+
+
+ Price
+ {data.price ? data.price : "n/s"}
+
+
+ Life period
+
+ {data.lifePeriod ? data.lifePeriod : "n/s"}
+
+
+ {phenomenonSlug && (
+ <>
+
+ Accuracy
+
+ {sensorElement.accuracy ? sensorElement.accuracy : "n/s"}
+
+
+
+ Unit
+
+ {sensorElement.accuracyUnit
+ ? `${sensorElement.accuracyUnit.name} (${sensorElement.accuracyUnit.notation})`
+ : "n/s"}
+
+
+ >
+ )}
+
+
+
+ );
+ break;
+ case "devices":
+ content = (
+
+ {data.description
+ ? sensorWikiLabel(data.description.item)
+ : "No data available."}
+
+ );
+ break;
+ case "domains":
+ content = (
+
+ {data.description
+ ? sensorWikiLabel(data.description.item)
+ : "No data available."}
+
+ );
+ break;
+ case "units":
+ content = (
+
+ {data.description
+ ? sensorWikiLabel(data.description.item)
+ : "No data available."}
+
+ );
+ break;
+ default:
+ content = No information found.
;
+ }
+
+ return content;
+};
+
+export default function SensorWikHoverCard(props: SensorWikHoverCardProps) {
+ const [content, setContent] = useState(null);
+ const {
+ slug,
+ type,
+ trigger,
+ phenomenonSlug,
+ side,
+ avoidCollisions,
+ openDelay,
+ closeDelay,
+ } = props;
+
+ useEffect(() => {
+ getData(slug, type, phenomenonSlug).then((content) => {
+ setContent(content);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ {trigger}
+
+ {content}
+
+
+ );
+}
diff --git a/app/components/sidebar-nav.tsx b/app/components/sidebar-nav.tsx
new file mode 100644
index 000000000..f34086bd8
--- /dev/null
+++ b/app/components/sidebar-nav.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { NavLink } from "@remix-run/react";
+import { cn } from "~/lib/utils";
+
+interface SidebarNavProps extends React.HTMLAttributes {
+ items: {
+ href: string;
+ title: string;
+ icon?: any;
+ separator?: boolean;
+ }[];
+ setOpen: React.Dispatch>;
+}
+
+export function SidebarNav({
+ className,
+ items,
+ setOpen,
+ ...props
+}: SidebarNavProps) {
+ return (
+
+ {items.map((item) => (
+ <>
+ {
+ setOpen(false);
+ }}
+ className={({ isActive, isPending }) =>
+ isPending
+ ? ""
+ : "hover:bg-transparent hover:underline"
+ }
+ >
+
+ {item.icon && item.icon}
+ {item.title}
+
+
+ {item?.separator && (
+
+ )}
+ >
+ ))}
+
+ );
+}
diff --git a/app/components/sidebar-settings-nav.tsx b/app/components/sidebar-settings-nav.tsx
new file mode 100644
index 000000000..0e5e160b3
--- /dev/null
+++ b/app/components/sidebar-settings-nav.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { NavLink } from "@remix-run/react";
+import { cn } from "~/lib/utils";
+
+interface SidebarNavProps extends React.HTMLAttributes {
+ items: {
+ href: string;
+ title: string;
+ }[];
+}
+
+export function SidebarSettingsNav({
+ className,
+ items,
+ ...props
+}: SidebarNavProps) {
+ return (
+
+ {items.map((item) => (
+
+ isPending
+ ? ""
+ : isActive
+ ? "bg-muted hover:bg-muted"
+ : "hover:bg-transparent hover:underline"
+ }
+ >
+ {item.title}
+
+ ))}
+
+ );
+}
diff --git a/app/components/spinner/index.tsx b/app/components/spinner/index.tsx
new file mode 100644
index 000000000..21c99478a
--- /dev/null
+++ b/app/components/spinner/index.tsx
@@ -0,0 +1,44 @@
+export default function Spinner() {
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/app/components/stepper/index.tsx b/app/components/stepper/index.tsx
new file mode 100644
index 000000000..f15e209a9
--- /dev/null
+++ b/app/components/stepper/index.tsx
@@ -0,0 +1,68 @@
+import { Link } from "@remix-run/react";
+import clsx from "clsx";
+
+interface Step {
+ title: string;
+ longTitle?: string;
+}
+
+interface SearchProps {
+ setStep: (step: number) => void;
+ steps: Step[];
+ activeStep: number;
+ activatedSteps: number[];
+}
+
+export default function Stepper(props: SearchProps) {
+ return (
+
+ {/* Osem Logo*/}
+
+
+ {/*
+ openSenseMap
+ */}
+
+
+ {props.steps.map((step: Step, index: number) => (
+ props.setStep(index + 1)}
+ className={clsx(
+ props.activeStep === index
+ ? "text-light-green dark:text-dark-green"
+ : "",
+ !props.activatedSteps.includes(index + 1) ? "text-gray-300" : "",
+ "flex cursor-pointer items-center text-xl font-medium",
+ )}
+ name="action"
+ value={index + 1}
+ disabled={!props.activatedSteps.includes(index + 1)}
+ >
+
+ {index + 1}
+
+ {step.title}
+ {props.steps.length - 1 != index && (
+
+
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/ui/accordion.tsx b/app/components/ui/accordion.tsx
new file mode 100644
index 000000000..937620af2
--- /dev/null
+++ b/app/components/ui/accordion.tsx
@@ -0,0 +1,60 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/app/components/ui/alert-dialog.tsx b/app/components/ui/alert-dialog.tsx
new file mode 100644
index 000000000..732fdf901
--- /dev/null
+++ b/app/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/app/components/ui/alert.tsx b/app/components/ui/alert.tsx
new file mode 100644
index 000000000..8221a9488
--- /dev/null
+++ b/app/components/ui/alert.tsx
@@ -0,0 +1,60 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border border-slate-200 p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-slate-950 dark:border-slate-800 dark:[&>svg]:text-slate-50",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-white text-slate-950 dark:bg-dark-boxes dark:text-dark-text ",
+ destructive:
+ "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Alert.displayName = "Alert";
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertTitle.displayName = "AlertTitle";
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertDescription.displayName = "AlertDescription";
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/app/components/ui/animated-counter.tsx b/app/components/ui/animated-counter.tsx
new file mode 100644
index 000000000..7c0baacf3
--- /dev/null
+++ b/app/components/ui/animated-counter.tsx
@@ -0,0 +1,48 @@
+import {
+ animate,
+ motion,
+ useInView,
+ useMotionValue,
+ useTransform,
+} from "framer-motion";
+import { useEffect, useRef } from "react";
+
+type AnimatedCounterProps = {
+ from: number;
+ to: number;
+};
+
+function AnimatedCounter({ from, to }: AnimatedCounterProps) {
+ const count = useMotionValue(from);
+ const rounded = useTransform(count, (latest) => Math.round(latest));
+
+ const ref = useRef(null);
+ const inView = useInView(ref);
+
+ useEffect(() => {
+ let timeoutId: number | undefined;
+
+ const updateCount = () => {
+ if (count.get() !== to) {
+ animate(count, to, { duration: 1 });
+ requestAnimationFrame(updateCount);
+ }
+ };
+
+ if (inView) {
+ timeoutId = window.setTimeout(() => {
+ requestAnimationFrame(updateCount);
+ }, 450);
+ }
+
+ return () => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ };
+ }, [count, inView, to]);
+
+ return {rounded} ;
+}
+
+export default AnimatedCounter;
diff --git a/app/components/ui/aspect-ratio.tsx b/app/components/ui/aspect-ratio.tsx
new file mode 100644
index 000000000..d6a5226f5
--- /dev/null
+++ b/app/components/ui/aspect-ratio.tsx
@@ -0,0 +1,7 @@
+"use client"
+
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+const AspectRatio = AspectRatioPrimitive.Root
+
+export { AspectRatio }
diff --git a/app/components/ui/avatar.tsx b/app/components/ui/avatar.tsx
new file mode 100644
index 000000000..6afb68104
--- /dev/null
+++ b/app/components/ui/avatar.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/app/components/ui/badge.tsx b/app/components/ui/badge.tsx
new file mode 100644
index 000000000..569549784
--- /dev/null
+++ b/app/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border border-zinc-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-zinc-400 focus:ring-offset-2 dark:border-white dark:focus:ring-zinc-800",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-slate-900 text-slate-50 hover:bg-slate-900/80 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/80",
+ secondary:
+ "border-transparent bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
+ destructive:
+ "border-transparent bg-red-500 text-slate-50 hover:bg-red-500/80 dark:bg-red-900 dark:text-red-50 dark:hover:bg-red-900/80",
+ outline: "text-zinc-950 dark:text-zinc-50",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx
new file mode 100644
index 000000000..e7d55f849
--- /dev/null
+++ b/app/components/ui/button.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+import { cva } from "class-variance-authority";
+import type { VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "underline-offset-4 hover:underline text-primary",
+ },
+ size: {
+ default: "h-10 py-2 px-4",
+ sm: "h-9 px-3 rounded-md",
+ lg: "h-11 px-8 rounded-md",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/app/components/ui/calendar.tsx b/app/components/ui/calendar.tsx
new file mode 100644
index 000000000..ae5a00312
--- /dev/null
+++ b/app/components/ui/calendar.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import { ChevronLeft, ChevronRight } from "lucide-react"
+import { DayPicker } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+export type CalendarProps = React.ComponentProps
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ ,
+ IconRight: ({ ...props }) => ,
+ }}
+ {...props}
+ />
+ )
+}
+Calendar.displayName = "Calendar"
+
+export { Calendar }
diff --git a/app/components/ui/card.tsx b/app/components/ui/card.tsx
new file mode 100644
index 000000000..f1ab38aa9
--- /dev/null
+++ b/app/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/app/components/ui/checkbox.tsx b/app/components/ui/checkbox.tsx
new file mode 100644
index 000000000..c796cc6c4
--- /dev/null
+++ b/app/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/app/components/ui/command.tsx b/app/components/ui/command.tsx
new file mode 100644
index 000000000..20315133e
--- /dev/null
+++ b/app/components/ui/command.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import * as React from "react";
+import { type DialogProps } from "@radix-ui/react-dialog";
+import { Command as CommandPrimitive } from "cmdk";
+import { Search } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Dialog, DialogContent } from "@/components/ui/dialog";
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Command.displayName = CommandPrimitive.displayName;
+
+interface CommandDialogProps extends DialogProps {}
+
+const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+};
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+
+CommandInput.displayName = CommandPrimitive.Input.displayName;
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandList.displayName = CommandPrimitive.List.displayName;
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName;
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
+
+// TODO: Manually fixed this issue: https://github.com/shadcn-ui/ui/pull/4297
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandItem.displayName = CommandPrimitive.Item.displayName;
+
+const CommandShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+CommandShortcut.displayName = "CommandShortcut";
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+};
diff --git a/app/components/ui/dialog.tsx b/app/components/ui/dialog.tsx
new file mode 100644
index 000000000..f0fed75c7
--- /dev/null
+++ b/app/components/ui/dialog.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = ({
+ children,
+ ...props
+}: DialogPrimitive.DialogPortalProps) => (
+
+
+ {children}
+
+
+);
+DialogPortal.displayName = DialogPrimitive.Portal.displayName;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/app/components/ui/dropdown-menu.tsx b/app/components/ui/dropdown-menu.tsx
new file mode 100644
index 000000000..93dbd1626
--- /dev/null
+++ b/app/components/ui/dropdown-menu.tsx
@@ -0,0 +1,200 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/app/components/ui/form.tsx b/app/components/ui/form.tsx
new file mode 100644
index 000000000..402af08bf
--- /dev/null
+++ b/app/components/ui/form.tsx
@@ -0,0 +1,174 @@
+import * as React from "react";
+import type * as LabelPrimitive from "@radix-ui/react-label";
+import { Slot } from "@radix-ui/react-slot";
+import type { ControllerProps, FieldPath, FieldValues } from "react-hook-form";
+import { Controller, FormProvider, useFormContext } from "react-hook-form";
+
+import { cn } from "@/lib/utils";
+import { Label } from "@/components/ui/label";
+
+const Form = FormProvider;
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+> = {
+ name: TName;
+};
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+);
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ );
+};
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext);
+ const itemContext = React.useContext(FormItemContext);
+ const { getFieldState, formState } = useFormContext();
+
+ const fieldState = getFieldState(fieldContext.name, formState);
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ");
+ }
+
+ const { id } = itemContext;
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ };
+};
+
+type FormItemContextValue = {
+ id: string;
+};
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+);
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId();
+
+ return (
+
+
+
+ );
+});
+FormItem.displayName = "FormItem";
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField();
+
+ return (
+
+ );
+});
+FormLabel.displayName = "FormLabel";
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } =
+ useFormField();
+
+ return (
+
+ );
+});
+FormControl.displayName = "FormControl";
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField();
+
+ return (
+
+ );
+});
+FormDescription.displayName = "FormDescription";
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField();
+ const body = error ? String(error?.message) : children;
+
+ if (!body) {
+ return null;
+ }
+
+ return (
+
+ {body}
+
+ );
+});
+FormMessage.displayName = "FormMessage";
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+};
diff --git a/app/components/ui/hover-card.tsx b/app/components/ui/hover-card.tsx
new file mode 100644
index 000000000..e9fceaf3e
--- /dev/null
+++ b/app/components/ui/hover-card.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
+
+import { cn } from "@/lib/utils"
+
+const HoverCard = HoverCardPrimitive.Root
+
+const HoverCardTrigger = HoverCardPrimitive.Trigger
+
+const HoverCardContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+))
+HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }
diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx
new file mode 100644
index 000000000..647f679c0
--- /dev/null
+++ b/app/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/app/components/ui/label.tsx b/app/components/ui/label.tsx
new file mode 100644
index 000000000..534182176
--- /dev/null
+++ b/app/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/app/components/ui/popover.tsx b/app/components/ui/popover.tsx
new file mode 100644
index 000000000..eb3e6a92c
--- /dev/null
+++ b/app/components/ui/popover.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+const Popover = PopoverPrimitive.Root
+
+const PopoverTrigger = PopoverPrimitive.Trigger
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent }
diff --git a/app/components/ui/radio-group.tsx b/app/components/ui/radio-group.tsx
new file mode 100644
index 000000000..7a5450aa6
--- /dev/null
+++ b/app/components/ui/radio-group.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import * as React from "react"
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
+import { Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => {
+ return (
+
+
+
+
+
+ )
+})
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
+
+export { RadioGroup, RadioGroupItem }
diff --git a/app/components/ui/scroll-area.tsx b/app/components/ui/scroll-area.tsx
new file mode 100644
index 000000000..ff4aae8e1
--- /dev/null
+++ b/app/components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+))
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+))
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
+
+export { ScrollArea, ScrollBar }
diff --git a/app/components/ui/select.tsx b/app/components/ui/select.tsx
new file mode 100644
index 000000000..be75c18fd
--- /dev/null
+++ b/app/components/ui/select.tsx
@@ -0,0 +1,121 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+ {children}
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+}
diff --git a/app/components/ui/separator.tsx b/app/components/ui/separator.tsx
new file mode 100644
index 000000000..f2cd15a78
--- /dev/null
+++ b/app/components/ui/separator.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref
+ ) => (
+
+ )
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
+
+export { Separator }
diff --git a/app/components/ui/sheet.tsx b/app/components/ui/sheet.tsx
new file mode 100644
index 000000000..cdf7facdc
--- /dev/null
+++ b/app/components/ui/sheet.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import * as React from "react";
+import * as SheetPrimitive from "@radix-ui/react-dialog";
+import { cva, type VariantProps } from "class-variance-authority";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Sheet = SheetPrimitive.Root;
+
+const SheetTrigger = SheetPrimitive.Trigger;
+
+const SheetClose = SheetPrimitive.Close;
+
+const SheetPortal = ({ ...props }: SheetPrimitive.DialogPortalProps) => (
+
+);
+SheetPortal.displayName = SheetPrimitive.Portal.displayName;
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ "fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 dark:bg-slate-950",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ },
+);
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetHeader.displayName = "SheetHeader";
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetFooter.displayName = "SheetFooter";
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/app/components/ui/switch.tsx b/app/components/ui/switch.tsx
new file mode 100644
index 000000000..46572b6bf
--- /dev/null
+++ b/app/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
+
+export { Switch }
diff --git a/app/components/ui/table.tsx b/app/components/ui/table.tsx
new file mode 100644
index 000000000..bb3a87f32
--- /dev/null
+++ b/app/components/ui/table.tsx
@@ -0,0 +1,114 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Table.displayName = "Table"
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableHeader.displayName = "TableHeader"
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableBody.displayName = "TableBody"
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableFooter.displayName = "TableFooter"
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableRow.displayName = "TableRow"
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableHead.displayName = "TableHead"
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/app/components/ui/tabs.tsx b/app/components/ui/tabs.tsx
new file mode 100644
index 000000000..bf2f3872d
--- /dev/null
+++ b/app/components/ui/tabs.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import * as React from "react";
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+
+import { cn } from "@/lib/utils";
+
+const Tabs = TabsPrimitive.Root;
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/app/components/ui/textarea.tsx b/app/components/ui/textarea.tsx
new file mode 100644
index 000000000..127c44e23
--- /dev/null
+++ b/app/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/app/components/ui/toast.tsx b/app/components/ui/toast.tsx
new file mode 100644
index 000000000..c05bb58e7
--- /dev/null
+++ b/app/components/ui/toast.tsx
@@ -0,0 +1,129 @@
+import * as React from "react";
+import * as ToastPrimitives from "@radix-ui/react-toast";
+import type { VariantProps } from "class-variance-authority";
+import { cva } from "class-variance-authority";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const ToastProvider = ToastPrimitives.Provider;
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
+
+const toastVariants = cva(
+ "data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full",
+ {
+ variants: {
+ variant: {
+ default: "bg-background border",
+ destructive:
+ "group destructive border-destructive bg-destructive text-destructive-foreground",
+ success: "success group border-green-500 bg-green-500 text-white",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ );
+});
+Toast.displayName = ToastPrimitives.Root.displayName;
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastAction.displayName = ToastPrimitives.Action.displayName;
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+ToastClose.displayName = ToastPrimitives.Close.displayName;
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastTitle.displayName = ToastPrimitives.Title.displayName;
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastDescription.displayName = ToastPrimitives.Description.displayName;
+
+type ToastProps = React.ComponentPropsWithoutRef;
+
+type ToastActionElement = React.ReactElement;
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+};
diff --git a/app/components/ui/toaster.tsx b/app/components/ui/toaster.tsx
new file mode 100644
index 000000000..e2233852a
--- /dev/null
+++ b/app/components/ui/toaster.tsx
@@ -0,0 +1,35 @@
+"use client"
+
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "@/components/ui/toast"
+import { useToast } from "@/components/ui/use-toast"
+
+export function Toaster() {
+ const { toasts } = useToast()
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title} }
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/app/components/ui/toggle-group.tsx b/app/components/ui/toggle-group.tsx
new file mode 100644
index 000000000..38876ebd7
--- /dev/null
+++ b/app/components/ui/toggle-group.tsx
@@ -0,0 +1,61 @@
+"use client"
+
+import * as React from "react"
+import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
+import type { VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { toggleVariants } from "@/components/ui/toggle"
+
+const ToggleGroupContext = React.createContext<
+ VariantProps
+>({
+ size: "default",
+ variant: "default",
+})
+
+const ToggleGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, size, children, ...props }, ref) => (
+
+
+ {children}
+
+
+))
+
+ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
+
+const ToggleGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, children, variant, size, ...props }, ref) => {
+ const context = React.useContext(ToggleGroupContext)
+
+ return (
+
+ {children}
+
+ )
+})
+
+ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
+
+export { ToggleGroup, ToggleGroupItem }
diff --git a/app/components/ui/toggle.tsx b/app/components/ui/toggle.tsx
new file mode 100644
index 000000000..c72df2d67
--- /dev/null
+++ b/app/components/ui/toggle.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import * as React from "react"
+import * as TogglePrimitive from "@radix-ui/react-toggle"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const toggleVariants = cva(
+ "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors hover:bg-slate-100 hover:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-slate-100 data-[state=on]:text-slate-900 dark:ring-offset-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-400 dark:focus-visible:ring-slate-300 dark:data-[state=on]:bg-slate-800 dark:data-[state=on]:text-slate-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-slate-200 bg-transparent hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:hover:bg-slate-800 dark:hover:text-slate-50",
+ },
+ size: {
+ default: "h-10 px-3",
+ sm: "h-9 px-2.5",
+ lg: "h-11 px-5",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+const Toggle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, size, ...props }, ref) => (
+
+))
+
+Toggle.displayName = TogglePrimitive.Root.displayName
+
+export { Toggle, toggleVariants }
diff --git a/app/components/ui/tooltip.tsx b/app/components/ui/tooltip.tsx
new file mode 100644
index 000000000..e74f49889
--- /dev/null
+++ b/app/components/ui/tooltip.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+const TooltipProvider = TooltipPrimitive.Provider
+
+const Tooltip = TooltipPrimitive.Root
+
+const TooltipTrigger = TooltipPrimitive.Trigger
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/app/components/ui/use-toast.ts b/app/components/ui/use-toast.ts
new file mode 100644
index 000000000..37132fadf
--- /dev/null
+++ b/app/components/ui/use-toast.ts
@@ -0,0 +1,189 @@
+// Inspired by react-hot-toast library
+import * as React from "react"
+
+import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
+
+const TOAST_LIMIT = 1
+const TOAST_REMOVE_DELAY = 3000
+
+type ToasterToast = ToastProps & {
+ id: string
+ title?: React.ReactNode
+ description?: React.ReactNode
+ action?: ToastActionElement
+}
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const
+
+let count = 0
+
+function genId() {
+ count = (count + 1) % Number.MAX_VALUE
+ return count.toString()
+}
+
+type ActionType = typeof actionTypes
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"]
+ toast: ToasterToast
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"]
+ toast: Partial
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map>()
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId)
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ })
+ }, TOAST_REMOVE_DELAY)
+
+ toastTimeouts.set(toastId, timeout)
+}
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ }
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
+ ),
+ }
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId)
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id)
+ })
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t
+ ),
+ }
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ }
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ }
+ }
+}
+
+const listeners: Array<(state: State) => void> = []
+
+let memoryState: State = { toasts: [] }
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action)
+ listeners.forEach((listener) => {
+ listener(memoryState)
+ })
+}
+
+interface Toast extends Omit {}
+
+function toast({ ...props }: Toast) {
+ const id = genId()
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ })
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss()
+ },
+ },
+ })
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ }
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState)
+
+ React.useEffect(() => {
+ listeners.push(setState)
+ return () => {
+ const index = listeners.indexOf(setState)
+ if (index > -1) {
+ listeners.splice(index, 1)
+ }
+ }
+ }, [state])
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ }
+}
+
+export { useToast, toast }
diff --git a/app/cookies.ts b/app/cookies.ts
new file mode 100644
index 000000000..9fe3b02cd
--- /dev/null
+++ b/app/cookies.ts
@@ -0,0 +1,12 @@
+import { createCookie } from "@remix-run/node";
+
+const isProduction = process.env.NODE_ENV === "production";
+
+export let i18nCookie = createCookie("i18n", {
+ sameSite: "lax",
+ path: "/",
+ secrets: process.env.SESSION_SECRET
+ ? [process.env.SESSION_SECRET]
+ : ["s3cr3t"],
+ secure: isProduction,
+});
diff --git a/app/db.server.ts b/app/db.server.ts
index 990a05f60..4eb9ae512 100644
--- a/app/db.server.ts
+++ b/app/db.server.ts
@@ -1,10 +1,12 @@
-import { PrismaClient } from "@prisma/client";
+import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
+import postgres from "postgres";
import invariant from "tiny-invariant";
+import * as schema from "./schema";
-let prisma: PrismaClient;
+let drizzleClient: PostgresJsDatabase;
declare global {
- var __db__: PrismaClient;
+ var __db__: PostgresJsDatabase;
}
// this is needed because in development we don't want to restart
@@ -12,12 +14,12 @@ declare global {
// create a new connection to the DB with every change either.
// in production we'll have a single connection to the DB.
if (process.env.NODE_ENV === "production") {
- prisma = getClient();
+ drizzleClient = getClient();
} else {
if (!global.__db__) {
global.__db__ = getClient();
}
- prisma = global.__db__;
+ drizzleClient = global.__db__;
}
function getClient() {
@@ -26,37 +28,18 @@ function getClient() {
const databaseUrl = new URL(DATABASE_URL);
- const isLocalHost = databaseUrl.hostname === "localhost";
+ console.log(`🔌 setting up drizzle client to ${databaseUrl.host}`);
- const PRIMARY_REGION = isLocalHost ? null : process.env.PRIMARY_REGION;
- const FLY_REGION = isLocalHost ? null : process.env.FLY_REGION;
-
- const isReadReplicaRegion = !PRIMARY_REGION || PRIMARY_REGION === FLY_REGION;
-
- if (!isLocalHost) {
- databaseUrl.host = `${FLY_REGION}.${databaseUrl.host}`;
- if (!isReadReplicaRegion) {
- // 5433 is the read-replica port
- databaseUrl.port = "5433";
- }
- }
-
- console.log(`🔌 setting up prisma client to ${databaseUrl.host}`);
// NOTE: during development if you change anything in this function, remember
// that this only runs once per server restart and won't automatically be
// re-run per request like everything else is. So if you need to change
// something in this file, you'll need to manually restart the server.
- const client = new PrismaClient({
- datasources: {
- db: {
- url: databaseUrl.toString(),
- },
- },
+ const queryClient = postgres(DATABASE_URL, {
+ ssl: process.env.PG_CLIENT_SSL === "true" ? true : false,
});
- // connect eagerly
- client.$connect();
+ const client = drizzle(queryClient, { schema });
return client;
}
-export { prisma };
+export { drizzleClient };
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index 1d4ba68d5..af3177038 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -1,14 +1,42 @@
import { RemixBrowser } from "@remix-run/react";
+import i18next from "i18next";
+import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector";
+import I18NextHttpBackend from "i18next-http-backend";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
+import { I18nextProvider, initReactI18next } from "react-i18next";
+import i18nextOptions from "./i18next-options";
+import { getInitialNamespaces } from "remix-i18next";
+
+const hydrate = async () => {
+ await i18next
+ .use(initReactI18next) // Tell i18next to use the react-i18next plugin
+ .use(I18nextBrowserLanguageDetector) // Setup a client-side language detector
+ .use(I18NextHttpBackend) // Setup your backend
+ .init({
+ ...i18nextOptions, //spread the configuration
+ // This function detects the namespaces your routes rendered while SSR us
+ ns: getInitialNamespaces(),
+ backend: { loadPath: "/locales/{{lng}}//{{ns}}.json" },
+ detection: {
+ // Here only enable htmlTag detection, we'll detect the language only
+ // server-side with remix-i18next, by using the `` attribute
+ // we can communicate to the client the language detected server-side
+ order: ["htmlTag"],
+ // Because we only use htmlTag, there's no reason to cache the language
+ // on the browser, so we disable it
+ caches: [],
+ },
+ });
-const hydrate = () => {
startTransition(() => {
hydrateRoot(
document,
-
-
-
+
+
+
+
+
);
});
};
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
index 7b9fd9133..726026801 100644
--- a/app/entry.server.tsx
+++ b/app/entry.server.tsx
@@ -1,13 +1,22 @@
import { PassThrough } from "stream";
import type { EntryContext } from "@remix-run/node";
-import { Response } from "@remix-run/node";
+import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
+import { getEnv } from "./env.server";
+import { createInstance } from "i18next";
+import i18next from "./i18next.server";
+import { I18nextProvider, initReactI18next } from "react-i18next";
+import I18NexFsBackend from "i18next-fs-backend";
+import i18nextOptions from "./i18next-options"; // our i18n configuration file
+import { resolve } from "node:path";
const ABORT_DELAY = 5000;
-export default function handleRequest(
+global.ENV = getEnv();
+
+export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
@@ -17,19 +26,47 @@ export default function handleRequest(
? "onAllReady"
: "onShellReady";
+ // First, we create a new instance of i18next so every request will have a
+ // completely unique instance and not share any state
+ let instance = createInstance();
+
+ // Then we could detect locale from the request
+ let lng = await i18next.getLocale(request);
+ // And here we detect what namespaces the routes about to render want to use
+ let ns = i18next.getRouteNamespaces(remixContext);
+
+ // First, we create a new instance of i18next so every request will have a
+ // completely unique instance and not share any state.
+ await instance
+ .use(initReactI18next) // Tell our instance to use react-i18next
+ .use(I18NexFsBackend) // Setup our backend
+ .init({
+ ...i18nextOptions, // Spreact the configuration
+ lng, // The locale we detected above
+ ns, // The namespace the routes about to render wants to use
+ backend: {
+ loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"),
+ },
+ });
+
return new Promise((resolve, reject) => {
let didError = false;
+ // Then you can render your app wrapped in the I18nextProvider as in the
+ // entry.client file
const { pipe, abort } = renderToPipeableStream(
- ,
+
+
+ ,
{
[callbackName]: () => {
const body = new PassThrough();
+ const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
- new Response(body, {
+ new Response(stream, {
headers: responseHeaders,
status: didError ? 500 : responseStatusCode,
})
diff --git a/app/env.server.ts b/app/env.server.ts
new file mode 100644
index 000000000..e18c03537
--- /dev/null
+++ b/app/env.server.ts
@@ -0,0 +1,22 @@
+export function getEnv() {
+ return {
+ MAPBOX_GEOCODING_API: process.env.MAPBOX_GEOCODING_API,
+ MAPBOX_ACCESS_TOKEN: process.env.MAPBOX_ACCESS_TOKEN,
+ DIRECTUS_URL: process.env.DIRECTUS_URL,
+ MYBADGES_API_URL: process.env.MYBADGES_API_URL,
+ MYBADGES_URL: process.env.MYBADGES_URL,
+ NOVU_API_URL: process.env.NOVU_API_URL,
+ NOVU_WEBSOCKET_URL: process.env.NOVU_WEBSOCKET_URL,
+ NOVU_APPLICATION_IDENTIFIER: process.env.NOVU_APPLICATION_IDENTIFIER,
+ SENSORWIKI_API_URL: process.env.SENSORWIKI_API_URL,
+ };
+}
+
+type ENV = ReturnType;
+
+declare global {
+ var ENV: ENV;
+ interface Window {
+ ENV: ENV;
+ }
+}
diff --git a/app/i18next-options.ts b/app/i18next-options.ts
new file mode 100644
index 000000000..5e2dcfbdf
--- /dev/null
+++ b/app/i18next-options.ts
@@ -0,0 +1,11 @@
+export default {
+ // This is the list of languages your application supports
+ supportedLngs: ["en", "de"],
+ // This is the language you want to use in case
+ // if the user language is not in the supportedLngs
+ fallbackLng: "en",
+ // The default namespace of i18next is "translation", but you can customize it here
+ defaultNS: "common",
+ // Disabling suspense is recommended
+ react: { useSuspense: false },
+};
diff --git a/app/i18next.server.ts b/app/i18next.server.ts
new file mode 100644
index 000000000..ba5919c1e
--- /dev/null
+++ b/app/i18next.server.ts
@@ -0,0 +1,28 @@
+import Backend from "i18next-fs-backend";
+import { resolve } from "node:path";
+import { RemixI18Next } from "remix-i18next";
+import i18nextOptions from "./i18next-options";
+import { i18nCookie } from "./cookies";
+
+let i18next: RemixI18Next = new RemixI18Next({
+ detection: {
+ // persist language selection in cookie
+ cookie: i18nCookie,
+ supportedLanguages: i18nextOptions.supportedLngs,
+ fallbackLanguage: i18nextOptions.fallbackLng,
+ },
+ // This is the configuration for i18next used
+ // when translating messages server-side only
+ i18next: {
+ ...i18nextOptions,
+ backend: {
+ loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"),
+ },
+ },
+ // The backend you want to use to load the translations
+ // Tip: You could pass `resources` to the `i18next` configuration and avoid
+ // a backend here
+ backend: Backend,
+});
+
+export default i18next;
diff --git a/app/lib/date-ranges.ts b/app/lib/date-ranges.ts
new file mode 100644
index 000000000..6d80bd8aa
--- /dev/null
+++ b/app/lib/date-ranges.ts
@@ -0,0 +1,125 @@
+import {
+ endOfMonth,
+ endOfWeek,
+ endOfYesterday,
+ startOfMonth,
+ startOfToday,
+ startOfWeek,
+ startOfYesterday,
+ sub,
+} from "date-fns";
+
+type DateTimeRange = {
+ value: string;
+ label: string;
+ convert: () => { from: Date; to: Date };
+};
+
+const dateTimeRanges: DateTimeRange[] = [
+ {
+ value: "Last 30 minutes",
+ label: "Last 30 minutes",
+ convert: () => ({
+ from: sub(new Date(), { minutes: 30 }),
+ to: new Date(),
+ }),
+ },
+ {
+ value: "Last 1 hour",
+ label: "Last 1 hour",
+ convert: () => ({
+ from: sub(new Date(), { hours: 1 }),
+ to: new Date(),
+ }),
+ },
+ {
+ value: "Last 6 hours",
+ label: "Last 6 hours",
+ convert: () => ({
+ from: sub(new Date(), { hours: 6 }),
+ to: new Date(),
+ }),
+ },
+ {
+ value: "Last 12 hours",
+ label: "Last 12 hours",
+ convert: () => ({
+ from: sub(new Date(), { hours: 12 }),
+ to: new Date(),
+ }),
+ },
+ {
+ value: "Last 24 hours",
+ label: "Last 24 hours",
+ convert: () => ({
+ from: sub(new Date(), { hours: 24 }),
+ to: new Date(),
+ }),
+ },
+ {
+ value: "Last 7 days",
+ label: "Last 7 days",
+ convert: () => ({
+ from: sub(new Date(), { days: 7 }),
+ to: new Date(),
+ }),
+ },
+ {
+ value: "Last 30 days",
+ label: "Last 30 days",
+ convert: () => ({
+ from: sub(new Date(), { days: 30 }),
+ to: new Date(),
+ }),
+ },
+ {
+ value: "Yesterday",
+ label: "Yesterday",
+ convert: () => ({
+ from: startOfYesterday(),
+ to: endOfYesterday(),
+ }),
+ },
+ {
+ value: "Previous week",
+ label: "Previous week",
+ convert: () => ({
+ from: startOfWeek(sub(new Date(), { weeks: 1 })),
+ to: endOfWeek(sub(new Date(), { weeks: 1 })),
+ }),
+ },
+ {
+ value: "Previous month",
+ label: "Previous month",
+ convert: () => ({
+ from: startOfMonth(sub(new Date(), { months: 1 })),
+ to: endOfMonth(sub(new Date(), { months: 1 })),
+ }),
+ },
+ {
+ value: "Today so far",
+ label: "Today so far",
+ convert: () => ({
+ from: startOfToday(),
+ to: new Date(),
+ }),
+ },
+ {
+ value: "This week so far",
+ label: "This week so far",
+ convert: () => ({
+ from: startOfWeek(new Date()),
+ to: new Date(),
+ }),
+ },
+ {
+ value: "This month so far",
+ label: "This month so far",
+ convert: () => ({
+ from: startOfMonth(new Date()),
+ to: new Date(),
+ }),
+ },
+];
+
+export default dateTimeRanges;
diff --git a/app/lib/directus.ts b/app/lib/directus.ts
new file mode 100644
index 000000000..c88f24baa
--- /dev/null
+++ b/app/lib/directus.ts
@@ -0,0 +1,41 @@
+import { Directus } from '@directus/sdk';
+import type { ID } from '@directus/sdk';
+
+const directusUrl = process.env.DIRECTUS_URL || 'http://localhost:8055'
+
+export type UseCase = {
+ id: ID,
+ status: string,
+ image: string,
+ title: string,
+ description: string,
+ content: string,
+ language: "de" | "en"
+}
+
+export type Feature = {
+ id: ID,
+ title: string,
+ description: string,
+ icon: string,
+ language: "de" | "en"
+}
+
+export type Partner = {
+ id: ID,
+ name: string,
+ logo: string,
+ link: string
+}
+
+type DirectusCollection = {
+ use_cases: UseCase,
+ features: Feature,
+ partners: Partner
+}
+
+const directus = new Directus(directusUrl)
+
+export async function getDirectusClient () {
+ return directus
+}
\ No newline at end of file
diff --git a/app/lib/helpers.ts b/app/lib/helpers.ts
new file mode 100644
index 000000000..89da61cd1
--- /dev/null
+++ b/app/lib/helpers.ts
@@ -0,0 +1,27 @@
+export const hasObjPropMatchWithPrefixKey = (
+ object: Object,
+ prefixes: string[]
+) => {
+ const keys = Object.keys(object);
+ for (const key of keys) {
+ for (const prefix of prefixes) {
+ if (key.startsWith(prefix)) {
+ return true;
+ }
+ }
+ }
+ return false;
+};
+
+export const exposureHelper = (exposure: string) => {
+ switch (exposure) {
+ case "mobile":
+ return "MOBILE";
+ case "outdoor":
+ return "OUTDOOR";
+ case "indoor":
+ return "INDOOR";
+ default:
+ return "UNKNOWN";
+ }
+};
diff --git a/app/lib/search-map-helper.ts b/app/lib/search-map-helper.ts
new file mode 100644
index 000000000..fbe300b4a
--- /dev/null
+++ b/app/lib/search-map-helper.ts
@@ -0,0 +1,76 @@
+import type { LngLatBounds, LngLatLike, MapRef } from "react-map-gl";
+
+/**
+ * The function that is called when the user clicks on a location without bbox property in the search results. It flies the map to the location and closes the search results.
+ *
+ * @param center the coordinate of the center of the location to fly to
+ */
+export const goToLocation = (map: MapRef | undefined, center: LngLatLike) => {
+ map?.flyTo({
+ center: center,
+ animate: true,
+ speed: 1.6,
+ zoom: 20,
+ essential: true,
+ });
+};
+
+//function to zoom back out of map
+export const zoomOut = (map: MapRef | undefined) => {
+ map?.flyTo({
+ center: [0, 0],
+ animate: true,
+ speed: 1.6,
+ zoom: 1,
+ essential: true,
+ });
+};
+
+/**
+ * The function that is called when the user clicks on a location with the bbox property in the search results. It flies the map to the location and closes the search results.
+ *
+ * @param bbox
+ */
+export const goToLocationBBox = (
+ map: MapRef | undefined,
+ bbox: LngLatBounds,
+) => {
+ map?.fitBounds(bbox, {
+ animate: true,
+ speed: 1.6,
+ });
+};
+
+/**
+ * The function that is called when the user clicks on a device in the search results. It flies the map to the device and closes the search results.
+ *
+ * @param lng longitude of the device
+ * @param lat latitude of the device
+ * @param id id of the device
+ */
+export const goToDevice = (
+ map: MapRef | undefined,
+ lng: number,
+ lat: number,
+ id: string,
+) => {
+ map?.flyTo({
+ center: [lng, lat],
+ animate: true,
+ speed: 1.6,
+ zoom: 15,
+ essential: true,
+ });
+};
+
+export const goTo = (map: MapRef | undefined, item: any) => {
+ if (item.type === "device") {
+ goToDevice(map, item.lng, item.lat, item.deviceId);
+ } else if (item.type === "location") {
+ if (item.bbox) {
+ goToLocationBBox(map, item.bbox);
+ } else {
+ goToLocation(map, item.center);
+ }
+ }
+};
diff --git a/app/lib/utils.ts b/app/lib/utils.ts
new file mode 100644
index 000000000..9e3863d49
--- /dev/null
+++ b/app/lib/utils.ts
@@ -0,0 +1,94 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function getGraphColor(phenomena: string) {
+ // This is a list of all phenomena that are currently supported by the sensors.wiki API (https://api.sensors.wiki/phenomena).
+ // colorcodes need to be updated - thi is what ChatGPT suggested
+ switch (phenomena.toLowerCase()) {
+ case "barometric pressure":
+ case "barometrischer druck":
+ return "#0000FF"; // Blue
+ case "relative humidity":
+ case "relative luftfeuchte":
+ return "#008000"; // Green
+ case "co2":
+ return "#FFA500"; // Orange
+ case "soil moisture":
+ case "bodenfeuchte":
+ return "#A52A2A"; // Brown
+ case "ambient light":
+ case "umgebungslicht":
+ return "#FFFF00"; // Yellow
+ case "ultraviolet a light":
+ case "ultraviolett a licht":
+ return "#800080"; // Purple
+ case "air temperature":
+ case "temperatur":
+ return "#00FFFF"; // Cyan
+ case "pm2.5":
+ return "#808080"; // Gray
+ case "pm10 concentration":
+ case "pm10-konzentration":
+ return "#FFC0CB"; // Pink
+ case "humidity":
+ case "feuchtigkeit":
+ return "#008080"; // Teal
+ case "precipitation":
+ case "niederschlag":
+ return "#ADD8E6"; // Light Blue
+ case "volatile organic compound (voc)":
+ case "flüchtige organische verbindungen (fov)":
+ return "#FF00FF"; // Magenta
+ case "voltage":
+ case "spannung":
+ return "#FFD700"; // Gold
+ case "sound level":
+ case "lautstärke":
+ return "#00FF00"; // Lime
+ case "water level":
+ case "wasserstand":
+ return "#000080"; // Navy
+ case "water temperature":
+ case "wassertemperatur":
+ return "#4B0082"; // Indigo
+ case "wind direction":
+ case "windrichtung":
+ return "#808000"; // Olive
+ case "wind speed":
+ case "windgeschwindigkeit":
+ return "#800000"; // Maroon
+ default:
+ return "#000000"; // Default color if phenomena is not found (Black)
+ }
+}
+
+export function adjustBrightness(color: string, amount: number): string {
+ // Convert hex to RGB
+ const usePound = color[0] === "#";
+ const num = parseInt(color.slice(1), 16);
+
+ let r = (num >> 16) + amount;
+ let g = ((num >> 8) & 0x00ff) + amount;
+ let b = (num & 0x0000ff) + amount;
+
+ // Ensure RGB values are within valid range
+ r = Math.max(Math.min(255, r), 0);
+ g = Math.max(Math.min(255, g), 0);
+ b = Math.max(Math.min(255, b), 0);
+
+ return (
+ (usePound ? "#" : "") +
+ (r << 16 | g << 8 | b).toString(16).padStart(6, "0")
+ );
+}
+
+export function datesHave48HourRange(date1: Date, date2: Date): boolean {
+ const timeDifference = Math.abs(date2.getTime() - date1.getTime());
+ const hoursDifference = timeDifference / (1000 * 60 * 60);
+
+ return hoursDifference <= 48;
+}
diff --git a/app/models/badge.server.ts b/app/models/badge.server.ts
new file mode 100644
index 000000000..f653a6843
--- /dev/null
+++ b/app/models/badge.server.ts
@@ -0,0 +1,90 @@
+// Define the structure of the MyBadge object
+export interface MyBadge {
+ acceptance: string;
+ badgeclass: string;
+ badgeclassOpenBadgeId: string;
+ entityId: string;
+ entityType: string;
+ expires: null | string;
+ image: string;
+ issuedOn: string;
+ issuer: string;
+ issuerOpenBadgeId: string;
+ name: string;
+ narrative: null | string;
+ openBadgeId: string;
+ pending: boolean;
+ recipient: {
+ identity: string;
+ hashed: boolean;
+ type: string;
+ plaintextIdentity: string;
+ salt: string;
+ };
+ revocationReason: null | string;
+ revoked: boolean;
+}
+
+export async function getMyBadgesAccessToken() {
+ // Make a request to get an access token from the MyBadges API
+ const authRequest = new Request(process.env.MYBADGES_API_URL + "o/token", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ grant_type: "password",
+ username: `${process.env.MYBADGES_SERVERADMIN_USERNAME}`,
+ password: `${process.env.MYBADGES_SERVERADMIN_PASSWORD}`,
+ client_id: `${process.env.MYBADGES_CLIENT_ID}`,
+ client_secret: `${process.env.MYBADGES_CLIENT_SECRET}`,
+ scope: "rw:serverAdmin",
+ }),
+ });
+ const authResponse = await fetch(authRequest);
+ const authData = await authResponse.json();
+
+ return authData;
+}
+
+export async function getAllBadges(accessToken: string) {
+ const allBadgesRequest = new Request(
+ process.env.MYBADGES_API_URL + "v2/issuers/" + process.env.MYBADGES_ISSUERID_OSEM + "/badgeclasses",
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+ const allBadgesResponse = await fetch(allBadgesRequest);
+ const allBadgesData = await allBadgesResponse.json();
+ return allBadgesData;
+}
+
+export async function getUserBackpack(email: string, accessToken: string) {
+ // Make a request to the backpack endpoint with the bearer token
+ const backpackRequest = new Request(
+ process.env.MYBADGES_API_URL + "v2/backpack/" + email,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+ const backpackResponse = await fetch(backpackRequest);
+ if(backpackResponse.status === 500) {
+ return null;
+ }
+ const backpackData = await backpackResponse.json();
+
+ // filter the badges by issuer (only OSeM badges)
+ const filteredBadgeData = backpackData.result?.filter(
+ (badge: MyBadge) => badge.issuer === process.env.MYBADGES_ISSUERID_OSEM
+ );
+
+ return filteredBadgeData;
+}
diff --git a/app/models/device.server.ts b/app/models/device.server.ts
new file mode 100644
index 000000000..068d70771
--- /dev/null
+++ b/app/models/device.server.ts
@@ -0,0 +1,395 @@
+import { drizzleClient } from "~/db.server";
+import { point } from "@turf/helpers";
+import type { Point } from "geojson";
+import { device, sensor, type Device, type Sensor } from "~/schema";
+import { eq } from "drizzle-orm";
+
+export function getDevice({ id }: Pick) {
+ return drizzleClient.query.device.findFirst({
+ where: (device, { eq }) => eq(device.id, id),
+ columns: {
+ createdAt: true,
+ description: true,
+ exposure: true,
+ id: true,
+ image: true,
+ latitude: true,
+ longitude: true,
+ link: true,
+ model: true,
+ name: true,
+ sensorWikiModel: true,
+ status: true,
+ updatedAt: true,
+ },
+ });
+}
+
+export function getDeviceWithoutSensors({ id }: Pick) {
+ return drizzleClient.query.device.findFirst({
+ where: (device, { eq }) => eq(device.id, id),
+ columns: {
+ id: true,
+ name: true,
+ exposure: true,
+ updatedAt: true,
+ latitude: true,
+ longitude: true,
+ },
+ });
+}
+
+export function updateDeviceInfo({
+ id,
+ name,
+ exposure,
+}: Pick) {
+ return drizzleClient
+ .update(device)
+ .set({ name: name, exposure: exposure })
+ .where(eq(device.id, id));
+ // return prisma.device.update({
+ // where: { id },
+ // data: {
+ // name: name,
+ // exposure: exposure,
+ // },
+ // });
+}
+
+export function updateDeviceLocation({
+ id,
+ latitude,
+ longitude,
+}: Pick