diff --git a/app/components/campaigns/area/map.tsx b/app/components/campaigns/area/map.tsx
new file mode 100644
index 00000000..f17debe7
--- /dev/null
+++ b/app/components/campaigns/area/map.tsx
@@ -0,0 +1,215 @@
+import { TrashIcon } from "lucide-react";
+import type { Dispatch, SetStateAction } from "react";
+import { useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
+import type { MapLayerMouseEvent, PopupProps } from "react-map-gl";
+import { MapProvider, Source, Layer, Popup } from "react-map-gl";
+import { Map } from "~/components/map";
+import DrawControl from "~/components/Map/draw-control";
+import GeocoderControl from "~/components/Map/geocoder-control";
+import { Button } from "~/components/ui/button";
+import {
+ Popover,
+ PopoverAnchor,
+ PopoverArrow,
+ PopoverContent,
+} from "~/components/ui/popover";
+import normalize from "@mapbox/geojson-normalize";
+import flatten from "geojson-flatten";
+import type { FeatureCollection, GeoJsonProperties, Geometry } from "geojson";
+
+type MapProps = {
+ mapRef: any;
+ // handleMapClick: (e: MapLayerMouseEvent) => void
+ drawPopoverOpen: boolean;
+ setDrawPopoverOpen: Dispatch
>;
+ // onUpdate: (e: any) => void
+ // onDelete: (e: any) => void
+ geojsonUploadData: FeatureCollection | null;
+ setGeojsonUploadData: Dispatch<
+ SetStateAction | null>
+ >;
+ // popup: PopupProps | false
+ // setPopup: Dispatch>
+ setFeatures: (features: any) => void;
+};
+
+export default function DefineAreaMap({
+ setFeatures,
+ drawPopoverOpen,
+ setDrawPopoverOpen,
+ mapRef,
+ geojsonUploadData,
+ setGeojsonUploadData,
+}: MapProps) {
+ const { t } = useTranslation("campaign-area");
+ const [popup, setPopup] = useState();
+
+ const onUpdate = useCallback(
+ (e: any) => {
+ setGeojsonUploadData(null);
+ // if (e.features[0].properties.radius) {
+ // const coordinates = [
+ // e.features[0].geometry.coordinates[0],
+ // e.features[0].geometry.coordinates[1],
+ // ]; //[lon, lat]
+ // const radius = parseInt(e.features[0].properties.radius); // in meters
+ // const options = { numberOfEdges: 32 }; //optional, defaults to { numberOfEdges: 32 }
+
+ // const polygon = circleToPolygon(coordinates, radius, options);
+ // const updatedFeatures = {
+ // type: "Feature",
+ // geometry: {
+ // type: "Polygon",
+ // coordinates: polygon.coordinates[0].map((c) => {
+ // return [c[0], c[1]];
+ // }),
+ // },
+ // properties: {
+ // radius: radius,
+ // centerpoint: e.features[0].geometry.coordinates,
+ // },
+ // };
+ // console.log(updatedFeatures);
+ // setFeatures(updatedFeatures);
+ // } else {
+ setFeatures((currFeatures: any) => {
+ const updatedFeatures = e.features.map((f: any) => {
+ return { ...f };
+ });
+ const normalizedFeatures = normalize(updatedFeatures[0]);
+ const flattenedFeatures = flatten(normalizedFeatures);
+ return flattenedFeatures;
+ });
+ },
+ // },
+ [setFeatures, setGeojsonUploadData]
+ );
+
+ const onDelete = useCallback(
+ (e: any) => {
+ setFeatures((currFeatures: any) => {
+ const newFeatures = { ...currFeatures };
+
+ for (const feature of e.features) {
+ if (feature.id) {
+ // Filter out the feature with the matching 'id'
+ newFeatures.features = newFeatures.features.filter(
+ (f: any) => f.id !== feature.id
+ );
+ }
+ }
+ return newFeatures;
+ });
+ },
+ [setFeatures]
+ );
+
+ const handleMapClick = useCallback(
+ (e: MapLayerMouseEvent) => {
+ if (geojsonUploadData != null) {
+ const { lngLat } = e;
+ setPopup({
+ latitude: lngLat.lat,
+ longitude: lngLat.lng,
+ className: "p-4",
+ children: (
+
+ {geojsonUploadData.features.map((f: any, index: number) => (
+
+ {Object.entries(f.properties).map(([key, value]) => (
+
+ {key}: {value as string}
+
+ ))}
+
+ ))}
+
+
+ ),
+ });
+ }
+ },
+ [geojsonUploadData, setFeatures, setGeojsonUploadData]
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/comment-tab/comment-cards.tsx b/app/components/campaigns/campaignId/comment-tab/comment-cards.tsx
new file mode 100644
index 00000000..4d0423fa
--- /dev/null
+++ b/app/components/campaigns/campaignId/comment-tab/comment-cards.tsx
@@ -0,0 +1,145 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Form } from "@remix-run/react";
+import { Button } from "~/components/ui/button";
+import { TrashIcon, EditIcon } from "lucide-react";
+import { ClientOnly } from "remix-utils";
+import { MarkdownEditor } from "~/markdown.client";
+import Markdown from "markdown-to-jsx";
+// import type { Comment } from "@prisma/client";
+import type { Comment } from "~/schema";
+
+type CommentCardsProps = {
+ comments: any;
+ userId: string;
+ setCommentEditMode: (e: boolean) => void;
+ setEditCommentId: (e: string | undefined) => void;
+ setEditComment: (e: string | undefined) => void;
+ commentEditMode: boolean;
+ textAreaRef: any;
+ editComment: string;
+};
+
+export default function CommentCards({
+ comments,
+ userId,
+ setCommentEditMode,
+ setEditComment,
+ setEditCommentId,
+ textAreaRef,
+ commentEditMode,
+ editComment,
+}: CommentCardsProps) {
+ return (
+
+ {comments.map((c: Comment, i: number) => {
+ return (
+
+
+
+
+
+
+
+ CN
+
+ {/* @ts-ignore */}
+ {c.owner.name}
+
+ {userId === c.userId && (
+
+
+
+
+ )}
+
+
+
+ {commentEditMode ? (
+
+ {() => (
+
+
+
+
+ Bild hinzufügen
+
+
+ Markdown unterstützt
+
+
+
+
+ )}
+
+ ) : (
+ {c.content}
+ )}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/comment-tab/comment-input.tsx b/app/components/campaigns/campaignId/comment-tab/comment-input.tsx
new file mode 100644
index 00000000..61d11b6b
--- /dev/null
+++ b/app/components/campaigns/campaignId/comment-tab/comment-input.tsx
@@ -0,0 +1,93 @@
+import { Form } from "@remix-run/react";
+import { ClientOnly } from "remix-utils";
+import { Button } from "~/components/ui/button";
+import { MarkdownEditor } from "~/markdown.client";
+import { useNavigate } from "@remix-run/react";
+// import Tribute from "tributejs";
+// import tributeStyles from "tributejs/tribute.css";
+// import type { LinksFunction } from "@remix-run/node";
+// import { useEffect } from "react";
+
+// export const links: LinksFunction = () => {
+// return [{ rel: "stylesheet", href: tributeStyles }];
+// };
+
+type CommentInputProps = {
+ textAreaRef: any;
+ comment: string | undefined;
+ setComment: any;
+ setCommentEditMode: (editMode: boolean) => void;
+ mentions?: string[];
+};
+
+export default function CommentInput({
+ textAreaRef,
+ comment,
+ setComment,
+ setCommentEditMode,
+ mentions,
+}: CommentInputProps) {
+ const navigate = useNavigate();
+
+ // useEffect(() => {
+ // if (textAreaRef.current) {
+ // var tribute = new Tribute({
+ // trigger: "@",
+ // values: [
+ // { key: "Phil Heartman", value: "pheartman" },
+ // { key: "Gordon Ramsey", value: "gramsey" },
+ // ],
+ // itemClass: "bg-blue-700 text-black",
+ // });
+ // tribute.attach(textAreaRef.current.textarea);
+ // }
+ // // eslint-disable-next-line react-hooks/exhaustive-deps
+ // }, [textAreaRef.current]);
+
+ return (
+
+ {() => (
+
+
+
+ Bild hinzufügen
+
+ Markdown unterstützt
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/comment-tab/comment-replies.tsx b/app/components/campaigns/campaignId/comment-tab/comment-replies.tsx
new file mode 100644
index 00000000..622cc88a
--- /dev/null
+++ b/app/components/campaigns/campaignId/comment-tab/comment-replies.tsx
@@ -0,0 +1,87 @@
+import { Form } from "@remix-run/react";
+import { ClientOnly } from "remix-utils";
+import { Button } from "~/components/ui/button";
+import { MarkdownEditor } from "~/markdown.client";
+import { useNavigate } from "@remix-run/react";
+import { useState } from "react";
+
+type CommentInputProps = {
+ textAreaRef: any;
+ comment: string | undefined;
+ setComment: any;
+ setCommentEditMode: (editMode: boolean) => void;
+ mentions?: string[];
+};
+
+export default function Reply({
+ textAreaRef,
+ comment,
+ setComment,
+ setCommentEditMode,
+ mentions,
+}: CommentInputProps) {
+ const navigate = useNavigate();
+ const [reply, setReply] = useState("")
+
+ // useEffect(() => {
+ // if (textAreaRef.current) {
+ // var tribute = new Tribute({
+ // trigger: "@",
+ // values: [
+ // { key: "Phil Heartman", value: "pheartman" },
+ // { key: "Gordon Ramsey", value: "gramsey" },
+ // ],
+ // itemClass: "bg-blue-700 text-black",
+ // });
+ // tribute.attach(textAreaRef.current.textarea);
+ // }
+ // // eslint-disable-next-line react-hooks/exhaustive-deps
+ // }, [textAreaRef.current]);
+
+ return (
+
+ {() => (
+
+
+
+ Bild hinzufügen
+
+ Markdown unterstützt
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/event-tab/create-form.tsx b/app/components/campaigns/campaignId/event-tab/create-form.tsx
new file mode 100644
index 00000000..a239b5aa
--- /dev/null
+++ b/app/components/campaigns/campaignId/event-tab/create-form.tsx
@@ -0,0 +1,138 @@
+import { Form } from "@remix-run/react";
+import { ClientOnly } from "remix-utils";
+import { MarkdownEditor } from "~/markdown.client";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+type EventFormProps = {
+ eventDescription: string;
+ setEventDescription: any;
+ eventTextAreaRef: any;
+};
+
+export default function EventForm({
+ eventDescription,
+ setEventDescription,
+ eventTextAreaRef,
+}: EventFormProps) {
+ return (
+
+
+ Noch keine Events für diese Kampagne.{" "}
+ {" "}
+
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/event-tab/event-cards.tsx b/app/components/campaigns/campaignId/event-tab/event-cards.tsx
new file mode 100644
index 00000000..1e94d672
--- /dev/null
+++ b/app/components/campaigns/campaignId/event-tab/event-cards.tsx
@@ -0,0 +1,205 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Button } from "~/components/ui/button";
+import { EditIcon, TrashIcon } from "lucide-react";
+import { Form } from "@remix-run/react";
+import { ClientOnly } from "remix-utils";
+import { MarkdownEditor } from "~/markdown.client";
+import Markdown from "markdown-to-jsx";
+
+type EventCardsProps = {
+ events: any[];
+ eventEditMode: boolean;
+ setEventEditMode: any;
+ setEditEventTitle: any;
+ userId: string;
+ eventTextAreaRef: any;
+ editEventDescription: string;
+ setEditEventDescription: any;
+ editEventTitle: string;
+ setEditEventStartDate: any;
+};
+
+export default function EventCards({
+ events,
+ eventEditMode,
+ editEventTitle,
+ setEditEventStartDate,
+ editEventDescription,
+ setEditEventDescription,
+ eventTextAreaRef,
+ setEventEditMode,
+ setEditEventTitle,
+ userId,
+}: EventCardsProps) {
+ return (
+
+ {events.map((e: any, i: number) => (
+
+ ))}
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/overview-tab/edit-table.tsx b/app/components/campaigns/campaignId/overview-tab/edit-table.tsx
new file mode 100644
index 00000000..c162f418
--- /dev/null
+++ b/app/components/campaigns/campaignId/overview-tab/edit-table.tsx
@@ -0,0 +1,317 @@
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { Campaign } from "~/schema";
+// import type { Campaign } from "@prisma/client";
+import { Form } from "@remix-run/react";
+import { Switch } from "@/components/ui/switch";
+import {
+ ChevronDown,
+ EditIcon,
+ SaveIcon,
+ TrashIcon,
+ XIcon,
+} from "lucide-react";
+import Markdown from "markdown-to-jsx";
+import { Button } from "~/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { priorityEnum, exposureEnum } from "~/schema";
+// import { Priority, Exposure } from "@prisma/client";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useState, useRef } from "react";
+import { MarkdownEditor } from "~/markdown.client";
+import { ClientOnly } from "remix-utils";
+import { useTranslation } from "react-i18next";
+import { CountryDropdown } from "../../overview/country-dropdown";
+
+type EditTableProps = {
+ setEditMode: any;
+ campaign: any;
+ phenomena: string[];
+};
+
+export default function EditTable({
+ setEditMode,
+ campaign,
+ phenomena,
+}: EditTableProps) {
+ const descriptionRef = useRef();
+ const [title, setTitle] = useState(campaign.title);
+ const [editDescription, setEditDescription] = useState(
+ campaign.description
+ );
+ const [priority, setPriority] = useState(campaign.priority);
+ const [startDate, setStartDate] = useState(campaign.startDate);
+ const [endDate, setEndDate] = useState(campaign.endDate);
+ const [minimumParticipants, setMinimumParticipants] = useState(
+ campaign.minimumParticipants
+ );
+ const [openDropdown, setDropdownOpen] = useState(false);
+ const [phenomenaState, setPhenomenaState] = useState(
+ Object.fromEntries(phenomena.map((p: string) => [p, false]))
+ );
+ const [exposure, setExposure] = useState(campaign.exposure);
+ const [country, setCountry] = useState(campaign.country);
+ const { t } = useTranslation("edit-campaign-table");
+
+ return (
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/overview-tab/overview-table.tsx b/app/components/campaigns/campaignId/overview-tab/overview-table.tsx
new file mode 100644
index 00000000..4b50696b
--- /dev/null
+++ b/app/components/campaigns/campaignId/overview-tab/overview-table.tsx
@@ -0,0 +1,151 @@
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { Campaign } from "~/schema";
+// import type { Campaign } from "@prisma/client";
+import { Form } from "@remix-run/react";
+import { EditIcon, SaveIcon, TrashIcon, XIcon } from "lucide-react";
+import Markdown from "markdown-to-jsx";
+import { Button } from "~/components/ui/button";
+import { useState, useRef } from "react";
+import EditTable from "./edit-table";
+import { CountryFlagIcon } from "~/components/ui/country-flag";
+import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
+import { HoverCard } from "~/components/ui/hover-card";
+import { HoverCardContent, HoverCardTrigger } from "@radix-ui/react-hover-card";
+
+type OverviewTableProps = {
+ campaign: Campaign;
+ userId: string;
+ phenomena: string[];
+};
+
+export default function OverviewTable({
+ campaign,
+ userId,
+ phenomena,
+}: OverviewTableProps) {
+ const [editMode, setEditMode] = useState(false);
+ const [editDescription, setEditDescription] = useState(
+ ""
+ );
+ const descriptionRef = useRef();
+ const instructions = campaign.instructions
+ ? campaign.instructions.toString()
+ : "";
+ return (
+
+ {userId === campaign.ownerId && !editMode && (
+
+ )}
+ {!editMode ? (
+ <>
+
+
Contributors
+
+
+
+
+
+ JR
+
+
+
+
+
+
+
+
+
+
Instructions
+ {instructions}
+
+
+
+
+ Attribut
+ Wert
+
+
+
+
+ Beschreibung
+
+ {campaign.description}
+
+
+
+ Priorität
+ {campaign.priority}
+
+
+ Teilnehmer
+
+ {/* {campaign.participants.length} /{" "} */}
+ {campaign.minimumParticipants}
+
+
+
+ Erstellt am
+ {JSON.stringify(campaign.createdAt)}
+
+
+ Bearbeitet am
+ {JSON.stringify(campaign.updatedAt)}
+
+
+ Location
+
+
+ {campaign.countries && campaign.countries.map((country: string, index: number) => {
+ const flagIcon = CountryFlagIcon({
+ country: String(country).toUpperCase(),
+ });
+ if (!flagIcon) return null;
+
+ return {flagIcon}
;
+ })}
+
+
+
+ Phänomene
+ {campaign.phenomena}
+
+
+ Exposure
+ {campaign.exposure}
+
+
+ Hardware verfügbar
+
+ {campaign.hardwareAvailable ? "Ja" : "Nein"}
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/posts/create.tsx b/app/components/campaigns/campaignId/posts/create.tsx
new file mode 100644
index 00000000..2dd1c3a8
--- /dev/null
+++ b/app/components/campaigns/campaignId/posts/create.tsx
@@ -0,0 +1,31 @@
+import { Form } from "@remix-run/react";
+import { Button } from "~/components/ui/button";
+
+type Props = {
+ loggedIn: boolean;
+};
+
+export default function CreateThread({ loggedIn }: Props) {
+ if (loggedIn) {
+ return (
+
+ );
+ } else {
+ return Login to create a Thread;
+ }
+}
diff --git a/app/components/campaigns/campaignId/posts/index.tsx b/app/components/campaigns/campaignId/posts/index.tsx
new file mode 100644
index 00000000..8ab6dc4f
--- /dev/null
+++ b/app/components/campaigns/campaignId/posts/index.tsx
@@ -0,0 +1,92 @@
+import { Form, useActionData } from "@remix-run/react";
+import { useState } from "react";
+import { Button } from "~/components/ui/button";
+import { action } from "~/routes/campaigns/$slug";
+import { Comment, Post } from "~/schema";
+
+type Props = {
+ posts: Post[];
+};
+
+interface ShowReplyFields {
+ [postId: string]: boolean;
+}
+
+export default function ListPosts({ posts }: Props) {
+ const comments = useActionData();
+ console.log(comments);
+ const initialState: ShowReplyFields = posts.reduce(
+ (acc: ShowReplyFields, post) => {
+ acc[post.id] = false;
+ return acc;
+ },
+ {}
+ );
+
+ const [showReplyFields, setShowReplyFields] =
+ useState(initialState);
+
+ const handleReplyClick = (postId: string) => {
+ setShowReplyFields((prevShowReplyFields) => ({
+ ...prevShowReplyFields,
+ [postId]: !prevShowReplyFields[postId],
+ }));
+ };
+ return (
+
+ )}
+ >
+ );
+ })}
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/table/buttons.tsx b/app/components/campaigns/campaignId/table/buttons.tsx
new file mode 100644
index 00000000..0108cab4
--- /dev/null
+++ b/app/components/campaigns/campaignId/table/buttons.tsx
@@ -0,0 +1,42 @@
+import { EditIcon, SaveIcon, XIcon } from "lucide-react";
+import { Button } from "~/components/ui/button";
+
+type Props = {
+ setEditMode?: any;
+ t: any;
+};
+
+export function EditButton({ setEditMode, t }: Props) {
+ return (
+
+ );
+}
+
+export function CancelButton({ setEditMode, t }: Props) {
+ return (
+
+ );
+}
+
+export function SaveButton({ t }: Props) {
+ return (
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/table/edit-components/description.tsx b/app/components/campaigns/campaignId/table/edit-components/description.tsx
new file mode 100644
index 00000000..2a901b3b
--- /dev/null
+++ b/app/components/campaigns/campaignId/table/edit-components/description.tsx
@@ -0,0 +1,45 @@
+import { ClientOnly } from "remix-utils";
+import { MarkdownEditor } from "~/markdown.client";
+
+type Props = {
+ editDescription: any;
+ descriptionRef: any;
+ setEditDescription: any;
+ t: any;
+};
+export function EditDescription({
+ editDescription,
+ setEditDescription,
+ descriptionRef,
+ t,
+}: Props) {
+ return (
+ <>
+
+
+ {() => (
+ <>
+
+
+
+ {t("add image")}
+
+
+ {t("markdown supported")}
+
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/app/components/campaigns/campaignId/table/edit-components/phenomena.tsx b/app/components/campaigns/campaignId/table/edit-components/phenomena.tsx
new file mode 100644
index 00000000..333d445a
--- /dev/null
+++ b/app/components/campaigns/campaignId/table/edit-components/phenomena.tsx
@@ -0,0 +1,68 @@
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { t } from "i18next";
+import { ChevronDown } from "lucide-react";
+import { Button } from "~/components/ui/button";
+
+type Props = {
+ phenomenaState: any;
+ setPhenomenaState: any;
+ openDropdown: any;
+ setDropdownOpen: any;
+ phenomena: any;
+};
+
+export default function PhenomenaDropdown({
+ phenomenaState,
+ setPhenomenaState,
+ openDropdown,
+ setDropdownOpen,
+ phenomena,
+}: Props) {
+ return (
+
+
+
+
+
+ {phenomena.map((p: any) => {
+ return (
+ {
+ setPhenomenaState({
+ ...phenomenaState,
+ [p]: !phenomenaState[p],
+ });
+ }}
+ onSelect={(event) => event.preventDefault()}
+ >
+ {p}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/app/components/campaigns/campaignId/table/index.tsx b/app/components/campaigns/campaignId/table/index.tsx
new file mode 100644
index 00000000..4ed46195
--- /dev/null
+++ b/app/components/campaigns/campaignId/table/index.tsx
@@ -0,0 +1,280 @@
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { Campaign } from "~/schema";
+// import type { Campaign } from "@prisma/client";
+import { Form } from "@remix-run/react";
+import { Switch } from "@/components/ui/switch";
+import Markdown from "markdown-to-jsx";
+import { priorityEnum, exposureEnum } from "~/schema";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useState, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { CountryDropdown } from "../../overview/country-dropdown";
+import PhenomenaDropdown from "./edit-components/phenomena";
+import { EditButton, CancelButton, SaveButton } from "./buttons";
+import { EditDescription } from "./edit-components/description";
+
+type EditTableProps = {
+ owner: boolean;
+ campaign: any;
+ phenomena: string[];
+};
+
+export default function CampaignTable({
+ owner,
+ campaign,
+ phenomena,
+}: EditTableProps) {
+ const descriptionRef = useRef();
+ const [editMode, setEditMode] = useState(false);
+ const [title, setTitle] = useState(campaign.title);
+ const [editDescription, setEditDescription] = useState(
+ campaign.description
+ );
+ const [priority, setPriority] = useState(campaign.priority);
+ const [startDate, setStartDate] = useState(campaign.startDate);
+ const [endDate, setEndDate] = useState(campaign.endDate);
+ const [minimumParticipants, setMinimumParticipants] = useState(
+ campaign.minimumParticipants
+ );
+ const [openDropdown, setDropdownOpen] = useState(false);
+ const [phenomenaState, setPhenomenaState] = useState(
+ Object.fromEntries(phenomena.map((p: string) => [p, false]))
+ );
+ const [exposure, setExposure] = useState(campaign.exposure);
+ const [country, setCountry] = useState(campaign.country);
+ const { t } = useTranslation("edit-campaign-table");
+
+ return (
+
+ );
+}
diff --git a/app/components/campaigns/overview/all-countries-object.ts b/app/components/campaigns/overview/all-countries-object.ts
new file mode 100644
index 00000000..6aa6c496
--- /dev/null
+++ b/app/components/campaigns/overview/all-countries-object.ts
@@ -0,0 +1,251 @@
+export const countryListAlpha2 = {
+ AF: "Afghanistan",
+ AL: "Albania",
+ DZ: "Algeria",
+ AS: "American Samoa",
+ AD: "Andorra",
+ AO: "Angola",
+ AI: "Anguilla",
+ AQ: "Antarctica",
+ AG: "Antigua and Barbuda",
+ AR: "Argentina",
+ AM: "Armenia",
+ AW: "Aruba",
+ AU: "Australia",
+ AT: "Austria",
+ AZ: "Azerbaijan",
+ BS: "Bahamas (the)",
+ BH: "Bahrain",
+ BD: "Bangladesh",
+ BB: "Barbados",
+ BY: "Belarus",
+ BE: "Belgium",
+ BZ: "Belize",
+ BJ: "Benin",
+ BM: "Bermuda",
+ BT: "Bhutan",
+ BO: "Bolivia (Plurinational State of)",
+ BQ: "Bonaire, Sint Eustatius and Saba",
+ BA: "Bosnia and Herzegovina",
+ BW: "Botswana",
+ BV: "Bouvet Island",
+ BR: "Brazil",
+ IO: "British Indian Ocean Territory (the)",
+ BN: "Brunei Darussalam",
+ BG: "Bulgaria",
+ BF: "Burkina Faso",
+ BI: "Burundi",
+ CV: "Cabo Verde",
+ KH: "Cambodia",
+ CM: "Cameroon",
+ CA: "Canada",
+ KY: "Cayman Islands (the)",
+ CF: "Central African Republic (the)",
+ TD: "Chad",
+ CL: "Chile",
+ CN: "China",
+ CX: "Christmas Island",
+ CC: "Cocos (Keeling) Islands (the)",
+ CO: "Colombia",
+ KM: "Comoros (the)",
+ CD: "Congo (the Democratic Republic of the)",
+ CG: "Congo (the)",
+ CK: "Cook Islands (the)",
+ CR: "Costa Rica",
+ HR: "Croatia",
+ CU: "Cuba",
+ CW: "Curaçao",
+ CY: "Cyprus",
+ CZ: "Czechia",
+ CI: "Côte d'Ivoire",
+ DK: "Denmark",
+ DJ: "Djibouti",
+ DM: "Dominica",
+ DO: "Dominican Republic (the)",
+ EC: "Ecuador",
+ EG: "Egypt",
+ SV: "El Salvador",
+ GQ: "Equatorial Guinea",
+ ER: "Eritrea",
+ EE: "Estonia",
+ SZ: "Eswatini",
+ ET: "Ethiopia",
+ FK: "Falkland Islands (the) [Malvinas]",
+ FO: "Faroe Islands (the)",
+ FJ: "Fiji",
+ FI: "Finland",
+ FR: "France",
+ GF: "French Guiana",
+ PF: "French Polynesia",
+ TF: "French Southern Territories (the)",
+ GA: "Gabon",
+ GM: "Gambia (the)",
+ GE: "Georgia",
+ DE: "Germany",
+ GH: "Ghana",
+ GI: "Gibraltar",
+ GR: "Greece",
+ GL: "Greenland",
+ GD: "Grenada",
+ GP: "Guadeloupe",
+ GU: "Guam",
+ GT: "Guatemala",
+ GG: "Guernsey",
+ GN: "Guinea",
+ GW: "Guinea-Bissau",
+ GY: "Guyana",
+ HT: "Haiti",
+ HM: "Heard Island and McDonald Islands",
+ VA: "Holy See (the)",
+ HN: "Honduras",
+ HK: "Hong Kong",
+ HU: "Hungary",
+ IS: "Iceland",
+ IN: "India",
+ ID: "Indonesia",
+ IR: "Iran (Islamic Republic of)",
+ IQ: "Iraq",
+ IE: "Ireland",
+ IM: "Isle of Man",
+ IL: "Israel",
+ IT: "Italy",
+ JM: "Jamaica",
+ JP: "Japan",
+ JE: "Jersey",
+ JO: "Jordan",
+ KZ: "Kazakhstan",
+ KE: "Kenya",
+ KI: "Kiribati",
+ KP: "Korea (the Democratic People's Republic of)",
+ KR: "Korea (the Republic of)",
+ KW: "Kuwait",
+ KG: "Kyrgyzstan",
+ LA: "Lao People's Democratic Republic (the)",
+ LV: "Latvia",
+ LB: "Lebanon",
+ LS: "Lesotho",
+ LR: "Liberia",
+ LY: "Libya",
+ LI: "Liechtenstein",
+ LT: "Lithuania",
+ LU: "Luxembourg",
+ MO: "Macao",
+ MG: "Madagascar",
+ MW: "Malawi",
+ MY: "Malaysia",
+ MV: "Maldives",
+ ML: "Mali",
+ MT: "Malta",
+ MH: "Marshall Islands (the)",
+ MQ: "Martinique",
+ MR: "Mauritania",
+ MU: "Mauritius",
+ YT: "Mayotte",
+ MX: "Mexico",
+ FM: "Micronesia (Federated States of)",
+ MD: "Moldova (the Republic of)",
+ MC: "Monaco",
+ MN: "Mongolia",
+ ME: "Montenegro",
+ MS: "Montserrat",
+ MA: "Morocco",
+ MZ: "Mozambique",
+ MM: "Myanmar",
+ NA: "Namibia",
+ NR: "Nauru",
+ NP: "Nepal",
+ NL: "Netherlands (the)",
+ NC: "New Caledonia",
+ NZ: "New Zealand",
+ NI: "Nicaragua",
+ NE: "Niger (the)",
+ NG: "Nigeria",
+ NU: "Niue",
+ NF: "Norfolk Island",
+ MP: "Northern Mariana Islands (the)",
+ NO: "Norway",
+ OM: "Oman",
+ PK: "Pakistan",
+ PW: "Palau",
+ PS: "Palestine, State of",
+ PA: "Panama",
+ PG: "Papua New Guinea",
+ PY: "Paraguay",
+ PE: "Peru",
+ PH: "Philippines (the)",
+ PN: "Pitcairn",
+ PL: "Poland",
+ PT: "Portugal",
+ PR: "Puerto Rico",
+ QA: "Qatar",
+ MK: "Republic of North Macedonia",
+ RO: "Romania",
+ RU: "Russian Federation (the)",
+ RW: "Rwanda",
+ RE: "Réunion",
+ BL: "Saint Barthélemy",
+ SH: "Saint Helena, Ascension and Tristan da Cunha",
+ KN: "Saint Kitts and Nevis",
+ LC: "Saint Lucia",
+ MF: "Saint Martin (French part)",
+ PM: "Saint Pierre and Miquelon",
+ VC: "Saint Vincent and the Grenadines",
+ WS: "Samoa",
+ SM: "San Marino",
+ ST: "Sao Tome and Principe",
+ SA: "Saudi Arabia",
+ SN: "Senegal",
+ RS: "Serbia",
+ SC: "Seychelles",
+ SL: "Sierra Leone",
+ SG: "Singapore",
+ SX: "Sint Maarten (Dutch part)",
+ SK: "Slovakia",
+ SI: "Slovenia",
+ SB: "Solomon Islands",
+ SO: "Somalia",
+ ZA: "South Africa",
+ GS: "South Georgia and the South Sandwich Islands",
+ SS: "South Sudan",
+ ES: "Spain",
+ LK: "Sri Lanka",
+ SD: "Sudan (the)",
+ SR: "Suriname",
+ SJ: "Svalbard and Jan Mayen",
+ SE: "Sweden",
+ CH: "Switzerland",
+ SY: "Syrian Arab Republic",
+ TW: "Taiwan",
+ TJ: "Tajikistan",
+ TZ: "Tanzania, United Republic of",
+ TH: "Thailand",
+ TL: "Timor-Leste",
+ TG: "Togo",
+ TK: "Tokelau",
+ TO: "Tonga",
+ TT: "Trinidad and Tobago",
+ TN: "Tunisia",
+ TR: "Turkey",
+ TM: "Turkmenistan",
+ TC: "Turks and Caicos Islands (the)",
+ TV: "Tuvalu",
+ UG: "Uganda",
+ UA: "Ukraine",
+ AE: "United Arab Emirates (the)",
+ GB: "United Kingdom of Great Britain and Northern Ireland (the)",
+ UM: "United States Minor Outlying Islands (the)",
+ US: "United States of America (the)",
+ UY: "Uruguay",
+ UZ: "Uzbekistan",
+ VU: "Vanuatu",
+ VE: "Venezuela (Bolivarian Republic of)",
+ VN: "Viet Nam",
+ VG: "Virgin Islands (British)",
+ VI: "Virgin Islands (U.S.)",
+ WF: "Wallis and Futuna",
+ EH: "Western Sahara",
+ YE: "Yemen",
+ ZM: "Zambia",
+ ZW: "Zimbabwe",
+ AX: "Åland Islands",
+};
diff --git a/app/components/campaigns/overview/campaign-badges.tsx b/app/components/campaigns/overview/campaign-badges.tsx
new file mode 100644
index 00000000..ec81813d
--- /dev/null
+++ b/app/components/campaigns/overview/campaign-badges.tsx
@@ -0,0 +1,51 @@
+// import type { Exposure, Priority } from "@prisma/client";
+import { priorityEnum, exposureEnum } from "~/schema";
+import clsx from "clsx";
+import { ClockIcon } from "lucide-react";
+import { Badge } from "~/components/ui/badge";
+
+type PriorityBadgeProps = {
+ priority: keyof typeof priorityEnum;
+};
+
+type ExposureBadgeProps = {
+ exposure: keyof typeof exposureEnum;
+};
+
+export function PriorityBadge({ priority }: PriorityBadgeProps) {
+ const prio = priority.toString().toLowerCase();
+ return (
+
+ {" "}
+ {prio}
+
+ );
+}
+
+export function ExposureBadge({ exposure }: ExposureBadgeProps) {
+ const exposed = exposure.toString().toLowerCase();
+ if (exposed === "unknown") {
+ return null;
+ }
+ return (
+
+ {exposed}
+
+ );
+}
diff --git a/app/components/campaigns/overview/campaign-filter.tsx b/app/components/campaigns/overview/campaign-filter.tsx
new file mode 100644
index 00000000..9ee22469
--- /dev/null
+++ b/app/components/campaigns/overview/campaign-filter.tsx
@@ -0,0 +1,288 @@
+import { Form, useSearchParams } from "@remix-run/react";
+import { Button } from "~/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Switch } from "~/components/ui/switch";
+import {
+ AlertCircleIcon,
+ ArrowDownAZIcon,
+ ChevronDown,
+ ChevronUp,
+ FilterXIcon,
+} from "lucide-react";
+// import { Priority } from "@prisma/client";
+import { priorityEnum } from "~/schema";
+import { useTranslation } from "react-i18next";
+import { useState } from "react";
+import clsx from "clsx";
+import FiltersModal from "./filters-modal";
+import { Label } from "~/components/ui/label";
+
+type FilterProps = {
+ switchDisabled: boolean;
+ showMap: boolean;
+ setShowMap: (e: boolean) => void;
+ phenomena: string[];
+};
+
+export default function Filter({
+ switchDisabled,
+ showMap,
+ setShowMap,
+ phenomena,
+}: FilterProps) {
+ const { t } = useTranslation("explore-campaigns");
+ const [searchParams] = useSearchParams();
+ const [phenomenaState, setPhenomenaState] = useState(
+ Object.fromEntries(phenomena.map((p: string) => [p, false]))
+ );
+ const [filterObject, setFilterObject] = useState({
+ searchTerm: "",
+ priority: "",
+ country: "",
+ exposure: "",
+ phenomena: [] as string[],
+ time_range: {
+ startDate: "",
+ endDate: "",
+ },
+ });
+
+ const [sortBy, setSortBy] = useState("updatedAt");
+ const [showMobileFilters, setShowMobileFilters] = useState(false);
+ return (
+ <>
+
+
+ setShowMap(!showMap)}
+ />
+
+
+
+ {!showMap && (
+
+ )}
+ >
+ );
+}
diff --git a/app/components/campaigns/overview/country-dropdown.tsx b/app/components/campaigns/overview/country-dropdown.tsx
new file mode 100644
index 00000000..9854676e
--- /dev/null
+++ b/app/components/campaigns/overview/country-dropdown.tsx
@@ -0,0 +1,88 @@
+import { useState } from "react";
+import { Check, ChevronsUpDown } from "lucide-react";
+import { countryListAlpha2 } from "./all-countries-object";
+import { CountryFlagIcon } from "~/components/ui/country-flag";
+
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { ScrollArea } from "~/components/ui/scroll-area";
+
+type CountryDropdownProps = {
+ setCountry?: (country: string) => void;
+};
+
+export function CountryDropdown({ setCountry }: CountryDropdownProps) {
+ const [open, setOpen] = useState(false);
+ const [value, setValue] = useState("");
+
+ const countries = Object.values(countryListAlpha2);
+
+ return (
+
+
+
+
+
+
+
+ No country found.
+
+
+ {Object.entries(countryListAlpha2).map(
+ ([countryCode, countryName], index: number) => {
+ const flagIcon = CountryFlagIcon({
+ country: String(countryCode).toUpperCase(),
+ });
+
+ return (
+ {
+ setValue(countryName);
+ if (setCountry) {
+ setCountry(countryCode);
+ }
+ setOpen(false);
+ }}
+ >
+ {flagIcon !== undefined ? (
+ <>
+ {flagIcon}
+ {countryName}
+ >
+ ) : (
+ <>Flag not available for {countryName}>
+ )}
+
+ );
+ }
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/components/campaigns/overview/filters-bar.tsx b/app/components/campaigns/overview/filters-bar.tsx
new file mode 100644
index 00000000..df91f7b9
--- /dev/null
+++ b/app/components/campaigns/overview/filters-bar.tsx
@@ -0,0 +1,169 @@
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Button } from "~/components/ui/button";
+import FiltersModal from "./filters-modal";
+import { Switch } from "~/components/ui/switch";
+import {
+ AlertCircleIcon,
+ ArrowDownAZIcon,
+ ChevronDown,
+ FilterXIcon,
+} from "lucide-react";
+import type { Dispatch, SetStateAction } from "react";
+import { useTranslation } from "react-i18next";
+// import { Priority } from "@prisma/client";
+import { priorityEnum, zodPriorityEnum } from "~/schema";
+import clsx from "clsx";
+
+type FiltersBarProps = {
+ phenomena: string[];
+ phenomenaState: {
+ [k: string]: any;
+ };
+ setPhenomenaState: Dispatch<
+ SetStateAction<{
+ [k: string]: any;
+ }>
+ >;
+ filterObject: {
+ searchTerm: string;
+ priority: string;
+ country: string;
+ exposure: string;
+ phenomena: string[];
+ time_range: {
+ startDate: string;
+ endDate: string;
+ };
+ };
+ setFilterObject: Dispatch<
+ SetStateAction<{
+ searchTerm: string;
+ priority: string;
+ country: string;
+ exposure: string;
+ phenomena: string[];
+ time_range: {
+ startDate: string;
+ endDate: string;
+ };
+ }>
+ >;
+ sortBy: string;
+ setSortBy: Dispatch>;
+ switchDisabled: boolean;
+ showMap: boolean;
+ setShowMap: Dispatch>;
+ resetFilters: () => void;
+};
+
+export default function FiltersBar({
+ phenomena,
+ phenomenaState,
+ setPhenomenaState,
+ filterObject,
+ setFilterObject,
+ sortBy,
+ setSortBy,
+ switchDisabled,
+ showMap,
+ setShowMap,
+ resetFilters,
+}: FiltersBarProps) {
+ const { t } = useTranslation("explore-campaigns");
+ return (
+
+
+
+
+
+
+
+ setFilterObject({ ...filterObject, priority: e })
+ }
+ >
+ {Object.values(priorityEnum.enumValues).map((priority: zodPriorityEnum, index: number) => {
+ return (
+
+ {priority}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ {t("priority")}
+
+
+ {t("creation date")}
+
+
+
+
+
+
+
+ {t("show map")}
+ setShowMap(!showMap)}
+ />
+
+
+ );
+}
diff --git a/app/components/campaigns/overview/filters-modal.tsx b/app/components/campaigns/overview/filters-modal.tsx
new file mode 100644
index 00000000..7f1b20b3
--- /dev/null
+++ b/app/components/campaigns/overview/filters-modal.tsx
@@ -0,0 +1,292 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Button } from "~/components/ui/button";
+import { CountryDropdown } from "./country-dropdown";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Popover,
+ PopoverAnchor,
+ PopoverArrow,
+ PopoverContent,
+ PopoverTrigger,
+} from "~/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { ChevronDown, FilterIcon } from "lucide-react";
+import { ScrollArea } from "~/components/ui/scroll-area";
+import type { Dispatch, SetStateAction } from "react";
+import { useState } from "react";
+import { exposureEnum } from "~/schema";
+// import { Exposure } from "@prisma/client";
+import { useTranslation } from "react-i18next";
+import PhenomenaSelect from "../phenomena-select";
+
+type FiltersModalProps = {
+ phenomena: string[];
+ phenomenaState: {
+ [k: string]: any;
+ };
+ setPhenomenaState: Dispatch<
+ SetStateAction<{
+ [k: string]: any;
+ }>
+ >;
+ filterObject: {
+ searchTerm: string;
+ priority: string;
+ country: string;
+ exposure: string;
+ phenomena: string[];
+ time_range: {
+ startDate: string;
+ endDate: string;
+ };
+ };
+ setFilterObject: Dispatch<
+ SetStateAction<{
+ searchTerm: string;
+ priority: string;
+ country: string;
+ exposure: string;
+ phenomena: string[];
+ time_range: {
+ startDate: string;
+ endDate: string;
+ };
+ }>
+ >;
+};
+
+export default function FiltersModal({
+ phenomena,
+ phenomenaState,
+ setPhenomenaState,
+ filterObject,
+ setFilterObject,
+}: FiltersModalProps) {
+ const [moreFiltersOpen, setMoreFiltersOpen] = useState(false);
+ const [popoverOpen, setPopoverOpen] = useState(false);
+ const [phenomenaDropdown, setPhenomenaDropdownOpen] = useState(false);
+ const [localFilterObject, setLocalFilterObject] = useState({
+ country: "",
+ exposure: "",
+ phenomena: [] as string[],
+ time_range: {
+ startDate: "",
+ endDate: "",
+ },
+ });
+ const { t } = useTranslation("campaign-filters-modal");
+
+ return (
+
+ );
+}
diff --git a/app/components/campaigns/overview/grid.tsx b/app/components/campaigns/overview/grid.tsx
new file mode 100644
index 00000000..1e5f1a72
--- /dev/null
+++ b/app/components/campaigns/overview/grid.tsx
@@ -0,0 +1,186 @@
+// import type { Campaign, CampaignBookmark, User } from "@prisma/client";
+import type { Campaign } from "~/schema";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Link, Form } from "@remix-run/react";
+import { ExposureBadge, PriorityBadge } from "./campaign-badges";
+import { PlusIcon, StarIcon } from "lucide-react";
+import { Progress } from "~/components/ui/progress";
+import Markdown from "markdown-to-jsx";
+import { CountryFlagIcon } from "~/components/ui/country-flag";
+import { useTranslation } from "react-i18next";
+import Pagination from "./pagination";
+
+type CampaignGridProps = {
+ campaigns: any[];
+ showMap: boolean;
+ userId: string;
+ campaignCount: number;
+ totalPages: number;
+ // bookmarks: CampaignBookmark[];
+};
+
+export default function CampaignGrid({
+ campaigns,
+ showMap,
+ userId,
+ campaignCount,
+ totalPages,
+ // bookmarks,
+}: CampaignGridProps) {
+ const { t } = useTranslation("explore-campaigns");
+
+ const CampaignInfo = () => (
+
+ {campaigns.length} {t("of")} {campaignCount} {t("campaigns are shown")}
+
+ );
+
+ if (campaigns.length === 0) {
+ return (
+
+
{t("no campaigns yet")}. {" "}
+
+ {t("click")}{" "}
+
+ {t("here")}
+ {" "}
+ {t("to create a campaign")}
+
+
+ );
+ }
+ return (
+
+
+ {campaigns.map((item: Campaign, index: number) => {
+ // const isBookmarked =
+ // userId &&
+ // bookmarks.find(
+ // (bookmark: CampaignBookmark) =>
+ // bookmark.userId === userId && bookmark.campaignId === item.id
+ // );
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
{item.title}
+
+ {item.countries && item.countries.map(
+ (country: string, index: number) => {
+ if (index === 2) {
+ return (
+
+ );
+ }
+ const flagIcon = CountryFlagIcon({
+ country: String(country).toUpperCase(),
+ });
+ if (!flagIcon) return null;
+ return (
+
+ );
+ }
+ )}
+
+
+
+
+
+
+
+
+ {item.minimumParticipants} {t("total participants")}
+
+
+
+
+
+
+ {t("learn more")}
+
+
+ {item.description}
+
+
+
+
+
+
+ );
+ })}
+ {totalPages > 1 && (
+ <>
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/app/components/campaigns/overview/list-page-options.ts b/app/components/campaigns/overview/list-page-options.ts
new file mode 100644
index 00000000..76438f6e
--- /dev/null
+++ b/app/components/campaigns/overview/list-page-options.ts
@@ -0,0 +1,41 @@
+//original function here: https://github.com/hotosm/tasking-manager/blob/5136d12ede6c06d87d764085353efbcbd2fe5d2f/frontend/src/components/paginator/index.js#L70
+export function listPageOptions(page: number, lastPage: number) {
+ let pageOptions: (string | number)[] = [1];
+ if (lastPage === 0) {
+ return pageOptions;
+ }
+ if (page === 0 || page > lastPage) {
+ return pageOptions.concat([2, "...", lastPage]);
+ }
+ if (lastPage > 5) {
+ if (page < 3) {
+ return pageOptions.concat([2, 3, "...", lastPage]);
+ }
+ if (page === 3) {
+ return pageOptions.concat([2, 3, 4, "...", lastPage]);
+ }
+ if (page === lastPage) {
+ return pageOptions.concat(["...", page - 2, page - 1, lastPage]);
+ }
+ if (page === lastPage - 1) {
+ return pageOptions.concat(["...", page - 1, page, lastPage]);
+ }
+ if (page === lastPage - 2) {
+ return pageOptions.concat(["...", page - 1, page, page + 1, lastPage]);
+ }
+ return pageOptions.concat([
+ "...",
+ page - 1,
+ page,
+ page + 1,
+ "...",
+ lastPage,
+ ]);
+ } else {
+ let range = [];
+ for (let i = 1; i <= lastPage; i++) {
+ range.push(i);
+ }
+ return range;
+ }
+}
diff --git a/app/components/campaigns/overview/map/index.tsx b/app/components/campaigns/overview/map/index.tsx
new file mode 100644
index 00000000..52e73ea6
--- /dev/null
+++ b/app/components/campaigns/overview/map/index.tsx
@@ -0,0 +1,489 @@
+import { Input } from "~/components/ui/input";
+import {
+ Layer,
+ LngLatBounds,
+ LngLatLike,
+ MapLayerMouseEvent,
+ MapProvider,
+ MapRef,
+ MapboxEvent,
+ Marker,
+ Source,
+} from "react-map-gl";
+import { Map } from "~/components/map";
+import {
+ ChangeEvent,
+ Dispatch,
+ SetStateAction,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import type { BBox } from "geojson";
+import PointLayer from "~/components/campaigns/overview/map/point-layer";
+// import { Campaign, Exposure, Priority, Prisma } from "@prisma/client";
+import { Campaign, exposureEnum, priorityEnum } from "~/schema";
+import { Link } from "@remix-run/react";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Label } from "~/components/ui/label";
+import { CountryDropdown } from "../country-dropdown";
+import PhenomenaSelect from "../../phenomena-select";
+import { Calendar } from "@/components/ui/calendar";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "~/components/ui/button";
+import { cn } from "~/lib/utils";
+import { CalendarIcon } from "lucide-react";
+import { addDays, format } from "date-fns";
+import { DateRange } from "react-day-picker";
+import { DataItem } from "~/components/ui/multi-select";
+
+export default function CampaignMap({
+ campaigns,
+ phenomena,
+}: {
+ campaigns: Campaign[];
+ // setDisplayedCampaigns: Dispatch>;
+ phenomena: string[];
+}) {
+ type PriorityType = keyof typeof priorityEnum;
+ type ExposureType = keyof typeof exposureEnum;
+
+ const mapRef = useRef(null);
+ const [mapBounds, setMapBounds] = useState();
+ const [zoom, setZoom] = useState(1);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [filterObject, setFilterObject] = useState<{
+ priority: PriorityType | "";
+ country: string;
+ exposure: ExposureType | "";
+ phenomena: string[];
+ time_range: DateRange | undefined;
+ }>({
+ priority: "",
+ country: "",
+ exposure: "",
+ phenomena: [],
+ time_range: {
+ from: undefined,
+ to: undefined,
+ },
+ });
+ const [filteredCampaigns, setFilteredCampaigns] =
+ useState(campaigns);
+
+ const [selectedPhenomena, setSelectedPhenomena] = useState([]);
+
+ const [visibleCampaigns, setVisibleCampaigns] = useState([]);
+
+ const handleMapLoad = useCallback(() => {
+ const map = mapRef?.current?.getMap();
+ if (map) {
+ setMapBounds(map.getBounds().toArray().flat() as BBox);
+ }
+ }, []);
+
+ //show only campaigns in sidebar that are within map view
+ const handleMapMouseMove = useCallback(
+ (event: MapLayerMouseEvent) => {
+ const map = mapRef?.current?.getMap();
+ if (map) {
+ const bounds = map.getBounds();
+ const visibleCampaigns: Campaign[] = campaigns.filter(
+ (campaign: Campaign) => {
+ const centerObject = campaign.centerpoint as any;
+ const geometryObject = centerObject.geometry as any;
+ const coordinates = geometryObject.coordinates;
+ if (coordinates && Array.isArray(coordinates))
+ return bounds.contains([
+ coordinates[0] as number,
+ coordinates[1] as number,
+ ]);
+ }
+ );
+ console.log(filteredCampaigns);
+ const visibleAndFiltered = filteredCampaigns.filter(
+ (filtered_campaign) =>
+ visibleCampaigns.some(
+ (visible_campaign) => visible_campaign.id === filtered_campaign.id
+ )
+ );
+ setVisibleCampaigns(visibleAndFiltered);
+ // setFilteredCampaigns(visibleAndFiltered);
+ }
+ },
+ [campaigns, filteredCampaigns]
+ );
+
+ const handleInputChange = (event: ChangeEvent) => {
+ setSearchTerm(event.target.value);
+ };
+
+ useEffect(() => {
+ setFilteredCampaigns(
+ campaigns.filter((campaign: Campaign) =>
+ campaign.title.includes(searchTerm.toLocaleLowerCase())
+ )
+ );
+ }, [campaigns, searchTerm]);
+
+ useEffect(() => {
+ setFilterObject({
+ ...filterObject,
+ phenomena: selectedPhenomena.map((p) => p.label),
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedPhenomena]);
+
+ const checkPriorityMatch = useCallback(
+ (priority: string) => {
+ return (
+ !filterObject.priority ||
+ priority.toLowerCase() === filterObject.priority.toLowerCase()
+ );
+ },
+ [filterObject.priority]
+ );
+
+ const checkCountryMatch = useCallback(
+ (countries: string[] | null) => {
+ if (!countries || countries.length === 0) {
+ return true;
+ }
+ return (
+ !filterObject.country ||
+ countries.some(
+ (country) =>
+ country.toLowerCase() === filterObject.country.toLowerCase()
+ )
+ );
+ },
+ [filterObject.country]
+ );
+
+ const checkExposureMatch = useCallback(
+ (exposure: string) => {
+ return (
+ !filterObject.exposure ||
+ exposure.toLowerCase() === filterObject.exposure.toLowerCase()
+ );
+ },
+ [filterObject.exposure]
+ );
+
+ const checkTimeRangeMatches = useCallback(
+ (startDate: Date | null, endDate: Date | null) => {
+ if (
+ !filterObject.time_range ||
+ !filterObject.time_range.from ||
+ !filterObject.time_range.to
+ )
+ return true;
+
+ const dateRange = [
+ filterObject.time_range.from,
+ filterObject.time_range.to,
+ ];
+
+ function inRange(element: Date, index: number, array: any) {
+ if (!startDate || !endDate) {
+ return false;
+ }
+ const campaignStartTimestamp = new Date(startDate).getTime();
+ const campaignEndTimestamp = new Date(endDate).getTime();
+ const filterTimeStamp = new Date(element).getTime();
+
+ return (
+ filterTimeStamp >= campaignStartTimestamp &&
+ filterTimeStamp <= campaignEndTimestamp
+ );
+ }
+
+ return dateRange.some(inRange);
+ },
+ [filterObject.time_range]
+ );
+
+ const checkPhenomenaMatch = useCallback(
+ (phenomena: string[]) => {
+ const filterPhenomena: string[] = filterObject.phenomena;
+
+ if (filterPhenomena.length === 0) {
+ return true;
+ }
+
+ const hasMatchingPhenomena = phenomena.some((phenomenon) =>
+ filterPhenomena.includes(phenomenon)
+ );
+
+ return hasMatchingPhenomena;
+ },
+ [filterObject.phenomena]
+ );
+
+ useEffect(() => {
+ console.log(filterObject);
+ const filteredCampaigns = campaigns.slice().filter((campaign: Campaign) => {
+ const priorityMatches = checkPriorityMatch(campaign.priority ?? '');
+ const countryMatches = checkCountryMatch(campaign.countries);
+ const exposureMatches = checkExposureMatch(campaign.exposure ?? '');
+ const timeRangeMatches = checkTimeRangeMatches(
+ campaign.startDate,
+ campaign.endDate
+ );
+ const phenomenaMatches = checkPhenomenaMatch(campaign.phenomena ?? []);
+ return (
+ priorityMatches &&
+ countryMatches &&
+ exposureMatches &&
+ timeRangeMatches &&
+ phenomenaMatches
+ );
+ });
+ setFilteredCampaigns(filteredCampaigns);
+ }, [
+ campaigns,
+ checkCountryMatch,
+ checkExposureMatch,
+ checkPriorityMatch,
+ checkTimeRangeMatches,
+ checkPhenomenaMatch,
+ filterObject,
+ ]);
+
+ return (
+ <>
+
+
+
+
+
+ More Filters
+
+
+
+ Filter by Priority
+
+ {
+ setFilterObject({ ...filterObject, priority: e });
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setFilterObject({ ...filterObject, country: e })
+ }
+ />
+
+
+ Filter by Exposure
+
+
+ setFilterObject({ ...filterObject, exposure: e })
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ setFilterObject({
+ ...filterObject,
+ time_range: {
+ from: e?.from,
+ to: e?.to,
+ },
+ });
+ }}
+ numberOfMonths={2}
+ />
+
+
+
+
+
+
+
+
+ {visibleCampaigns.map((campaign: Campaign) => {
+ return (
+
+ {campaign.title}
+
+ );
+ })}
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/components/campaigns/overview/map/point-layer.tsx b/app/components/campaigns/overview/map/point-layer.tsx
new file mode 100644
index 00000000..3037d3ca
--- /dev/null
+++ b/app/components/campaigns/overview/map/point-layer.tsx
@@ -0,0 +1,321 @@
+import type {
+ BBox,
+ Feature,
+ GeoJsonProperties,
+ Geometry,
+ GeometryCollection,
+} from "geojson";
+import type { Dispatch, SetStateAction } from "react";
+import { useMemo, useCallback, useState, useEffect } from "react";
+import { Layer, Marker, Popup, Source, useMap } from "react-map-gl";
+import type { PointFeature } from "supercluster";
+import useSupercluster from "use-supercluster";
+import debounce from "lodash.debounce";
+// import type { Campaign, Prisma } from "@prisma/client";
+import type { Campaign } from "~/schema";
+import type { DeviceClusterProperties } from "~/routes/explore";
+import { useSearchParams } from "@remix-run/react";
+import { FeatureCollection, Properties } from "@turf/helpers";
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+type PointProperties = {
+ title: string;
+ cluster: boolean;
+ point_count: number;
+ id: string;
+ // color: string;
+ // selected: boolean;
+};
+
+const DEBOUNCE_VALUE = 50;
+
+const options = {
+ radius: 50,
+ maxZoom: 14,
+};
+
+export default function PointLayer({
+ campaigns,
+}: // setDisplayedCampaigns,
+{
+ campaigns: Campaign[];
+ // setDisplayedCampaigns: Dispatch>;
+}) {
+ const { osem: mapRef } = useMap();
+ const [bounds, setBounds] = useState(
+ mapRef?.getMap().getBounds().toArray().flat() as BBox
+ );
+ const [zoom, setZoom] = useState(mapRef?.getZoom() || 0);
+ const [selectedMarker, setSelectedMarker] = useState("");
+ const [selectedCampaign, setSelectedCampaign] = useState();
+
+ const centerpoints = campaigns
+ .map((campaign: Campaign) => {
+ if (
+ typeof campaign.centerpoint === "object" &&
+ campaign.centerpoint !== null &&
+ "geometry" in campaign.centerpoint
+ ) {
+ const centerObject = campaign.centerpoint as any;
+ const geometryObject = centerObject.geometry as any;
+ if (centerObject && geometryObject) {
+ return {
+ coordinates: geometryObject.coordinates,
+ title: campaign.title,
+ id: campaign.id,
+ };
+ }
+ } else {
+ return null;
+ }
+ })
+ .filter((coords) => coords !== null);
+
+ const points: PointFeature[] =
+ useMemo(() => {
+ return centerpoints.map(
+ (point: PointFeature) => ({
+ type: "Feature",
+ properties: {
+ cluster: false,
+ point_count: 1,
+ color: "blue",
+ selected: false,
+ title: point?.title ?? "",
+ id: point?.id?.toString() ?? "",
+ },
+ geometry: {
+ type: "Point",
+ // @ts-ignore
+ coordinates: point.coordinates,
+ },
+ })
+ );
+ }, [centerpoints]);
+
+ const debouncedChangeHandler = debounce(() => {
+ if (!mapRef) return;
+ setBounds(mapRef.getMap().getBounds().toArray().flat() as BBox);
+ setZoom(mapRef.getZoom());
+ }, DEBOUNCE_VALUE);
+
+ // register the debounced change handler to map events
+ useEffect(() => {
+ if (!mapRef) return;
+
+ mapRef?.getMap().on("load", debouncedChangeHandler);
+ mapRef?.getMap().on("zoom", debouncedChangeHandler);
+ mapRef?.getMap().on("move", debouncedChangeHandler);
+ mapRef?.getMap().on("resize", debouncedChangeHandler);
+ }, [debouncedChangeHandler, mapRef]);
+
+ function createGeoJson(clusters: any) {
+ const filteredClusters = clusters.filter(
+ (cluster: any) => cluster.properties.cluster
+ );
+ const features: Feature[] = filteredClusters.map((cluster: any) => ({
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [
+ cluster.geometry.coordinates.longitude,
+ cluster.geometry.coordinates.latitude,
+ ],
+ },
+ properties: {
+ id: cluster.id,
+ },
+ }));
+ return {
+ type: "FeatureCollection",
+ features: features,
+ };
+ }
+
+ const { clusters, supercluster } = useSupercluster({
+ points,
+ bounds,
+ zoom,
+ options,
+ });
+
+ const geojsonData = useMemo(() => createGeoJson(clusters), [clusters]);
+ console.log(geojsonData);
+
+ const handleClusterClick = useCallback(
+ (cluster: DeviceClusterProperties) => {
+ // supercluster from hook can be null or undefined
+ if (!supercluster) return;
+
+ const [longitude, latitude] = cluster.geometry.coordinates;
+
+ const expansionZoom = Math.min(
+ supercluster.getClusterExpansionZoom(cluster.id as number),
+ 20
+ );
+
+ mapRef?.getMap().flyTo({
+ center: [longitude, latitude],
+ animate: true,
+ speed: 1.6,
+ zoom: expansionZoom,
+ essential: true,
+ });
+ },
+ [mapRef, supercluster]
+ );
+
+ const handleMarkerClick = useCallback(
+ (markerId: string, latitude: number, longitude: number) => {
+ const clickedCampaign = campaigns.filter(
+ (campaign: Campaign) => campaign.id === markerId
+ );
+ // const url = new URL(window.location.href);
+ // const query = url.searchParams;
+ // query.set("search", selectedCampaign[0].title);
+ // query.set("showMap", "true");
+ // window.location.href = url.toString();
+ // searchParams.append("search", selectedCampaign[0].title);
+
+ setSelectedMarker(markerId);
+ // setDisplayedCampaigns(selectedCampaign);
+ setSelectedCampaign(clickedCampaign[0]);
+ mapRef?.flyTo({
+ center: [longitude, latitude],
+ duration: 1000,
+ zoom: 6,
+ });
+ },
+ [
+ campaigns,
+ mapRef,
+ // setDisplayedCampaigns,
+ setSelectedCampaign,
+ setSelectedMarker,
+ ]
+ );
+
+ const clusterMarker = useMemo(() => {
+ return clusters.map((cluster) => {
+ // every cluster point has coordinates
+ const [longitude, latitude] = cluster.geometry.coordinates;
+ // the point may be either a cluster or a crime point
+ const { cluster: isCluster, point_count: pointCount } =
+ cluster.properties;
+
+ // we have a cluster to render
+ if (isCluster) {
+ return (
+
+ handleClusterClick(cluster)}
+ >
+ {pointCount}
+
+
+ );
+ }
+
+ // we have a single device to render
+ return (
+ <>
+
+ handleMarkerClick(cluster.properties.id, latitude, longitude)
+ }
+ >
+ {selectedMarker === cluster.properties.id && (
+ setSelectedMarker("")}
+ anchor="bottom"
+ maxWidth="400px"
+ style={{
+ maxHeight: "208px",
+ overflowY: "scroll",
+ }}
+ >
+
+
+ {selectedCampaign?.title}
+
+
+
+ Description
+ {selectedCampaign?.description}
+
+
+ Priority
+ {selectedCampaign?.priority}
+
+
+ Exposure
+ {selectedCampaign?.exposure}
+ {" "}
+
+ StartDate
+
+ {selectedCampaign?.startDate &&
+ new Date(selectedCampaign?.startDate)
+ .toISOString()
+ .split("T")[0]}
+
+
+
+ Phenomena
+
+ {selectedCampaign?.phenomena.map((p, i) => (
+ {p}
+ ))}
+
+
+
+
+
+ )}
+
+ {cluster.properties.title}
+
+ >
+ );
+ });
+ }, [
+ clusters,
+ handleClusterClick,
+ handleMarkerClick,
+ points.length,
+ selectedMarker,
+ ]);
+
+ return <>{clusterMarker}>;
+}
diff --git a/app/components/campaigns/overview/map/sidebar.tsx b/app/components/campaigns/overview/map/sidebar.tsx
new file mode 100644
index 00000000..e69de29b
diff --git a/app/components/campaigns/overview/pagination.tsx b/app/components/campaigns/overview/pagination.tsx
new file mode 100644
index 00000000..4a8a09ec
--- /dev/null
+++ b/app/components/campaigns/overview/pagination.tsx
@@ -0,0 +1,67 @@
+// code from https://github.com/AustinGil/npm/blob/main/app/components/Pagination.jsx
+
+import React from "react";
+import { Link, useSearchParams } from "@remix-run/react";
+import { Button } from "~/components/ui/button";
+import { listPageOptions } from "./list-page-options";
+
+const Pagination = ({
+ totalPages = Number.MAX_SAFE_INTEGER,
+ pageParam = "page",
+ className = "",
+ ...attrs
+}) => {
+ const [queryParams] = useSearchParams();
+ const currentPage = Number(queryParams.get(pageParam) || 1);
+ totalPages = Number(totalPages);
+
+ const previousQuery = new URLSearchParams(queryParams);
+ previousQuery.set(pageParam, (currentPage - 1).toString());
+ const nextQuery = new URLSearchParams(queryParams);
+ nextQuery.set(pageParam, (currentPage + 1).toString());
+
+ const pageOptions = listPageOptions(currentPage, totalPages);
+
+ return (
+
+ );
+};
+
+export default Pagination;
diff --git a/app/components/campaigns/overview/where-query.ts b/app/components/campaigns/overview/where-query.ts
new file mode 100644
index 00000000..1c7e9ab1
--- /dev/null
+++ b/app/components/campaigns/overview/where-query.ts
@@ -0,0 +1,75 @@
+export const generateWhereObject = (query: URLSearchParams) => {
+ const where: {
+ title?: {
+ contains: string;
+ mode: "insensitive";
+ };
+ priority?: string;
+ country?: {
+ contains: string;
+ mode: "insensitive";
+ };
+ exposure?: string;
+ startDate?: {
+ gte: Date;
+ };
+ endDate?: {
+ lte: Date;
+ };
+ phenomena?: any;
+ } = {};
+
+ if (query.get("search")) {
+ where.title = {
+ contains: query.get("search") || "",
+ mode: "insensitive",
+ };
+ }
+
+ if (query.get("priority")) {
+ const priority = query.get("priority") || "";
+ where.priority = priority;
+ }
+
+ if (query.get("country")) {
+ where.country = {
+ contains: query.get("country") || "",
+ mode: "insensitive",
+ };
+ }
+
+ if (query.get("exposure")) {
+ const exposure = query.get("exposure") || "UNKNOWN";
+ where.exposure = exposure;
+ }
+ if (query.get("phenomena")) {
+ const phenomenaString = query.get("phenomena") || "";
+ try {
+ const phenomena = JSON.parse(phenomenaString);
+
+ if (Array.isArray(phenomena) && phenomena.length > 0) {
+ where.phenomena = {
+ hasSome: phenomena,
+ };
+ }
+ } catch (error) {
+ console.error("Error parsing JSON:", error);
+ }
+ }
+
+ if (query.get("startDate")) {
+ const startDate = new Date(query.get("startDate") || "");
+ where.startDate = {
+ gte: startDate,
+ };
+ }
+
+ if (query.get("endDate")) {
+ const endDate = new Date(query.get("endDate") || "");
+ where.endDate = {
+ lte: endDate,
+ };
+ }
+
+ return where;
+};
diff --git a/app/components/campaigns/phenomena-select.tsx b/app/components/campaigns/phenomena-select.tsx
new file mode 100644
index 00000000..70167fc0
--- /dev/null
+++ b/app/components/campaigns/phenomena-select.tsx
@@ -0,0 +1,49 @@
+import type { Dispatch, SetStateAction } from "react";
+import type { DataItem } from "../ui/multi-select";
+import { MultiSelect } from "../ui/multi-select";
+
+type PhenomenaSelectProps = {
+ phenomena: string[];
+ setSelected: React.Dispatch>;
+ localFilterObject?: {
+ country: string;
+ exposure: string;
+ phenomena: string[];
+ time_range: {
+ startDate: string;
+ endDate: string;
+ };
+ };
+ setLocalFilterObject?: Dispatch<
+ SetStateAction<{
+ country: string;
+ exposure: string;
+ phenomena: string[];
+ time_range: {
+ startDate: string;
+ endDate: string;
+ };
+ }>
+ >;
+ setSelectedPhenomena?: any;
+};
+
+export default function PhenomenaSelect({
+ phenomena,
+ setSelected,
+}: PhenomenaSelectProps) {
+ const data = phenomena.map((str) => {
+ return {
+ value: str,
+ label: str,
+ };
+ });
+ return (
+
+ );
+}
diff --git a/app/components/campaigns/select-countries.tsx b/app/components/campaigns/select-countries.tsx
new file mode 100644
index 00000000..ce156761
--- /dev/null
+++ b/app/components/campaigns/select-countries.tsx
@@ -0,0 +1,28 @@
+import { MultiSelect } from "../ui/multi-select";
+import { countryListAlpha2 } from "./overview/all-countries-object";
+import type { DataItem } from "../ui/multi-select";
+
+type Props = {
+ selectedCountry?: DataItem;
+ setSelected: React.Dispatch>;
+};
+export default function SelectCountries({
+ selectedCountry,
+ setSelected,
+}: Props) {
+ const data = Object.entries(countryListAlpha2).map((entry) => {
+ return {
+ value: entry[0],
+ label: entry[1],
+ };
+ });
+ const preselected = selectedCountry;
+ return (
+
+ );
+}
diff --git a/app/components/campaigns/tutorial/contribute/steps.tsx b/app/components/campaigns/tutorial/contribute/steps.tsx
new file mode 100644
index 00000000..e69de29b
diff --git a/app/components/campaigns/tutorial/create/steps.tsx b/app/components/campaigns/tutorial/create/steps.tsx
new file mode 100644
index 00000000..71fe74c6
--- /dev/null
+++ b/app/components/campaigns/tutorial/create/steps.tsx
@@ -0,0 +1,8 @@
+// export default function CreateSteps(){
+// const steps = [
+// {
+// message: '',
+// img: string }
+
+// ]
+// }
\ No newline at end of file
diff --git a/app/components/device-card.tsx b/app/components/device-card.tsx
new file mode 100644
index 00000000..68344276
--- /dev/null
+++ b/app/components/device-card.tsx
@@ -0,0 +1,35 @@
+import { Circle } from "lucide-react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "./ui/card";
+import type { Device } from "~/schema";
+
+interface DeviceCardProps {
+ device: Device;
+}
+
+export default function DeviceCard({ device }: DeviceCardProps) {
+ return (
+
+
+
+ {device.name}
+ {device.description}
+
+
+
+
+
+
+ {device.model}
+
+
Updated {device.updatedAt.toString()}
+
+
+
+ );
+}
diff --git a/app/components/error-boundary.tsx b/app/components/error-boundary.tsx
new file mode 100644
index 00000000..21d4a5eb
--- /dev/null
+++ b/app/components/error-boundary.tsx
@@ -0,0 +1,44 @@
+import {
+ isRouteErrorResponse,
+ useParams,
+ useRouteError,
+} from "@remix-run/react";
+import { type ErrorResponse } from "@remix-run/router";
+import { getErrorMessage } from "~/utils/misc";
+
+type StatusHandler = (info: {
+ error: ErrorResponse;
+ params: Record;
+}) => JSX.Element | null;
+
+export function GeneralErrorBoundary({
+ defaultStatusHandler = ({ error }) => (
+
+ {error.status} {error.data}
+
+ ),
+ statusHandlers,
+ unexpectedErrorHandler = (error) => {getErrorMessage(error)}
,
+}: {
+ defaultStatusHandler?: StatusHandler;
+ statusHandlers?: Record;
+ unexpectedErrorHandler?: (error: unknown) => JSX.Element | null;
+}) {
+ const error = useRouteError();
+ const params = useParams();
+
+ if (typeof document !== "undefined") {
+ console.error(error);
+ }
+
+ return (
+
+ {isRouteErrorResponse(error)
+ ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({
+ error,
+ params,
+ })
+ : unexpectedErrorHandler(error)}
+
+ );
+}
diff --git a/app/components/error-message.tsx b/app/components/error-message.tsx
new file mode 100644
index 00000000..79ba0be3
--- /dev/null
+++ b/app/components/error-message.tsx
@@ -0,0 +1,29 @@
+// import { X } from "lucide-react";
+// import { Alert, AlertDescription } from "./ui/alert";
+// import { useNavigate } from "@remix-run/react";
+
+// export default function ErrorMessage() {
+// let navigate = useNavigate();
+// const goBack = () => navigate(-1);
+
+// return (
+//
+//
+// {
+// goBack();
+// }}
+// />
+//
+//
+// Oh no, this shouldn't happen, but don't worry, our team is on the case!
+//
+//
+//
+// Add some info here.
+//
+//
+//
+// );
+// }
\ No newline at end of file
diff --git a/app/components/header/menu/index.tsx b/app/components/header/menu/index.tsx
index f5f6550d..c8bfaf34 100644
--- a/app/components/header/menu/index.tsx
+++ b/app/components/header/menu/index.tsx
@@ -145,16 +145,17 @@ export default function Menu() {
)}
{data.profile && (
-
+
+ Profile
)}
- {t("settings_label")}
+ {t("settings_label")}
- {t("my_devices_label")}
+ {t("my_devices_label")}
diff --git a/app/components/header/nav-bar/nav-bar.tsx b/app/components/header/nav-bar/nav-bar.tsx
index e0e3970a..4430df74 100644
--- a/app/components/header/nav-bar/nav-bar.tsx
+++ b/app/components/header/nav-bar/nav-bar.tsx
@@ -1,204 +1,204 @@
-import React, { useEffect, useRef } from "react";
-import Search from "~/components/search";
-import { SunIcon, CalendarDaysIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
-import { TimeFilter } from "~/components/header/navBar/time-filter/time-filter";
-import type { DateRange } from "react-day-picker";
-import getUserLocale from "get-user-locale";
-import { format } from "date-fns";
-import { useTranslation } from "react-i18next";
-import type { Device } from "@prisma/client";
-
-interface NavBarProps {
- devices: Device[];
-}
-
-type ValuePiece = Date | string | null;
-
-type Value = ValuePiece
-
-
-export default function NavBar(props: NavBarProps) {
- let { t } = useTranslation("navbar");
-
- const [timeState, setTimeState] = React.useState("live");
- const [isDialogOpen, setIsDialogOpen] = React.useState(false);
- const [isHovered, setIsHovered] = React.useState(false);
- const [showSearch, setShowSearch] = React.useState(false);
- const searchRef = useRef(null);
-
- const [value, onChange] = React.useState(null);
- const [dateRange, setDateRange] = React.useState(undefined);
- const [singleDate, setSingleDate] = React.useState(undefined)
- const userLocaleString = getUserLocale();
-
- /**
- * Focus the search input
- */
- const focusSearchInput = () => {
- searchRef.current?.focus();
- };
-
- /**
- * Display the search
- */
- const displaySearch = () => {
- setShowSearch(true);
- setTimeout(() => {
- focusSearchInput();
- }, 100);
- };
-
- /**
- * Close the search when the escape key is pressed
- *
- * @param event event object
- */
- const closeSearch = (event: any) => {
- if (event.key === "Escape") {
- setShowSearch(false);
- }
- };
-
- /**
- * useEffect hook to attach and remove the event listener
- */
- useEffect(() => {
- // attach the event listener
- document.addEventListener("keydown", closeSearch);
-
- // remove the event listener
- return () => {
- document.removeEventListener("keydown", closeSearch);
- };
- });
-
- // useEffect(() => {
- // console.log("dateRange", dateRange);
- // console.log("time", value);
- // console.log("singleDate", singleDate);
- // }, [dateRange, value, singleDate]);
-
- return (
-
- {!isHovered && !showSearch ? (
-
{
- setIsHovered(true);
- }}
- >
-
-
-
- {t("temperature_label")}
-
-
-
-
- Suche
-
- Ctrl + K
-
-
-
-
-
- {timeState === "live" ? (
- {t("live_label")}
- ) : timeState === "pointintime" ? (
- singleDate ? (
- <>
- {format(
- singleDate,
- userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy"
- )}
- >
- ) : (
- t("date_picker_label")
- )
- ) : timeState === "timeperiod" ? (
- dateRange?.from ? (
- dateRange.to ? (
- <>
- {format(
- dateRange.from,
- userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy"
- )}{" "}
- -{" "}
- {format(
- dateRange.to,
- userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy"
- )}
- >
- ) : (
- format(
- dateRange.from,
- userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy"
- )
- )
- ) : (
- t("date_range_picker_label")
- )
- ) : null}
-
-
-
- ) : isHovered && !showSearch ? (
-
{
- if (!isDialogOpen) {
- setIsHovered(false);
- }
- }}
- >
-
-
-
-
-
-
- ) : (
-
{
- setIsHovered(false);
- }}
- onMouseEnter={() => {
- setIsHovered(true);
- }}
- >
- {
- setShowSearch(false);
- setIsHovered(false);
- }}
- />
-
- )}
-
- );
-}
+import React, { useEffect, useRef } from "react";
+import Search from "~/components/search";
+import { SunIcon, CalendarDaysIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
+import { TimeFilter } from "~/components/header/navBar/time-filter/time-filter";
+import type { DateRange } from "react-day-picker";
+import getUserLocale from "get-user-locale";
+import { format } from "date-fns";
+import { useTranslation } from "react-i18next";
+import type { Device } from "~/schema";
+
+interface NavBarProps {
+ devices: Device[];
+}
+
+type ValuePiece = Date | string | null;
+
+type Value = ValuePiece
+
+
+export default function NavBar(props: NavBarProps) {
+ let { t } = useTranslation("navbar");
+
+ const [timeState, setTimeState] = React.useState("live");
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false);
+ const [isHovered, setIsHovered] = React.useState(false);
+ const [showSearch, setShowSearch] = React.useState(false);
+ const searchRef = useRef(null);
+
+ const [value, onChange] = React.useState(null);
+ const [dateRange, setDateRange] = React.useState(undefined);
+ const [singleDate, setSingleDate] = React.useState(undefined)
+ const userLocaleString = getUserLocale();
+
+ /**
+ * Focus the search input
+ */
+ const focusSearchInput = () => {
+ searchRef.current?.focus();
+ };
+
+ /**
+ * Display the search
+ */
+ const displaySearch = () => {
+ setShowSearch(true);
+ setTimeout(() => {
+ focusSearchInput();
+ }, 100);
+ };
+
+ /**
+ * Close the search when the escape key is pressed
+ *
+ * @param event event object
+ */
+ const closeSearch = (event: any) => {
+ if (event.key === "Escape") {
+ setShowSearch(false);
+ }
+ };
+
+ /**
+ * useEffect hook to attach and remove the event listener
+ */
+ useEffect(() => {
+ // attach the event listener
+ document.addEventListener("keydown", closeSearch);
+
+ // remove the event listener
+ return () => {
+ document.removeEventListener("keydown", closeSearch);
+ };
+ });
+
+ // useEffect(() => {
+ // console.log("dateRange", dateRange);
+ // console.log("time", value);
+ // console.log("singleDate", singleDate);
+ // }, [dateRange, value, singleDate]);
+
+ return (
+
+ {!isHovered && !showSearch ? (
+
{
+ setIsHovered(true);
+ }}
+ >
+
+
+
+ {t("temperature_label")}
+
+
+
+
+ Suche
+
+ Ctrl + K
+
+
+
+
+
+ {timeState === "live" ? (
+ {t("live_label")}
+ ) : timeState === "pointintime" ? (
+ singleDate ? (
+ <>
+ {format(
+ singleDate,
+ userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy"
+ )}
+ >
+ ) : (
+ t("date_picker_label")
+ )
+ ) : timeState === "timeperiod" ? (
+ dateRange?.from ? (
+ dateRange.to ? (
+ <>
+ {format(
+ dateRange.from,
+ userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy"
+ )}{" "}
+ -{" "}
+ {format(
+ dateRange.to,
+ userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy"
+ )}
+ >
+ ) : (
+ format(
+ dateRange.from,
+ userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy"
+ )
+ )
+ ) : (
+ t("date_range_picker_label")
+ )
+ ) : null}
+
+
+
+ ) : isHovered && !showSearch ? (
+
{
+ if (!isDialogOpen) {
+ setIsHovered(false);
+ }
+ }}
+ >
+
+
+
+
+
+
+ ) : (
+
{
+ setIsHovered(false);
+ }}
+ onMouseEnter={() => {
+ setIsHovered(true);
+ }}
+ >
+ {
+ setShowSearch(false);
+ setIsHovered(false);
+ }}
+ />
+
+ )}
+
+ );
+}
diff --git a/app/components/header/notification/index.tsx b/app/components/header/notification/index.tsx
index 447137b9..8dda7cdb 100644
--- a/app/components/header/notification/index.tsx
+++ b/app/components/header/notification/index.tsx
@@ -1,47 +1,86 @@
-import {
- NovuProvider,
- PopoverNotificationCenter,
- NotificationBell,
-} from "@novu/notification-center";
-import type { IMessage } from "@novu/notification-center";
-import { useLoaderData } from "@remix-run/react";
-import type { loader } from "~/root";
-
-function onNotificationClick(message: IMessage) {
- if (message?.cta?.data?.url) {
- //window.location.href = message.cta.data.url;
- window.open(message.cta.data.url, "_blank");
- }
-}
-
-export default function Notification() {
- const data = useLoaderData();
- return (
-
-
- {
- //header content here
- return ;
- }}
- footer={() => {
- //footer content here
- return ;
- }}
- >
- {({ unseenCount }) => }
-
-
-
- );
-}
+import {
+ NovuProvider,
+ PopoverNotificationCenter,
+ NotificationBell,
+ useUpdateAction,
+ MessageActionStatusEnum,
+ useRemoveNotification,
+} from "@novu/notification-center";
+import type { ButtonTypeEnum, IMessage } from "@novu/notification-center";
+import { useLoaderData } from "@remix-run/react";
+import type { loader } from "~/root";
+import { styles } from "./styles";
+import { useToast } from "~/components/ui/use-toast";
+import { useNavigate } from "@remix-run/react";
+
+function PopoverWrapper() {
+ const { updateAction } = useUpdateAction();
+ const { removeNotification } = useRemoveNotification();
+ const { toast } = useToast();
+ const navigate = useNavigate();
+
+ function handlerOnNotificationClick(message: IMessage) {
+ if (message?.cta?.data?.url) {
+ window.location.href = message.cta.data.url;
+ }
+ }
+
+ async function handlerOnActionClick(
+ templateIdentifier: string,
+ type: ButtonTypeEnum,
+ message: IMessage
+ ) {
+ if (templateIdentifier === "new-participant") {
+ await updateAction({
+ messageId: message._id,
+ actionButtonType: type,
+ status: MessageActionStatusEnum.DONE,
+ });
+
+ await removeNotification({
+ messageId: message._id,
+ });
+ if (type === "primary") {
+ toast({ title: "Participant accepted successfully!" });
+ // navigate("../create/area");
+ }
+ if (type === "secondary") {
+ toast({ title: "Participant rejected" });
+ }
+ }
+ }
+
+ return (
+
+ {({ unseenCount }) => {
+ return ;
+ }}
+
+ );
+}
+
+export default function Notification() {
+ const data = useLoaderData();
+ if (!data.user) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/app/components/header/notification/styles.ts b/app/components/header/notification/styles.ts
new file mode 100644
index 00000000..38b37f3b
--- /dev/null
+++ b/app/components/header/notification/styles.ts
@@ -0,0 +1,174 @@
+const primaryColor = "#709f61";
+const secondaryColor = "#AFE1AF";
+const primaryTextColor = "#0C0404";
+const secondaryTextColor = "#494F55";
+const unreadBackGroundColor = "#869F9F";
+const primaryButtonBackGroundColor = unreadBackGroundColor;
+const secondaryButtonBackGroundColor = "#C6DFCD";
+const dropdownBorderStyle = "2px solid #AFE1AF";
+const tabLabelAfterStyle = "#AFE1AF !important";
+const ncWidth = "350px !important";
+
+export const styles = {
+ bellButton: {
+ root: {
+ marginTop: "5px",
+ svg: {
+ color: secondaryColor,
+ fill: primaryColor,
+ minWidth: "75px",
+ minHeight: "80px",
+ },
+ },
+ dot: {
+ marginRight: "-25px",
+ marginTop: "-20px",
+ rect: {
+ fill: "red",
+ strokeWidth: "0",
+ width: "3px",
+ height: "3px",
+ x: 10,
+ y: 2,
+ },
+ },
+ },
+ unseenBadge: {
+ root: { color: primaryTextColor, background: secondaryColor },
+ },
+ popover: {
+ arrow: {
+ backgroundColor: primaryColor,
+ borderLeftColor: secondaryColor,
+ borderTopColor: secondaryColor,
+ },
+ dropdown: {
+ border: dropdownBorderStyle,
+ borderRadius: "10px",
+ marginTop: "25px",
+ maxWidth: ncWidth,
+ },
+ },
+ header: {
+ root: {
+ backgroundColor: primaryColor,
+ "&:hover": { backgroundColor: primaryColor },
+ cursor: "pointer",
+ color: primaryTextColor,
+ },
+ cog: { opacity: 1 },
+ markAsRead: {
+ color: primaryTextColor,
+ fontSize: "14px",
+ },
+ title: { color: primaryTextColor },
+ backButton: {
+ color: primaryTextColor,
+ },
+ },
+ layout: {
+ root: {
+ background: primaryColor,
+ maxWidth: ncWidth,
+ },
+ },
+ loader: {
+ root: {
+ stroke: primaryTextColor,
+ },
+ },
+ accordion: {
+ item: {
+ backgroundColor: secondaryColor,
+ ":hover": {
+ backgroundColor: secondaryColor,
+ },
+ },
+ content: {
+ backgroundColor: secondaryColor,
+ borderBottomLeftRadius: "7px",
+ borderBottomRightRadius: "7px",
+ },
+ control: {
+ ":hover": {
+ backgroundColor: secondaryColor,
+ },
+ color: primaryTextColor,
+ title: {
+ color: primaryTextColor,
+ },
+ },
+ chevron: {
+ color: primaryTextColor,
+ },
+ },
+ notifications: {
+ root: {
+ ".nc-notifications-list-item": {
+ backgroundColor: secondaryColor,
+ },
+ },
+ listItem: {
+ layout: {
+ borderRadius: "7px",
+ color: primaryTextColor,
+ },
+ timestamp: { color: secondaryTextColor, fontWeight: "bold" },
+ dotsButton: {
+ path: {
+ fill: primaryTextColor,
+ },
+ },
+ unread: {
+ "::before": { background: unreadBackGroundColor },
+ },
+ buttons: {
+ primary: {
+ background: primaryButtonBackGroundColor,
+ color: primaryTextColor,
+ "&:hover": {
+ background: primaryButtonBackGroundColor,
+ color: secondaryTextColor,
+ },
+ },
+ secondary: {
+ background: secondaryButtonBackGroundColor,
+ color: secondaryTextColor,
+ "&:hover": {
+ background: secondaryButtonBackGroundColor,
+ color: secondaryTextColor,
+ },
+ },
+ },
+ },
+ },
+ actionsMenu: {
+ item: { "&:hover": { backgroundColor: secondaryColor } },
+ dropdown: {
+ backgroundColor: primaryColor,
+ },
+ arrow: {
+ backgroundColor: primaryColor,
+ borderTop: "0",
+ borderLeft: "0",
+ },
+ },
+ preferences: {
+ item: {
+ title: { color: primaryTextColor },
+ divider: { borderTopColor: primaryColor },
+ channels: { color: secondaryTextColor },
+ content: {
+ icon: { color: primaryTextColor },
+ channelLabel: { color: primaryTextColor },
+ success: { color: primaryTextColor },
+ },
+ },
+ },
+ tabs: {
+ tabLabel: {
+ "::after": { background: tabLabelAfterStyle },
+ },
+ tabsList: { borderBottomColor: primaryColor },
+ },
+};
diff --git a/app/components/label-button.tsx b/app/components/label-button.tsx
new file mode 100644
index 00000000..4a77d3ad
--- /dev/null
+++ b/app/components/label-button.tsx
@@ -0,0 +1,5 @@
+export function LabelButton({
+ ...props
+}: Omit, "className">) {
+ return ;
+}
diff --git a/app/components/landing/header.tsx b/app/components/landing/header.tsx
index 47e9a894..6b296937 100644
--- a/app/components/landing/header.tsx
+++ b/app/components/landing/header.tsx
@@ -1,284 +1,288 @@
-import { Form, Link } from "@remix-run/react";
-import { Theme, useTheme } from "~/utils/theme-provider";
-import { SunIcon, MoonIcon } from "@heroicons/react/24/solid";
-import invariant from "tiny-invariant";
-import type { header } from "~/lib/directus";
-import { useState } from "react";
-
-const links = [
- {
- name: "Explore",
- link: "/explore",
- },
- {
- name: "Features",
- link: "#features",
- },
- {
- name: "Tools",
- link: "#tools",
- },
- {
- name: "Use Cases",
- link: "#useCases",
- },
- {
- name: "Partners",
- link: "#partners",
- },
-];
-
-type HeaderProps = {
- data: header;
-};
-
-export default function Header(data: HeaderProps) {
- const [theme, setTheme] = useTheme();
- const [openMenu, setOpenMenu] = useState(false);
- const toggleTheme = () => {
- setTheme((prevTheme) =>
- prevTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT
- );
- };
-
- //* User Id and Name
- const userId = data.data.userId;
- const userName = data.data.userName;
-
- //* To control user menu visibility
- const userMenu = () => {
- console.log("🚀 ~ onClick");
- const profileMenu = document.querySelector(".profile-menu");
- invariant(profileMenu, "profileMenu is not found");
- let profileMenuStatus = profileMenu.classList.contains("invisible");
- if (profileMenuStatus) {
- profileMenu.classList.remove("invisible");
- profileMenu.classList.add("visible");
- } else {
- profileMenu.classList.remove("visible");
- profileMenu.classList.add("invisible");
- }
- };
-
- return (
-
- );
-}
+import { Form, Link } from "@remix-run/react";
+import { Theme, useTheme } from "~/utils/theme-provider";
+import { SunIcon, MoonIcon } from "@heroicons/react/24/solid";
+import invariant from "tiny-invariant";
+import type { header } from "~/lib/directus";
+import { useState } from "react";
+
+const links = [
+ {
+ name: "Explore",
+ link: "/explore",
+ },
+ {
+ name: "Features",
+ link: "#features",
+ },
+ {
+ name: "Tools",
+ link: "#tools",
+ },
+ {
+ name: "Use Cases",
+ link: "#useCases",
+ },
+ {
+ name: "Partners",
+ link: "#partners",
+ },
+ {
+ name: "Campaigns",
+ link: "/campaigns/explore",
+ },
+];
+
+type HeaderProps = {
+ data: header;
+};
+
+export default function Header(data: HeaderProps) {
+ const [theme, setTheme] = useTheme();
+ const [openMenu, setOpenMenu] = useState(false);
+ const toggleTheme = () => {
+ setTheme((prevTheme) =>
+ prevTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT
+ );
+ };
+
+ //* User Id and Name
+ const userId = data.data.userId;
+ const userName = data.data.userName;
+
+ //* To control user menu visibility
+ const userMenu = () => {
+ console.log("🚀 ~ onClick");
+ const profileMenu = document.querySelector(".profile-menu");
+ invariant(profileMenu, "profileMenu is not found");
+ let profileMenuStatus = profileMenu.classList.contains("invisible");
+ if (profileMenuStatus) {
+ profileMenu.classList.remove("invisible");
+ profileMenu.classList.add("visible");
+ } else {
+ profileMenu.classList.remove("visible");
+ profileMenu.classList.add("invisible");
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/app/components/map/Markers.tsx b/app/components/map/Markers.tsx
new file mode 100644
index 00000000..5b1512e1
--- /dev/null
+++ b/app/components/map/Markers.tsx
@@ -0,0 +1,61 @@
+import { useEffect, useState } from "react";
+import type { CircleLayer, MarkerProps } from "react-map-gl";
+import { Layer, Marker, Source } from "react-map-gl";
+
+const triggerHoverLayerStyle: CircleLayer = {
+ id: "point",
+ type: "circle",
+ paint: {
+ "circle-radius": 30,
+ "circle-opacity": 0,
+ "circle-translate": [0, -12],
+ },
+};
+
+type Props = {
+ markers: MarkerProps[];
+ onClick?: (_m: MarkerProps) => void;
+ onChange?: (_e: mapboxgl.MapLayerMouseEvent) => void;
+};
+
+export default function Markers({ markers, onClick, onChange }: Props) {
+ const [triggerHoverLayerData, setTriggerHoverLayerData] = useState<
+ GeoJSON.FeatureCollection | undefined
+ >();
+
+ useEffect(() => {
+ // this layer triggers the onhover method
+ setTriggerHoverLayerData({
+ type: "FeatureCollection",
+ features:
+ markers?.map((m) => ({
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [m.longitude, m.latitude],
+ },
+ properties: {
+ // stepId: m.stepId,
+ },
+ })) ?? [],
+ });
+ }, [markers]);
+
+ return (
+ <>
+ {markers.map((m, i) => (
+ onClick(m)}
+ style={{
+ padding: "10px",
+ }}
+ >
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/app/components/map/layers/cluster/box-marker.tsx b/app/components/map/layers/cluster/box-marker.tsx
new file mode 100644
index 00000000..88732451
--- /dev/null
+++ b/app/components/map/layers/cluster/box-marker.tsx
@@ -0,0 +1,78 @@
+import type { Device } from "~/schema";
+import { exposureEnum } from "~/schema";
+import { useNavigate } from "@remix-run/react";
+import { AnimatePresence, motion } from "framer-motion";
+import { Box, Rocket } from "lucide-react";
+import type { MarkerProps } from "react-map-gl";
+import { Marker, useMap } from "react-map-gl";
+import { cn } from "~/lib/utils";
+
+interface BoxMarkerProps extends MarkerProps {
+ device: Device;
+}
+
+const getStatusColor = (device: Device) => {
+ if (device.status === "active") {
+ if(device.exposure === 'mobile') {
+ return "bg-blue-100";
+ }
+ return "bg-green-100";
+ } else if (device.status === "inactive") {
+ return "bg-gray-100";
+ } else {
+ return "bg-gray-100 opacity-50";
+ }
+}
+
+export default function BoxMarker({ device, ...props }: BoxMarkerProps) {
+ const navigate = useNavigate();
+ const { osem } = useMap();
+
+ const isFullZoom = osem && osem?.getZoom() >= 14;
+
+ return (
+
+
+ navigate(`${device.id}`)}
+ >
+
+ {device.exposure === 'mobile' ? (
+
+ ) : (
+
+ )}
+ {isFullZoom && device.status === "active" ? (
+
+ ) : null}
+
+ {isFullZoom ? (
+
+ {device.name}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/app/components/map/layers/cluster/cluster-layer.tsx b/app/components/map/layers/cluster/cluster-layer.tsx
new file mode 100644
index 00000000..7b8cfc8d
--- /dev/null
+++ b/app/components/map/layers/cluster/cluster-layer.tsx
@@ -0,0 +1,152 @@
+import type { Device } from "~/schema";
+import type {
+ GeoJsonProperties,
+ BBox,
+ FeatureCollection,
+ Point,
+} from "geojson";
+import { useMemo, useCallback, useState, useEffect } from "react";
+import { Marker, useMap } from "react-map-gl";
+import type { PointFeature } from "supercluster";
+import useSupercluster from "use-supercluster";
+import type { DeviceClusterProperties } from "~/routes/explore";
+import DonutChartCluster from "./donut-chart-cluster";
+import BoxMarker from "./box-marker";
+import debounce from "lodash.debounce";
+
+const DEBOUNCE_VALUE = 50;
+
+// supercluster options
+const options = {
+ radius: 50,
+ maxZoom: 14,
+ map: (props: any) => ({ categories: { [props.status]: 1 } }),
+ reduce: (accumulated: any, props: any) => {
+ const categories: any = {};
+ // clone the categories object from the accumulator
+ for (const key in accumulated.categories) {
+ categories[key] = accumulated.categories[key];
+ }
+ // add props' category data to the clone
+ for (const key in props.categories) {
+ if (key in accumulated.categories) {
+ categories[key] = accumulated.categories[key] + props.categories[key];
+ } else {
+ categories[key] = props.categories[key];
+ }
+ }
+ // assign the clone to the accumulator
+ accumulated.categories = categories;
+ },
+};
+
+export default function ClusterLayer({
+ devices,
+}: {
+ devices: FeatureCollection;
+}) {
+ const { osem: mapRef } = useMap();
+
+ // the viewport bounds and zoom level
+ const [bounds, setBounds] = useState(
+ mapRef?.getMap().getBounds().toArray().flat() as BBox
+ );
+ const [zoom, setZoom] = useState(mapRef?.getZoom() || 0);
+
+ // get clusters
+ const points: PointFeature[] = useMemo(() => {
+ return devices.features.map((device) => ({
+ type: "Feature",
+ properties: {
+ cluster: false,
+ ...device.properties,
+ },
+ geometry: device.geometry,
+ }));
+ }, [devices.features]);
+
+ // get bounds and zoom level from the map
+ // debounce the change handler to prevent too many updates
+ const debouncedChangeHandler = debounce(() => {
+ if (!mapRef) return;
+ setBounds(mapRef.getMap().getBounds().toArray().flat() as BBox);
+ setZoom(mapRef.getZoom());
+ }, DEBOUNCE_VALUE);
+
+ // register the debounced change handler to map events
+ useEffect(() => {
+ if (!mapRef) return;
+
+ mapRef?.getMap().on("load", debouncedChangeHandler);
+ mapRef?.getMap().on("zoom", debouncedChangeHandler);
+ mapRef?.getMap().on("move", debouncedChangeHandler);
+ mapRef?.getMap().on("resize", debouncedChangeHandler);
+ }, [debouncedChangeHandler, mapRef]);
+
+ const { clusters, supercluster } = useSupercluster({
+ points,
+ bounds,
+ zoom,
+ options,
+ });
+
+ const clusterOnClick = useCallback(
+ (cluster: DeviceClusterProperties) => {
+ // supercluster from hook can be null or undefined
+ if (!supercluster) return;
+
+ const [longitude, latitude] = cluster.geometry.coordinates;
+
+ const expansionZoom = Math.min(
+ supercluster.getClusterExpansionZoom(cluster.id as number),
+ 20
+ );
+
+ mapRef?.getMap().flyTo({
+ center: [longitude, latitude],
+ animate: true,
+ speed: 1.6,
+ zoom: expansionZoom,
+ essential: true,
+ });
+ },
+ [mapRef, supercluster]
+ );
+
+ const clusterMarker = useMemo(() => {
+ return clusters.map((cluster) => {
+ // every cluster point has coordinates
+ const [longitude, latitude] = cluster.geometry.coordinates;
+ // the point may be either a cluster or a crime point
+ const { cluster: isCluster } = cluster.properties;
+
+ // we have a cluster to render
+ if (isCluster) {
+ return (
+
+
+
+ );
+ }
+
+ // we have a single device to render
+ return (
+
+ );
+ });
+ }, [clusterOnClick, clusters]);
+
+ return <>{clusterMarker}>;
+}
\ No newline at end of file
diff --git a/app/components/map/cluster/donut-chart-cluster.tsx b/app/components/map/layers/cluster/donut-chart-cluster.tsx
similarity index 100%
rename from app/components/map/cluster/donut-chart-cluster.tsx
rename to app/components/map/layers/cluster/donut-chart-cluster.tsx
diff --git a/app/components/map/layers.ts b/app/components/map/layers/index.ts
similarity index 100%
rename from app/components/map/layers.ts
rename to app/components/map/layers/index.ts
diff --git a/app/components/map/layers/mobile/color-palette.ts b/app/components/map/layers/mobile/color-palette.ts
new file mode 100644
index 00000000..c61eea8e
--- /dev/null
+++ b/app/components/map/layers/mobile/color-palette.ts
@@ -0,0 +1,11 @@
+import chroma from "chroma-js";
+
+export const LOW_COLOR = "#375F73";
+export const HIGH_COLOR = "#B5F584";
+
+export const createPalette = (
+ min: number,
+ max: number,
+ minColor = LOW_COLOR,
+ maxColor = HIGH_COLOR
+) => chroma.scale([minColor, maxColor]).domain([min, max]);
diff --git a/app/components/map/layers/mobile/mobile-box-layer.tsx b/app/components/map/layers/mobile/mobile-box-layer.tsx
new file mode 100644
index 00000000..2994e34f
--- /dev/null
+++ b/app/components/map/layers/mobile/mobile-box-layer.tsx
@@ -0,0 +1,114 @@
+import type { Sensor } from "~/schema";
+import {
+ featureCollection,
+ lineString,
+ multiLineString,
+ point,
+} from "@turf/helpers";
+import type { MultiLineString, Point } from "geojson";
+import { useEffect, useState } from "react";
+import { Layer, Source, useMap } from "react-map-gl";
+import bbox from "@turf/bbox";
+import { HIGH_COLOR, LOW_COLOR, createPalette } from "./color-palette";
+
+const FIT_PADDING = 50;
+const BOTTOM_BAR_HEIGHT = 400;
+
+export default function MobileBoxLayer({
+ sensor,
+ minColor = LOW_COLOR,
+ maxColor = HIGH_COLOR,
+}: {
+ sensor: Sensor;
+ minColor?:
+ | mapboxgl.CirclePaint["circle-color"]
+ | mapboxgl.LinePaint["line-color"];
+ maxColor?:
+ | mapboxgl.CirclePaint["circle-color"]
+ | mapboxgl.LinePaint["line-color"];
+}) {
+ const [sourceData, setSourceData] = useState();
+
+ const { osem } = useMap();
+
+ useEffect(() => {
+ const sensorData = sensor.data! as unknown as {
+ value: String;
+ location?: number[];
+ createdAt: Date;
+ }[];
+
+ // create color palette from min and max values
+ const minValue = Math.min(...sensorData.map((d) => Number(d.value)));
+ const maxValue = Math.max(...sensorData.map((d) => Number(d.value)));
+ const palette = createPalette(
+ minValue,
+ maxValue,
+ minColor as string,
+ maxColor as string
+ );
+
+ // generate points from the sensor data
+ // apply color from palette
+ const points = sensorData.map((measurement) =>
+ point(measurement.location!, {
+ value: Number(measurement.value),
+ createdAt: new Date(measurement.createdAt),
+ color: palette(Number(measurement.value)).hex(),
+ })
+ );
+
+ if (points.length === 0) return;
+
+ // generate a line from the points
+ const line = lineString(points.map((point) => point.geometry.coordinates));
+ const lines = multiLineString([line.geometry.coordinates]);
+
+ setSourceData(
+ featureCollection([...points, lines])
+ );
+ }, [maxColor, minColor, sensor.data]);
+
+ // fit the map to the bounds of the data
+ useEffect(() => {
+ if (!osem || !sourceData) return;
+ const [x1, y1, x2, y2] = bbox(sourceData);
+ osem?.fitBounds([x1, y1, x2, y2], {
+ padding: {
+ top: FIT_PADDING,
+ bottom: BOTTOM_BAR_HEIGHT + FIT_PADDING,
+ left: FIT_PADDING,
+ right: FIT_PADDING,
+ },
+ });
+ }, [osem, sourceData]);
+
+ if (!sourceData) return null;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/app/components/map/layers/mobile/mobile-box-view.tsx b/app/components/map/layers/mobile/mobile-box-view.tsx
new file mode 100644
index 00000000..64f26a73
--- /dev/null
+++ b/app/components/map/layers/mobile/mobile-box-view.tsx
@@ -0,0 +1,106 @@
+import type { Sensor } from "~/schema";
+import MobileBoxLayer from "./mobile-box-layer";
+import { HIGH_COLOR, LOW_COLOR } from "./color-palette";
+import { useEffect, useRef, useState } from "react";
+
+export default function MobileBoxView({ sensors }: { sensors: Sensor[] }) {
+ return (
+
+ {sensors.map((sensor) => (
+
+ ))}
+
+ );
+}
+
+function SensorView({ sensor }: { sensor: Sensor }) {
+ const [minColor, setMinColor] = useState(LOW_COLOR);
+ const [maxColor, setMaxColor] = useState(HIGH_COLOR);
+
+ return (
+ <>
+
-)
-DialogPortal.displayName = DialogPrimitive.Portal.displayName
+);
+DialogPortal.displayName = DialogPrimitive.Portal.displayName;
const DialogOverlay = React.forwardRef<
React.ElementRef