diff --git a/app/components/search/search-list-item.tsx b/app/components/search/search-list-item.tsx
index 54fe6221a..93d1c4a08 100644
--- a/app/components/search/search-list-item.tsx
+++ b/app/components/search/search-list-item.tsx
@@ -1,5 +1,6 @@
// import type { LngLatBounds, LngLatLike } from "react-map-gl";
import { useNavigate } from "@remix-run/react";
+import { useSearchParams } from "@remix-run/react";
import { useMap } from "react-map-gl";
import { goTo } from "~/lib/search-map-helper";
@@ -18,6 +19,11 @@ interface SearchListItemProps {
export default function SearchListItem(props: SearchListItemProps) {
const navigate = useNavigate();
const { osem } = useMap();
+ const [searchParams] = useSearchParams();
+ const navigateTo =
+ (props.type === "device" ? `/explore/${props.item.deviceId}` : "/explore") +
+ // @ts-ignore
+ (searchParams.size > 0 ? "?" + searchParams.toString() : "");
// console.log(props.index)
@@ -27,11 +33,7 @@ export default function SearchListItem(props: SearchListItemProps) {
onClick={() => {
goTo(osem, props.item);
props.setShowSearch(false);
- navigate(
- props.type === "device"
- ? `/explore/${props.item.deviceId}`
- : "/explore"
- );
+ navigate(navigateTo);
}}
data-active={props.active}
onMouseEnter={() => {
diff --git a/app/components/search/search-list.tsx b/app/components/search/search-list.tsx
index 8a422dbe4..c4993fed0 100644
--- a/app/components/search/search-list.tsx
+++ b/app/components/search/search-list.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useMap } from "react-map-gl";
-import { useNavigate } from "@remix-run/react";
+import { useNavigate, useSearchParams } from "@remix-run/react";
import {
CpuChipIcon,
@@ -62,7 +62,31 @@ export default function SearchList(props: SearchListProps) {
var searchResultsAll = props.searchResultsDevice.concat(
props.searchResultsLocation
);
- var selected = searchResultsAll[cursor];
+ const [selected, setSelected] = useState(searchResultsAll[cursor]);
+
+ const [searchParams] = useSearchParams();
+ const [navigateTo, setNavigateTo] = useState(
+ (selected.type === "device"
+ ? `/explore/${selected.deviceId}`
+ : "/explore") +
+ // @ts-ignore
+ (searchParams.size > 0 ? "?" + searchParams.toString() : "")
+ );
+
+ useEffect(() => {
+ setSelected(searchResultsAll[cursor]);
+ }, [cursor, searchResultsAll]);
+
+ useEffect(() => {
+ if (selected.type === "device") {
+ // @ts-ignore
+ setNavigateTo(`/explore/${selected.deviceId}` + (searchParams.size > 0 ? "?" + searchParams.toString() : ""));
+ } else if (selected.type === "location") {
+ // @ts-ignore
+ setNavigateTo("/explore" + (searchParams.size > 0 ? "?" + searchParams.toString() : ""));
+ }
+ console.log(navigateTo);
+ }, [selected, searchParams, navigateTo]);
const setShowSearchCallback = useCallback((state: boolean) => {
props.setShowSearch(state);
@@ -83,13 +107,17 @@ export default function SearchList(props: SearchListProps) {
if (length !== 0 && enterPress) {
goTo(osem, selected);
setShowSearchCallback(false);
- navigate(
- selected.type === "device"
- ? `/explore/${selected.deviceId}`
- : "/explore"
- );
+ navigate(navigateTo);
}
- }, [enterPress, length, osem, navigate, selected, setShowSearchCallback]);
+ }, [
+ enterPress,
+ length,
+ osem,
+ navigate,
+ selected,
+ setShowSearchCallback,
+ navigateTo,
+ ]);
const handleDigitPress = (event: any) => {
if (
@@ -97,16 +125,11 @@ export default function SearchList(props: SearchListProps) {
Number(event.key) <= length &&
event.ctrlKey
) {
- selected = searchResultsAll[Number(event.key) - 1];
event.preventDefault();
setCursor(Number(event.key) - 1);
goTo(osem, selected);
- setTimeout(() => setShowSearchCallback(false), 500);
- navigate(
- selected.type === "device"
- ? `/explore/${selected.deviceId}`
- : "/explore"
- );
+ setTimeout(() => {setShowSearchCallback(false); navigate(navigateTo);}, 500);
+
}
};
diff --git a/app/components/ui/avatar.tsx b/app/components/ui/avatar.tsx
new file mode 100644
index 000000000..51e507ba9
--- /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/calendar.tsx b/app/components/ui/calendar.tsx
index 53c5cab6a..e1bef38ed 100644
--- a/app/components/ui/calendar.tsx
+++ b/app/components/ui/calendar.tsx
@@ -13,29 +13,39 @@ function Calendar({
className,
classNames,
showOutsideDays = true,
+ // disabled={ after: new Date() },
...props
}: CalendarProps) {
+
return (
(
+
+
+ {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/input.tsx b/app/components/ui/input.tsx
new file mode 100644
index 000000000..929e05f50
--- /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..b32881e64
--- /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 { VariantProps, cva } 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..ceee78f28
--- /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/tabs.tsx b/app/components/ui/tabs.tsx
new file mode 100644
index 000000000..b706ec977
--- /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/toast.tsx b/app/components/ui/toast.tsx
new file mode 100644
index 000000000..e06db931f
--- /dev/null
+++ b/app/components/ui/toast.tsx
@@ -0,0 +1,128 @@
+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",
+ },
+ },
+ 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/use-toast.ts b/app/components/ui/use-toast.ts
new file mode 100644
index 000000000..b672dcf2c
--- /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 = 2
+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/routes/explore.tsx b/app/routes/explore.tsx
index f916a0ae2..220d60cd7 100644
--- a/app/routes/explore.tsx
+++ b/app/routes/explore.tsx
@@ -26,10 +26,15 @@ import {
} from "~/components/map/layers";
import type { Device } from "@prisma/client";
import OverlaySearch from "~/components/search/overlay-search";
+import { Toaster } from "~/components/ui//toaster";
+import { getUser } from "~/session.server";
export async function loader({ request }: LoaderArgs) {
const devices = await getDevices();
- return json({ devices });
+
+ const user = await getUser(request);
+
+ return json({ devices, user });
}
export const links: LinksFunction = () => {
@@ -119,13 +124,8 @@ export default function Explore() {
- {showSearch ? (
-
- ) : null}
+
+ { showSearch ? : null }
diff --git a/app/routes/explore/login.tsx b/app/routes/explore/login.tsx
new file mode 100644
index 000000000..93d6b3270
--- /dev/null
+++ b/app/routes/explore/login.tsx
@@ -0,0 +1,229 @@
+import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node";
+import { json, redirect } from "@remix-run/node";
+import {
+ Form,
+ Link,
+ useActionData,
+ useNavigation,
+ useSearchParams,
+} from "@remix-run/react";
+import * as React from "react";
+
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+import { verifyLogin } from "~/models/user.server";
+import { createUserSession, getUserId } from "~/session.server";
+import { safeRedirect, validateEmail } from "~/utils";
+import { X } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+export async function loader({ request }: LoaderArgs) {
+ const userId = await getUserId(request);
+ if (userId) return redirect("/");
+ return json({});
+}
+
+export async function action({ request }: ActionArgs) {
+ const formData = await request.formData();
+ const email = formData.get("email");
+ const password = formData.get("password");
+ const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
+ const remember = formData.get("remember");
+
+ if (!validateEmail(email)) {
+ return json(
+ { errors: { email: "Email is invalid", password: null } },
+ { status: 400 }
+ );
+ }
+
+ if (typeof password !== "string" || password.length === 0) {
+ return json(
+ { errors: { password: "Password is required", email: null } },
+ { status: 400 }
+ );
+ }
+
+ if (password.length < 8) {
+ return json(
+ { errors: { password: "Password is too short", email: null } },
+ { status: 400 }
+ );
+ }
+
+ const user = await verifyLogin(email, password);
+
+ if (!user) {
+ return json(
+ { errors: { email: "Invalid email or password", password: null } },
+ { status: 400 }
+ );
+ }
+
+ return createUserSession({
+ request,
+ userId: user.id,
+ remember: remember === "on" ? true : false,
+ redirectTo,
+ });
+}
+
+export const meta: MetaFunction = () => {
+ return {
+ title: "Login",
+ };
+};
+
+export default function LoginPage() {
+ const [searchParams] = useSearchParams();
+ // @ts-ignore
+ const redirectTo = (searchParams.size > 0 ? "/explore?" + searchParams.toString() : "/explore")
+ const actionData = useActionData();
+ const emailRef = React.useRef(null);
+ const passwordRef = React.useRef(null);
+
+ const { t } = useTranslation("login");
+ const navigation = useNavigation();
+ const isLoggingIn = Boolean(navigation.state === "submitting");
+
+ React.useEffect(() => {
+ if (actionData?.errors?.email) {
+ emailRef.current?.focus();
+ } else if (actionData?.errors?.password) {
+ passwordRef.current?.focus();
+ }
+ }, [actionData]);
+
+ return (
+
+
+
+
+
+
{t("login_label")}
+
+
+
+
+
+
+ );
+}
diff --git a/app/routes/explore/register.tsx b/app/routes/explore/register.tsx
new file mode 100644
index 000000000..78372b91e
--- /dev/null
+++ b/app/routes/explore/register.tsx
@@ -0,0 +1,221 @@
+import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node";
+import { json, redirect } from "@remix-run/node";
+import {
+ Form,
+ Link,
+ useActionData,
+ useNavigation,
+ useSearchParams,
+} from "@remix-run/react";
+import * as React from "react";
+
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+import { createUserSession, getUserId } from "~/session.server";
+import { createUser, getUserByEmail } from "~/models/user.server";
+import { safeRedirect, validateEmail } from "~/utils";
+
+import { X } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+export async function loader({ request }: LoaderArgs) {
+ const userId = await getUserId(request);
+ if (userId) return redirect("/");
+ return json({});
+}
+
+export async function action({ request }: ActionArgs) {
+ const formData = await request.formData();
+ const email = formData.get("email");
+ const password = formData.get("password");
+ const redirectTo = safeRedirect(formData.get("redirectTo"), "/explore");
+
+ if (!validateEmail(email)) {
+ return json(
+ { errors: { email: "Email is invalid", password: null } },
+ { status: 400 }
+ );
+ }
+
+ if (typeof password !== "string" || password.length === 0) {
+ return json(
+ { errors: { password: "Password is required", email: null } },
+ { status: 400 }
+ );
+ }
+
+ if (password.length < 8) {
+ return json(
+ { errors: { password: "Password is too short", email: null } },
+ { status: 400 }
+ );
+ }
+
+ const existingUser = await getUserByEmail(email);
+ if (existingUser) {
+ return json(
+ {
+ errors: {
+ email: "A user already exists with this email",
+ password: null,
+ },
+ },
+ { status: 400 }
+ );
+ }
+
+ const user = await createUser(email, password);
+
+ return createUserSession({
+ request,
+ userId: user.id,
+ remember: false,
+ redirectTo,
+ });
+}
+
+export const meta: MetaFunction = () => {
+ return {
+ title: "Sign Up",
+ };
+};
+
+export default function RegisterDialog() {
+ const { t } = useTranslation("register");
+ const [searchParams] = useSearchParams();
+ // @ts-ignore
+ const redirectTo = (searchParams.size > 0 ? "/explore?" + searchParams.toString() : "/explore");
+ const actionData = useActionData();
+ const emailRef = React.useRef(null);
+ const passwordRef = React.useRef(null);
+
+ const navigation = useNavigation();
+ const isCreating = Boolean(navigation.state === "submitting");
+
+ React.useEffect(() => {
+ if (actionData?.errors?.email) {
+ emailRef.current?.focus();
+ } else if (actionData?.errors?.password) {
+ passwordRef.current?.focus();
+ }
+ }, [actionData]);
+
+ return (
+
+
+
+
+
+
{t("register_label")}
+
+
+
+
+
+
+ );
+}
diff --git a/app/routes/logout.tsx b/app/routes/logout.tsx
index 5def3ddfd..a22cd74f8 100644
--- a/app/routes/logout.tsx
+++ b/app/routes/logout.tsx
@@ -4,9 +4,12 @@ import { redirect } from "@remix-run/node";
import { logout } from "~/session.server";
export async function action({ request }: ActionArgs) {
- return logout(request);
+ const formData = await request.formData();
+ const redirectTo = formData.get("redirectTo")?.toString() || "/explore";
+ console.log(redirectTo);
+ return logout({request, redirectTo});
}
export async function loader() {
- return redirect("/");
+ return redirect("/explore/login");
}
diff --git a/app/session.server.ts b/app/session.server.ts
index 31a861e4b..210eec943 100644
--- a/app/session.server.ts
+++ b/app/session.server.ts
@@ -19,7 +19,7 @@ export const sessionStorage = createCookieSessionStorage({
const USER_SESSION_KEY = "userId";
-export async function getSession(request: Request) {
+export async function getUserSession(request: Request) {
const cookie = request.headers.get("Cookie");
return sessionStorage.getSession(cookie);
}
@@ -27,7 +27,7 @@ export async function getSession(request: Request) {
export async function getUserId(
request: Request
): Promise {
- const session = await getSession(request);
+ const session = await getUserSession(request);
const userId = session.get(USER_SESSION_KEY);
return userId;
}
@@ -39,7 +39,7 @@ export async function getUser(request: Request) {
const user = await getUserById(userId);
if (user) return user;
- throw await logout(request);
+ throw await logout({request: request, redirectTo: "/explore"});
}
export async function requireUserId(
@@ -60,7 +60,7 @@ export async function requireUser(request: Request) {
const user = await getUserById(userId);
if (user) return user;
- throw await logout(request);
+ throw await logout({request: request, redirectTo: "/explore"});
}
export async function createUserSession({
@@ -74,7 +74,7 @@ export async function createUserSession({
remember: boolean;
redirectTo: string;
}) {
- const session = await getSession(request);
+ const session = await getUserSession(request);
session.set(USER_SESSION_KEY, userId);
return redirect(redirectTo, {
headers: {
@@ -87,9 +87,15 @@ export async function createUserSession({
});
}
-export async function logout(request: Request) {
- const session = await getSession(request);
- return redirect("/", {
+export async function logout({
+ request,
+ redirectTo,
+}: {
+ request: Request;
+ redirectTo: string;
+}) {
+ const session = await getUserSession(request);
+ return redirect(redirectTo, {
headers: {
"Set-Cookie": await sessionStorage.destroySession(session),
},
diff --git a/package-lock.json b/package-lock.json
index d8efb147e..2c296e1d8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,8 +10,13 @@
"@heroicons/react": "^2.0.15",
"@mantine/hooks": "^6.0.8",
"@prisma/client": "^4.9.0",
- "@radix-ui/react-dialog": "^1.0.2",
+ "@radix-ui/react-avatar": "^1.0.2",
+ "@radix-ui/react-dialog": "^1.0.3",
+ "@radix-ui/react-dropdown-menu": "^2.0.4",
+ "@radix-ui/react-label": "^2.0.1",
"@radix-ui/react-navigation-menu": "^1.1.1",
+ "@radix-ui/react-popover": "^1.0.5",
+ "@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3",
"@remix-run/express": "^1.12.0",
"@remix-run/node": "^1.12.0",
@@ -28,6 +33,7 @@
"date-fns": "^2.29.3",
"express": "^4.18.2",
"express-prometheus-middleware": "^1.2.0",
+ "get-user-locale": "^2.2.1",
"i18next": "^22.4.15",
"i18next-browser-languagedetector": "^7.0.1",
"i18next-fs-backend": "^2.1.1",
@@ -2633,6 +2639,32 @@
"npm": ">=6.0.0"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.7.3.tgz",
+ "integrity": "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg=="
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.5.4.tgz",
+ "integrity": "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==",
+ "dependencies": {
+ "@floating-ui/core": "^0.7.3"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-0.7.2.tgz",
+ "integrity": "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg==",
+ "dependencies": {
+ "@floating-ui/dom": "^0.5.3",
+ "use-isomorphic-layout-effect": "^1.1.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -3048,6 +3080,35 @@
"@babel/runtime": "^7.13.10"
}
},
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.2.tgz",
+ "integrity": "sha512-fqYwhhI9IarZ0ll2cUSfKuXHlJK0qE4AfnRrPBbRwEH/4mGQn04/QFGomLi8TXWIdv9WJk//KgGm+aDxVIr1wA==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-primitive": "1.0.2"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
+ "node_modules/@radix-ui/react-avatar": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.0.2.tgz",
+ "integrity": "sha512-XRL8z2l9V7hRLCPjHWg/34RBPZUGpmOjmsRSNvIh2DI28GyIWDChbcsDUVc63MzOItk6Q83Ob2KK8k2FUlXlGA==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "@radix-ui/react-use-layout-effect": "1.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
"node_modules/@radix-ui/react-collection": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.2.tgz",
@@ -3140,6 +3201,25 @@
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.4.tgz",
+ "integrity": "sha512-y6AT9+MydyXcByivdK1+QpjWoKaC7MLjkS/cH1Q3keEyMvDkiY85m8o2Bi6+Z1PPUlCsMULopxagQOSfN0wahg==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-menu": "2.0.4",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-use-controllable-state": "1.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.0.tgz",
@@ -3178,6 +3258,49 @@
"react": "^16.8 || ^17.0 || ^18.0"
}
},
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.0.1.tgz",
+ "integrity": "sha512-qcfbS3B8hTYmEO44RNcXB6pegkxRsJIbdxTMu0PEX0Luv5O2DvTIwwVYxQfUwLpM88EL84QRPLOLgwUSApMsLQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-primitive": "1.0.2"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.0.4.tgz",
+ "integrity": "sha512-mzKR47tZ1t193trEqlQoJvzY4u9vYfVH16ryBrVrCAGZzkgyWnMQYEZdUkM7y8ak9mrkKtJiqB47TlEnubeOFQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-collection": "1.0.2",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-direction": "1.0.0",
+ "@radix-ui/react-dismissable-layer": "1.0.3",
+ "@radix-ui/react-focus-guards": "1.0.0",
+ "@radix-ui/react-focus-scope": "1.0.2",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-popper": "1.1.1",
+ "@radix-ui/react-portal": "1.0.2",
+ "@radix-ui/react-presence": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-roving-focus": "1.0.3",
+ "@radix-ui/react-slot": "1.0.1",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "aria-hidden": "^1.1.1",
+ "react-remove-scroll": "2.5.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.1.2.tgz",
@@ -3204,6 +3327,55 @@
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.5.tgz",
+ "integrity": "sha512-GRHZ8yD12MrN2NLobHPE8Rb5uHTxd9x372DE9PPNnBjpczAQHcZ5ne0KXG4xpf+RDdXSzdLv9ym6mYJCDTaUZg==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-dismissable-layer": "1.0.3",
+ "@radix-ui/react-focus-guards": "1.0.0",
+ "@radix-ui/react-focus-scope": "1.0.2",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-popper": "1.1.1",
+ "@radix-ui/react-portal": "1.0.2",
+ "@radix-ui/react-presence": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-slot": "1.0.1",
+ "@radix-ui/react-use-controllable-state": "1.0.0",
+ "aria-hidden": "^1.1.1",
+ "react-remove-scroll": "2.5.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.1.tgz",
+ "integrity": "sha512-keYDcdMPNMjSC8zTsZ8wezUMiWM9Yj14wtF3s0PTIs9srnEPC9Kt2Gny1T3T81mmSeyDjZxsD9N5WCwNNb712w==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@floating-ui/react-dom": "0.7.2",
+ "@radix-ui/react-arrow": "1.0.2",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "@radix-ui/react-use-layout-effect": "1.0.0",
+ "@radix-ui/react-use-rect": "1.0.0",
+ "@radix-ui/react-use-size": "1.0.0",
+ "@radix-ui/rect": "1.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
"node_modules/@radix-ui/react-portal": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.2.tgz",
@@ -3244,6 +3416,27 @@
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.3.tgz",
+ "integrity": "sha512-stjCkIoMe6h+1fWtXlA6cRfikdBzCLp3SnVk7c48cv/uy3DTGoXhN76YaOYUJuy3aEDvDIKwKR5KSmvrtPvQPQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-collection": "1.0.2",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-direction": "1.0.0",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "@radix-ui/react-use-controllable-state": "1.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
"node_modules/@radix-ui/react-slot": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz",
@@ -3256,6 +3449,26 @@
"react": "^16.8 || ^17.0 || ^18.0"
}
},
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.3.tgz",
+ "integrity": "sha512-4CkF/Rx1GcrusI/JZ1Rvyx4okGUs6wEenWA0RG/N+CwkRhTy7t54y7BLsWUXrAz/GRbBfHQg/Odfs/RoW0CiRA==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-direction": "1.0.0",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-presence": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-roving-focus": "1.0.3",
+ "@radix-ui/react-use-controllable-state": "1.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
"node_modules/@radix-ui/react-toast": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.3.tgz",
@@ -3337,6 +3550,30 @@
"react": "^16.8 || ^17.0 || ^18.0"
}
},
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.0.tgz",
+ "integrity": "sha512-TB7pID8NRMEHxb/qQJpvSt3hQU4sqNPM1VCTjTRjEOa7cEop/QMuq8S6fb/5Tsz64kqSvB9WnwsDHtjnrM9qew==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/rect": "1.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.0.0.tgz",
+ "integrity": "sha512-imZ3aYcoYCKhhgNpkNDh/aTiU05qw9hX+HHI1QDBTyIlcFjgeFlKKySNGMwTp7nYFLQg/j0VA2FmCY4WPDDHMg==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-use-layout-effect": "1.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.2.tgz",
@@ -3350,6 +3587,14 @@
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
+ "node_modules/@radix-ui/rect": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.0.tgz",
+ "integrity": "sha512-d0O68AYy/9oeEy1DdC07bz1/ZXX+DqCskRd3i4JzLSTXwefzaepQrKjXC7aNM8lTHjFLDO0pDgaEiQ7jEk+HVg==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10"
+ }
+ },
"node_modules/@remix-run/dev": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-1.14.1.tgz",
@@ -4540,6 +4785,19 @@
"@types/node": "*"
}
},
+ "node_modules/@types/lodash": {
+ "version": "4.14.195",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz",
+ "integrity": "sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg=="
+ },
+ "node_modules/@types/lodash.memoize": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@types/lodash.memoize/-/lodash.memoize-4.1.7.tgz",
+ "integrity": "sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
"node_modules/@types/mapbox-gl": {
"version": "2.7.10",
"resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-2.7.10.tgz",
@@ -5477,38 +5735,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
- "node_modules/acorn-node": {
- "version": "1.8.2",
- "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz",
- "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==",
- "dev": true,
- "dependencies": {
- "acorn": "^7.0.0",
- "acorn-walk": "^7.0.0",
- "xtend": "^4.0.2"
- }
- },
- "node_modules/acorn-node/node_modules/acorn": {
- "version": "7.4.1",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
- "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
- "dev": true,
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-walk": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz",
- "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
- "dev": true,
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -7675,15 +7901,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/defined": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz",
- "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==",
- "dev": true,
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/degenerator": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-3.0.2.tgz",
@@ -7765,23 +7982,6 @@
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
},
- "node_modules/detective": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz",
- "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==",
- "dev": true,
- "dependencies": {
- "acorn-node": "^1.8.2",
- "defined": "^1.0.0",
- "minimist": "^1.2.6"
- },
- "bin": {
- "detective": "bin/detective.js"
- },
- "engines": {
- "node": ">=0.8.0"
- }
- },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -10783,6 +10983,18 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/get-user-locale": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/get-user-locale/-/get-user-locale-2.2.1.tgz",
+ "integrity": "sha512-3814zipTZ2MvczOcppEXB3jXu+0HWwj5WmPI6//SeCnUIUaRXu7W4S54eQZTEPadlMZefE+jAlPOn+zY3tD4Qw==",
+ "dependencies": {
+ "@types/lodash.memoize": "^4.1.7",
+ "lodash.memoize": "^4.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/wojtekmaj/get-user-locale?sponsor=1"
+ }
+ },
"node_modules/getos": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz",
@@ -12342,6 +12554,15 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/jiti": {
+ "version": "1.18.2",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz",
+ "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==",
+ "dev": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
"node_modules/joi": {
"version": "17.8.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.8.3.tgz",
@@ -12740,8 +12961,7 @@
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
- "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
- "dev": true
+ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
@@ -18634,20 +18854,20 @@
}
},
"node_modules/tailwindcss": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.7.tgz",
- "integrity": "sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.0.tgz",
+ "integrity": "sha512-hOXlFx+YcklJ8kXiCAfk/FMyr4Pm9ck477G0m/us2344Vuj355IpoEDB5UmGAsSpTBmr+4ZhjzW04JuFXkb/fw==",
"dev": true,
"dependencies": {
"arg": "^5.0.2",
"chokidar": "^3.5.3",
"color-name": "^1.1.4",
- "detective": "^5.2.1",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
"fast-glob": "^3.2.12",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
+ "jiti": "^1.17.2",
"lilconfig": "^2.0.6",
"micromatch": "^4.0.5",
"normalize-path": "^3.0.0",
@@ -18661,7 +18881,8 @@
"postcss-selector-parser": "^6.0.11",
"postcss-value-parser": "^4.2.0",
"quick-lru": "^5.1.1",
- "resolve": "^1.22.1"
+ "resolve": "^1.22.1",
+ "sucrase": "^3.29.0"
},
"bin": {
"tailwind": "lib/cli.js",
@@ -19597,6 +19818,19 @@
"react": ">=16"
}
},
+ "node_modules/use-isomorphic-layout-effect": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz",
+ "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/use-sidecar": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
diff --git a/package.json b/package.json
index 08027d8af..2eaf4e3e1 100644
--- a/package.json
+++ b/package.json
@@ -39,8 +39,13 @@
"@heroicons/react": "^2.0.15",
"@mantine/hooks": "^6.0.8",
"@prisma/client": "^4.9.0",
- "@radix-ui/react-dialog": "^1.0.2",
+ "@radix-ui/react-avatar": "^1.0.2",
+ "@radix-ui/react-dialog": "^1.0.3",
+ "@radix-ui/react-dropdown-menu": "^2.0.4",
+ "@radix-ui/react-label": "^2.0.1",
"@radix-ui/react-navigation-menu": "^1.1.1",
+ "@radix-ui/react-popover": "^1.0.5",
+ "@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3",
"@remix-run/express": "^1.12.0",
"@remix-run/node": "^1.12.0",
@@ -57,6 +62,7 @@
"date-fns": "^2.29.3",
"express": "^4.18.2",
"express-prometheus-middleware": "^1.2.0",
+ "get-user-locale": "^2.2.1",
"i18next": "^22.4.15",
"i18next-browser-languagedetector": "^7.0.1",
"i18next-fs-backend": "^2.1.1",
diff --git a/public/locales/de/login.json b/public/locales/de/login.json
new file mode 100644
index 000000000..f22c19a36
--- /dev/null
+++ b/public/locales/de/login.json
@@ -0,0 +1,9 @@
+{
+ "login_label": "Einloggen",
+ "email_label": "E-Mail",
+ "password_label": "Passwort",
+ "transition_label": "Wird eingeloggt...",
+ "remember_label": "Eingeloggt bleiben",
+ "no_account_label": "Noch kein Konto?",
+ "register_label": "Registrieren"
+}
diff --git a/public/locales/de/menu.json b/public/locales/de/menu.json
new file mode 100644
index 000000000..b5dede065
--- /dev/null
+++ b/public/locales/de/menu.json
@@ -0,0 +1,22 @@
+{
+ "title": "Wilkommen",
+ "subtitle": "Bitte loggen Sie sich ein, um mehr Inhalte zu sehen.",
+ "profile_label": "Profil",
+ "settings_label": "Einstellungen",
+ "my_devices_label": "Meine Geräte",
+ "add_device_label": "Gerät hinzufügen",
+ "tutorials_label": "Tutorials",
+ "api_docs_label": "API Dokumentation",
+ "faq_label": "FAQ",
+ "contact_label": "Kontakt",
+ "imprint_label": "Impressum",
+ "data_protection_label": "Datenschutz",
+ "donate_label": "Spenden",
+ "promotion_label": "Förderung",
+ "login_label": "Einloggen",
+ "logout_label": "Ausloggen",
+
+ "toast_login_success": "Erfolgreich eingeloggt",
+ "toast_logout_success": "Erfolgreich ausgeloggt",
+ "toast_user_creation_success": "Benutzer erfolgreich erstellt"
+}
\ No newline at end of file
diff --git a/public/locales/de/navbar.json b/public/locales/de/navbar.json
new file mode 100644
index 000000000..74d257321
--- /dev/null
+++ b/public/locales/de/navbar.json
@@ -0,0 +1,15 @@
+{
+ "date_picker_label": "Wähle einen Zeitpunkt",
+ "date_range_picker_label": "Wähle einen Zeitraum",
+ "search_label": "Suche",
+ "temperature_label": "Temperatur",
+ "date_label": "Zeitraum",
+ "ctrl": "Strg",
+ "button": "Anzeigen",
+ "live_label": "Live",
+ "live_description": "Zeige live Daten auf der Karte an. Diese Daten werden jede 5 Minuten aktualisiert. Wenn Du auf eine Station klickst kannst du dir die letzten Messungen anzeigen.",
+ "pointintime_label": "Zeitpunkt",
+ "pointintime_description": "Zeige einen historischen Zeitpunkt auf der Karte an.",
+ "timeperiod_label": "Zeitraum",
+ "timeperiod_description": "Erforsche die Entwicklung der Phänomene in einem bestimmten Zeitfenster."
+}
\ No newline at end of file
diff --git a/public/locales/de/register.json b/public/locales/de/register.json
new file mode 100644
index 000000000..5f087ae93
--- /dev/null
+++ b/public/locales/de/register.json
@@ -0,0 +1,9 @@
+{
+ "register_label": "Registrieren",
+ "email_label": "E-Mail",
+ "password_label": "Passwort",
+ "account_label": "Konto erstellen",
+ "transition_label": "Erstelle Konto...",
+ "already_account_label": "Bereits ein Konto?",
+ "login_label": "Einloggen"
+}
\ No newline at end of file
diff --git a/public/locales/en/login.json b/public/locales/en/login.json
new file mode 100644
index 000000000..ed697335d
--- /dev/null
+++ b/public/locales/en/login.json
@@ -0,0 +1,9 @@
+{
+ "login_label": "Log in",
+ "email_label": "Email",
+ "password_label": "Password",
+ "transition_label": "Logging in...",
+ "remember_label": "Remember me",
+ "no_account_label": "Don't have an account?",
+ "register_label": "Sign up"
+}
\ No newline at end of file
diff --git a/public/locales/en/menu.json b/public/locales/en/menu.json
new file mode 100644
index 000000000..a5f746022
--- /dev/null
+++ b/public/locales/en/menu.json
@@ -0,0 +1,22 @@
+{
+ "title": "Welcome",
+ "subtitle": "Please sign in to see more content",
+ "profile_label": "Profile",
+ "settings_label": "Settings",
+ "my_devices_label": "My Devices",
+ "add_device_label": "Add Device",
+ "tutorials_label": "Tutorials",
+ "api_docs_label": "API Docs",
+ "faq_label": "FAQ",
+ "contact_label": "Contact",
+ "imprint_label": "Imprint",
+ "data_protection_label": "Data Protection",
+ "donate_label": "Donate",
+ "promotion_label": "Promotion",
+ "login_label": "Log in",
+ "logout_label": "Log out",
+
+ "toast_login_success": "Successfully logged in",
+ "toast_logout_success": "Successfully logged out",
+ "toast_user_creation_success": "Successfully created account"
+}
\ No newline at end of file
diff --git a/public/locales/en/navbar.json b/public/locales/en/navbar.json
new file mode 100644
index 000000000..39014d903
--- /dev/null
+++ b/public/locales/en/navbar.json
@@ -0,0 +1,15 @@
+{
+ "date_picker_label": "Select a date",
+ "date_range_picker_label": "Select a time period",
+ "search_label": "Search",
+ "temperature_label": "Temperature",
+ "date_label": "Time period",
+ "ctrl": "Ctrl",
+ "button": "Show",
+ "live_label": "Live",
+ "live_description": "Show live data on the map. This data is updated every 5 minutes. If you click on a station, you can view the last measurements.",
+ "pointintime_label": "Point in time",
+ "pointintime_description": "Show a historical point in time on the map.",
+ "timeperiod_label": "Time period",
+ "timeperiod_description": "Explore the development of the phenomena in a specific time window."
+}
\ No newline at end of file
diff --git a/public/locales/en/register.json b/public/locales/en/register.json
new file mode 100644
index 000000000..3864f0cb0
--- /dev/null
+++ b/public/locales/en/register.json
@@ -0,0 +1,9 @@
+{
+ "register_label": "Sign up",
+ "email_label": "Email",
+ "password_label": "Password",
+ "account_label": "Create account",
+ "transition_label": "Creating account...",
+ "already_account_label": "Already have an account?",
+ "login_label": "Log in"
+}
\ No newline at end of file
diff --git a/styles/app.css b/styles/app.css
index 67393a4e5..6f141d732 100644
--- a/styles/app.css
+++ b/styles/app.css
@@ -11,6 +11,7 @@
--color-gray-400: #767474;
--color-green-100: #69ac54;
--color-green-300: #709f61;
+ --color-red-500: #f10000;
--color-orange-500: #f97316;
--color-headerBorder: #e3e3e3;